
框架的意義
對于程式員來說,我們通常知道很多概念,例如組件、模塊、系統、框架、架構等,而本文我們重點說 框架,

- 框架,本質上是一些實用經驗集合,即是前輩們在實際開發程序中積攢下來的實戰經驗,累積成一套實用工具,避免你在開發程序中重復去造輪子,特別是幫你把日常中能遇到的場景或問題都給屏蔽掉,框架的意義在于屏蔽掉開發的基礎復雜度、屏蔽掉此類共性的東西,同時建立嚴格的編碼規范,讓框架使用者開箱即用,并且只需要關注差異面,即業務層面的實作,簡而言之,框架只干一件事,那就是 簡化開發,然后在此基礎上,可能會再考慮一些安全性、效率、性能、彈性、管理、拓展、解耦等等,
理解 Spring 核心
Spring 作為一個框架,目的也是:簡化開發 ,只不過在簡化開發的程序中 Spring 做了一個特別的設計,那就是 Bean管理,這也是 Spring 的設計核心,而 Bean 生命周期管理的設計巧妙的 解耦 了 Bean 之間的關系,
因此 Spring 核心特性就是 解耦 和 簡化,

Spring 框架圖示展示得很清晰,基本描繪出 Spring 框架的核心:
- 內核
- 外延
簡單說,就是 Spring 設計了一個 核心容器 Core Container,這里頭主要就是管理 Bean 生命周期,然后為了服務這些業務 Bean ,引入了 Core , Context , SpEL 等工具到核心容器中,然后在核心容器基礎上,又為了把更多的能力集成進來,例如為了拓展 資料訪問 能力加入了 JDBC 、ORM 、OXM 、JMS 、Transactions 等,為了拓展 Web 能力加入了 WebSocket 、Servlet、Web、Portlet 等,其中為了把 RequestMapping 或 Servlet 等這些使用集成到業務 Bean 上,引入了 AOP ,包括還有引入(最終是提供) Aspects、Instrumentation、Messageing 等增強方式,

所以仔細一看,Spring 就是把像資料庫訪問、Web支持、快取、訊息發送等等這些能力集成到業務 Bean 上,并提供一些測驗支持,總結來說理解 Spring 就兩點:
-
Bean管理: 解耦Bean關系,理解為內核,從 Bean 的定義、創建、管理等,這是業務Bean,
-
功能增強: 解耦功能、宣告式簡化,理解為外延,在業務Bean基礎上,需要訪庫等能力,那就是功能增強,
基本體現的就是兩個核心特性,一個 解耦、一個 簡化,

Bean管理 本身就是在做 解耦,解除耦合,這個解耦指 Bean 和 Bean 之間的關聯關系,Bean 之間通過介面協議互相串聯起來的,至于每個介面有多少個實作類,那都不會有任何影響,Bean 之間只保留單點通道,通過介面相互隔離,關系都交給 Spring 管理,這樣就避免了實作類和實作類之間出現一些耦合,就算方法增減了、參考變更了也不至于互相污染,
功能增強 本身就是在做 簡化,例如宣告式簡化,像宣告式編程,使用者只需要告訴框架他要什么,不用管框架是如何實作的,另外簡化方面還有 約定優于配置 (當然這個確切的說是 SpringBoot 里的設計),約定優于配置其實就是約定好了無需去做復雜的配置,例如你引入一個什么組件或能力就像 redis 或 kafka,你不需要提前配置,因為 springboot 已經為你默認配置,開箱即用,
因此 Spring 框架特性怎么理解?就 解耦 和 簡化 ,

而 SpringBoot,簡單理解就是在 Spring 框架基礎上添加了一個 SPI 可拓展機制 和 版本管理,讓易用性更高,簡化升級,

而 SpringCloud,簡單理解就是,由于 SpringBoot 的 依賴 可以被很好的管理,拓展 可以被可插拔的拓展,因此在 SpringBoot 基礎上集成了很多跟微服務架構相關的能力,例如集成了很多組件,便有了 SpringCloud 全生態,
基本了解了 Spring 特性之后,我們回到 Spring 的核心設計 IoC 與 AOP ,
IoC
我們說了 Spring 的其一特性是 解耦,那到底是使用什么來解耦?

控制反轉(Inversion of Control,縮寫為 IoC),是面向物件編程中的一種設計原則,可以用來減低計算機代碼之間的耦合度,其中最常見的方式叫做依賴注入(Dependency Injection,簡稱 DI),還有一種方式叫“依賴查找”(Dependency Lookup,EJB 和 Apache Avalon 都使用這種方式),通過控制反轉,物件在被創建的時候,由一個調控系統內所有物件的外界物體將其所依賴的物件的參考傳遞給它,也可以說,依賴被注入到物件中,
簡單來說,就是原本 Bean 與 Bean 之間的這種互相呼叫,變成了由 IoC 容器去統一調配,如果沒使用 IoC 容器統一管理業務 Bean,你的應用在部署、修改、迭代的時候,業務 Bean 是會侵入代碼實作并互相呼叫的,

那么問題來了,所有系統都需要引入 IOC 嗎?
IoC 容器是面向 迭代 起作用,如果你的應用就 不存在迭代 的情況,即系統是萬年不變的,那沒必要引入 IoC,因為你每引入一項技術,都勢必會增加復雜度,所以額外引入 IoC 也一樣會增加你整體應用的復雜度,所以假如 不存在迭代,大可直接寫死A類參考B類,B類又寫死參考C類,無需引入 IoC,一定要理解每一項技術背后是為了解決什么問題,同時在做架構設計的時候記住兩個原則:合適 、簡單,當然,實際上我們大部分應用是 持續迭代 的,在類實作上、互相參考上、甚至介面協議上都有可能變化,所以一般引入 IoC 是合適的(如果是介面協議變化,即引數或回傳值發生變化,那還是需要改動類間的代碼的),
具體的,IoC 相當于是把 Bean 實體的創建程序交給 Spring 管理,無論是通過 XML、JavaConfig,還是注解方式,最終都是把實體化的作業交給 Spring 負責,之后 Bean 之間通過介面相互呼叫,而實體化程序中就涉及到 注入,無論采用什么方式來實體化 Bean,注入 的類別就兩種:
- Setter注入 : 通過 setter 來設定,發生在物件 實體化之后 設定,
- 構造器注入 : 通過構造器注入,發生在物件 實體化之前 就得把引數/實體準備好,
setter注入:
- 與傳統的 JavaBean 的寫法更相似,程式開發人員更容易理解、接受,通過 setter 方法設定依賴關系顯得更加直觀、自然,
- 對于復雜的依賴關系,如果采用構造注入,會導致構造器過于臃腫,難以閱讀,Spring 在創建 Bean 實體時,需要同時實體化其依賴的全部實體,因而導致性能下降,而使用設值注入,則能避免這些問題,
- 尤其在某些成員變數可選的情況下,多引數的構造器更加笨重,
構造器注入:
- 構造器注入可以在構造器中決定依賴關系的注入順序,優先依賴的優先注入,
- 對于依賴關系無需變化的 Bean ,構造注入更有用處,因為沒有 setter 方法,所有的依賴關系全部在構造器內設定,無須擔心后續的代碼對依賴關系產生破壞,
- 依賴關系只能在構造器中設定,則只有組件的創建者才能改變組件的依賴關系,對組件的呼叫者而言,組件內部的依賴關系完全透明,更符合高內聚的原則,
而這兩種方式的注入方式都使用了 反射,

反射
了解反射相關類以及含義:
- java.lang.Class: 代表整個位元組碼,代表一個型別,代表整個類,
- java.lang.reflect.Method: 代表位元組碼中的方法位元組碼,代表類中的方法,
- java.lang.reflect.Constructor: 代表位元組碼中的構造方法位元組碼,代表類中的構造方法,
- java.lang.reflect.Field: 代表位元組碼中的屬性位元組碼,代表類中的成員變數(靜態變數+實體變數),
java.lang.reflect 包提供了許多反射類,用于獲取或設定實體物件,簡單來說,反射能夠:
- 在運行時 判斷任意一個物件所屬的類;
- 在運行時構造任意一個類的物件;
- 在運行時判斷任意一個類所具有的成員變數和方法;
- 在運行時呼叫任意一個物件的方法;
- 生成動態代理,
IoC 和 反射,只是把 Bean 的實體創建處理完,而后續還有 功能增強,功能增強靠的就是 AOP,
AOP
AOP全名 Aspect-Oriented Programming ,中文直譯為面向切面編程,當前已經成為一種比較成熟的編程思想,可以用來很好的解決應用系統中分布于各個模塊的交叉關注點問題,在輕量級的J2EE中應用開發中,使用AOP來靈活處理一些具有 橫切性質 的系統級服務,如事務處理、安全檢查、快取、物件池管理等,已經成為一種非常適用的解決方案,
為什么需要AOP
當我們要進行一些日志記錄、權限控制、性能統計等時,在傳統應用程式當中我們可能在需要的物件或方法中進行編碼,而且比如權限控制、性能統計大部分是重復的,這樣代碼中就存在大量 重復代碼,即使有人說我把通用部分提取出來,那必然存在呼叫還是存在重復,像性能統計我們可能只是在必要時才進行,在診斷完畢后要洗掉這些代碼;還有日志記錄,比如記錄一些方法訪問日志、資料訪問日志等等,這些都會滲透到各個要訪問方法中;還有權限控制,必須在方法執行開始進行審核,想想這些是多么可怕而且是多么無聊的作業,如果采用 Spring,這些日志記錄、權限控制、性能統計從業務邏輯中分離出來,通過 Spring 支持的面向切面編程,在需要這些功能的地方動態添加這些功能,無需滲透到各個需要的方法或物件中;有人可能說了,我們可以使用“代理設計模式”或“包裝器設計模式”,你可以使用這些,但還是需要通過編程方式來創建代理物件,還是要 耦合 這些代理物件,而采用 Spring 面向 切面 編程能提供一種更好的方式來完成上述功能,一般通過 配置 方式,而且不需要在現有代碼中添加任何額外代碼,現有代碼專注業務邏輯,

所以,AOP 以橫截面的方式插入到主流程中,Spring AOP 面向切面編程能幫助我們無耦合的實作:
- 性能監控,在方法呼叫前后記錄呼叫時間,方法執行太長或超時報警,
- 快取代理,快取某方法的回傳值,下次執行該方法時,直接從快取里獲取,
- 軟體破解,使用 AOP 修改軟體的驗證類的判斷邏輯,
- 記錄日志,在方法執行前后記錄系統操作日志,
- 作業流系統,作業流系統需要將業務代碼和流程引擎代碼混合在一起執行,那么我們可以使用AOP將其分離,并動態掛接業務,
- 權限驗證,方法執行前驗證是否有權限執行當前方法,沒有則拋出沒有權限執行例外,有業務代碼捕捉,
- 等等
AOP 其實就是從應用中劃分出來了一個切面,然后在這個切面里面插入一些 “增強”,最后產生一個增加了新功能的 代理物件,注意,是代理物件,這是Spring AOP 實作的基礎,這個代理物件只不過比原始物件(Bean)多了一些功能而已,比如 Bean預處理、Bean后處理、例外處理 等, AOP 代理的目的就是 將切面織入到目標物件,
AOP如何實作
前面我們說 IoC 的實作靠反射,然后解耦,那 AOP 靠啥實作?
AOP,簡單來說就是給物件增強一些功能,我們需要看 Java 給我們預留了哪些口或者在哪些階段,允許我們去織入某些增強功能,
我們可以從幾個層面來實作AOP,

-
編譯期
- 原理:在編譯器編譯之前注入源代碼,源代碼被編譯之后的位元組碼自然會包含這部分注入的邏輯,
- 代表作如:lombok, mapstruct(編譯期通過 pluggable annotation processing API 修改的),
-
運行期,位元組碼加載前
- 原理:位元組碼要經過 classloader(類加載器)加載,那我們可以通過 自定義類加載器 的方式,在位元組碼被自定義類加載器 加載前 給它修改掉,
- 代表作如:javasist, java.lang.instrument ,ASM(操縱位元組碼),
- 許多 agent 如 Skywaking, Arthas 都是這么搞,注意區分 靜態agent 與 動態agent,
- JVMTI 是 JVM 提供操作 native 方法的工具,Instrument 就是提供給你操縱 JVMTI 的 java 介面,詳情見 java.lang.instrument.Instrumentation
-
運行期,位元組碼加載后
- 原理:位元組碼被類加載器加載后,動態構建位元組碼檔案生成目標類的 子類,將切面邏輯加入到子類中,
- 代表作如:jdk proxy, cglib,
按照類別分類,基本可以理解為:
| 類別 | 原理 | 優點 | 缺點 |
|---|---|---|---|
| 靜態AOP | 在編譯期,切面直接以位元組碼的形式編譯到目標位元組碼檔案中 | 對系統無性能影響 | 靈活度不夠 |
| 動態AOP | 在運行期,目標類加載后,為介面動態生成代理類,將切面織入到代理類中 | 動態代理方式,相對于靜態AOP更加靈活 | 切入的關注點需要實作介面,對系統有一點性能影響 |
| 動態位元組碼生成 | 在運行期,目標類加載后,動態構建位元組碼檔案生成目標類的 子類,將切面邏輯加入到子類中 | 沒有介面也可以織入 | 擴展類的實體方法為final時,則無法進行織入,性能基本是最差的,因為需要生成子類嵌套一層,spring用的cglib就是這么搞的,所以性能比較差 |
| 自定義類加載器 | 在運行期,在位元組碼被自定義類加載器加載前,將切面邏輯加到目標位元組碼里,例如阿里的Pandora | 可以對絕大部分類進行織入 | 代碼中如果使用了其他類加載器,則這些類將不會被織入 |
| 位元組碼轉換 | 在運行期,所有類加載器加載位元組碼前,進行攔截 | 可以對所有類進行織入 | - |
當然,理論上是越早織入,性能越好,像 lombok,mapstruct 這類靜態AOP,基本在編譯期之前都修改完,所以性能很好,但是靈活性方面當然會比較差,獲取不到運行時的一些資訊情況,所以需要權衡比較,
簡單說明5種類別:

當然我整理了一份詳細的腦圖,可以直接在網頁上打開,
《腦圖:Java實作AOP思路》:
https://www.processon.com/embed/62333d1ce0b34d074452eec2
1、靜態AOP
發生在 編譯期,通過 Pluggable Annotation Processing API 修改原始碼,

在 javac 進行編譯的時候,會根據源代碼生成抽象語法樹(AST),而 java 通過開放 Pluggable Annotation Processing API 允許你參與修改源代碼,最終生成位元組碼,典型的代表就是 lombok,
2、動態AOP (動態代理)
發生在 運行期,于 位元組碼加載后,類、方法已經都被加載到方法區中了,

典型的代表就是 JDK Proxy,
public static void main(String[] args) {
// 需要代理的介面,被代理類實作的多個介面,都必須在這里定義
Class[] proxyInterface = new Class[]{IBusiness.class,IBusiness2.class};
// 構建AOP的Advice,這里需要傳入業務類的實體
LogInvocationHandler handler = new LogInvocationHandler(new Business());
// 生成代理類的位元組碼加載器
ClassLoader classLoader = DynamicProxyDemo.class.getClassLoader();
// 織入器,織入代碼并生成代理類
IBusiness2 proxyBusiness =
(IBusiness2)Proxy.newProxyInstance(classLoader, proxyInterface, handler);
// 使用代理類的實體來呼叫方法
proxyBusiness.doSomeThing2();
((IBusiness)proxyBusiness).doSomeThing();
}
其中代理實作 InvocationHandler 介面,最終實作邏輯在 invoke 方法中,生成代理類之后,只要目標物件的方法被呼叫了,都會優先進入代理類 invoke 方法,進行增強驗證等行為,
public class LogInvocationHandler implements InvocationHandler{
private Object target; // 目標物件
LogInvocationHandler(Object target){
this.target = target;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// 執行原有邏輯
Object rev = method.invoke(target,args);
// 執行織入的日志,你可以控制那些方法執行切入邏輯
if (method.getName().equals("doSomeThing2")){
// 記錄日志
}
return rev;
}
}
當然動態代理相對也是性能差,畢竟也多走了一層代理,每多走一層就肯定是越難以優化,
雖然,動態代理在運行期通過介面動態生成代理類,這為其帶來了一定的靈活性,但這個靈活性卻帶來了兩個問題:
- 第一代理類必須實作一個介面,如果沒實作介面會拋出一個例外,
- 第二性能影響,因為動態代理使用反射的機制實作的,首先反射肯定比直接呼叫要慢,經過測驗大概每個代理類比靜態代理多出10幾毫秒的消耗,其次使用反射大量生成類檔案可能引起 Full GC 造成性能影響,因為位元組碼檔案加載后會存放在JVM運行時區的方法區(或者叫持久代,JDK1.8 之后已經在元空間)中,當方法區滿的時候,會引起 Full GC ,所以當你大量使用動態代理時,可以將持久代設定大一些,減少 Full GC 次數,
關于動態代理的詳細原理和流程,推薦閱讀《一文讀懂Java動態代理》,
3、動態位元組碼生成
發生在 運行期,于 位元組碼加載后 ,生成目標類的子類,將切面邏輯加入到子類中,所以使用Cglib實作AOP不需要基于介面,
此時類、方法同樣已經都被加載到方法區中了,

典型的代表就是 Cglib(底層也是基于ASM操作位元組碼), Cglib 是一個強大的,高性能的 Code 生成類別庫,它可以在運行期間擴展Java類和實作Java介面,它封裝了 Asm,所以使用 Cglib 前需要引入 Asm 的jar,
public static void main(String[] args) {
byteCodeGe();
}
/**
* 動態位元組碼生成
*/
public static void byteCodeGe() {
//創建一個織入器
Enhancer enhancer = new Enhancer();
//設定父類
enhancer.setSuperclass(Business.class);
//設定需要織入的邏輯
enhancer.setCallback(new LogIntercept());
//使用織入器創建子類
IBusiness2 newBusiness = (IBusiness2) enhancer.create();
newBusiness.doSomeThing2();
}
/**
* 記錄日志
*/
public static class LogIntercept implements MethodInterceptor {
@Override
public Object intercept(
Object target,
Method method,
Object[] args,
MethodProxy proxy) throws Throwable {
//執行原有邏輯,注意這里是invokeSuper
Object rev = proxy.invokeSuper(target, args);
//執行織入的日志
if (method.getName().equals("doSomeThing")) {
System.out.println("recordLog");
}
return rev;
}
}
Spring 默認采取 JDK 動態代理 機制實作 AOP,當動態代理不可用時(代理類無介面)會使用 CGlib 機制,缺點是:
-
只能對方法進行切入,不能對介面、欄位、static靜態代碼塊、private私有方法進行切入,
-
同類中的互相呼叫方法將不會使用代理類,因為要使用代理類必須從Spring容器中獲取Bean,同類中的互相呼叫方法是通過 this 關鍵字來呼叫,spring 基本無法去修改 jvm 里面的邏輯,
-
使用 CGlib 無法對 final 類進行代理,因為無法生成子類了,
4、自定義類加載器
發生在 運行期,于 位元組碼加載前,在類加載到JVM之前直接修改某些類的 方法,并將 切入邏輯 織入到這個方法里,然后將修改后的位元組碼檔案交給虛擬機運行,

典型的代表就是 javasist,它可以獲得指定方法名的方法、執行前后插入代碼邏輯,
Javassist是一個編輯位元組碼的框架,可以讓你很簡單地操作位元組碼,它可以在運行期定義或修改Class,使用Javassist實作AOP的原理是在位元組碼加載前直接修改需要切入的方法,這比使用Cglib實作AOP更加高效,并且沒太多限制,實作原理如下圖:

我們使用系統類加載器啟動我們自定義的類加載器,在這個類加載器里加一個類加載監聽器,監聽器發現目標類被加載時就織入切入邏輯,我們再看看使用Javassist 實作 AOP 的代碼:
/***啟動自定義的類加載器****/
//獲取存放CtClass的容器ClassPool
ClassPool cp = ClassPool.getDefault();
//創建一個類加載器
Loader cl = new Loader();
//增加一個轉換器
cl.addTranslator(cp, new MyTranslator());
//啟動MyTranslator的main函式
cl.run("jsvassist.JavassistAopDemo$MyTranslator", args);
// 類加載監聽器
public static class MyTranslator implements Translator {
public void start(ClassPool pool) throws
NotFoundException, CannotCompileException {
}
/**
* 類裝載到JVM前進行代碼織入
*/
public void onl oad(ClassPool pool, String classname) {
if (!"model$Business".equals(classname)) {
return;
}
//通過獲取類檔案
try {
CtClass cc = pool.get(classname);
//獲得指定方法名的方法
CtMethod m = cc.getDeclaredMethod("doSomeThing");
//在方法執行前插入代碼
m.insertBefore("{ System.out.println(\"recordLog\"); }");
} catch (NotFoundException e) {
} catch (CannotCompileException e) {
}
}
public static void main(String[] args) {
Business b = new Business();
b.doSomeThing2();
b.doSomeThing();
}
}
CtClass 是一個class檔案的抽象描述,也可以使用 insertAfter() 在方法的末尾插入代碼,或者使用 insertAt() 在指定行插入代碼,
使用自定義的類加載器實作AOP在性能上要優于動態代理和Cglib,因為它不會產生新類,但是它仍然存在一個問題,就是如果其他的類加載器來加載類的話,這些類將不會被攔截,
5、位元組碼轉換
自定義的類加載器實作AOP只能攔截自己加載的位元組碼,那么有沒有一種方式能夠監控所有類加載器加載位元組碼呢?有,使用Instrumentation,它是 Java 5 提供的新特性,使用 Instrumentation,開發者可以構建一個位元組碼轉換器,在位元組碼加載前進行轉換,
發生在 運行期 ,于 位元組碼加載前,Java 1.5 開始提供的 Instrumentation API ,Instrumentation API 就像是 JVM 預先放置的后門,它可以攔截在JVM上運行的程式,修改位元組碼,
這種方式是 Java API 天然提供的,在 java.lang.instrumentation ,就算 javasist 也是基于此實作,
一個代理實作 ClassFileTransformer 介面用于改變運行時的位元組碼(class File),這個改變發生在 jvm 加載這個類之前,對所有的類加載器有效,class File 這個術語定義于虛擬機規范3.1,指的是位元組碼的 byte 陣列,而不是檔案系統中的 class 檔案,介面中只有一個方法:
/**
* 位元組碼加載到虛擬機前會進入這個方法
*/
@Override
public byte[] transform(
ClassLoader loader,
String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer)
throws IllegalClassFormatException;
// 把 classBeingRedefined 重定義之后再交還回去
ClassFileTransformer 需要添加到 Instrumentation 實體中才能生效,
安全點注意
當對 JVM 中的位元組碼進行修改的時候,虛擬機也會通知所有執行緒通過安全點的方式停下來,因為修改會影響到類結構,
啟動流程

Bean生命周期管理,基本從無到有(IoC),從有到增強(AOP),
任何Bean在Spring容器中只有三種形態,定義、實體、增強,
從Bean定義資訊觀察,通過 xml 定義 bean關系,properties、yaml、json定義 屬性,bean關系和屬性就構成Bean的定義,其中BeanDefinitionReader負責掃描定義資訊生成Bean定義物件 BeanDefinition,在此基礎上,允許對 BeanDefinition 定義進行增強(Mybatis與Spring存在很多使用場景),
Bean定義完成之后,開始通過反射實體化物件、填充屬性等,同時又再次預留了很多增強的口,最終生成一個完整的物件,
實體化流程與三級快取
從定義到擴展,然后反射實體化,到增強,每種狀態都會存在參考,
所以Spring設計 三級快取,說白了是對應存盤Bean生命周期的三種形態:
- 定義
- 實體
- 增強

總結
Spring 就是 反射 + 位元組碼增強,
-
反射,為了 IoC 和 解耦
-
位元組碼增強,為了 簡化 和宣告式編程
深刻理解 Spring 這兩部分核心特性,關于 spring、springboot、springcloud 的所有語法糖設計與使用,就自然清楚,
參考
- Understanding Java Agents
- Java 1.5-java.lang.instrument
- ASM 位元組碼插樁
- arthas
- ASM
- cglib
- javassist
- Javassist/ASM Audit Log
- bytebuddy tutorial
- Performance Comparison of cglib, Javassist, JDK Proxy and Byte Buddy
- 控制反轉
- AOP 的實作機制
- Spring AOP 總結
- javaAgent、ASM、javassist、ByteBuddy 是什么?
首發訂閱
這里記錄技術內容,不定時發布,首發在
- 潘深練個人網站
- 微信公眾號:潘潘和他的朋友們
(本篇完)
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/466945.html
標籤:Java
上一篇:Spring5
