會用就行了?你知道 AOP 框架的原理嗎?
前言
本文將從另一個角度講解 AOP,從 巨集觀的實現原理和設計本質 入手。大部分講 AOP 的博文都是一上來就羅列語法,然後敲個應用 demo就完了 。但學習不能知其然,不知其所以然。
對 AOP 我提出了幾點思考:AspectJ 為什麼會大熱?AspectJ 是怎樣工作的?和 Spring AOP 有什麼區別?什麼場景下適用?我們能不能自己實現一個 AOP 方法?
在熟悉原理前,如果想先掌握 AOP 的使用方法可以看:
一、引入
敲一個小 Demo 來引入主題,假設我想不依賴任何 AOP 方法,在特定方法的執行前後加上日誌列印。
第一種方式:寫死程式碼
定義一個目標類介面


把 before() 和 after() 方法寫死在 execute() 方法體中,非常不優雅,我們改進一下。
第二種方式:靜態代理

但是存在一個問題,隨著列印日誌的需求增多,Proxy 類越來越多,我們能不能保持只有一個代理呢?這時候我們就需要用到 JDK 動態代理了。
第三種方式:動態代理
新建動態代理類

客戶端呼叫

這又引出一個問題,日誌列印和業務邏輯耦合在一起,我們希望把前置和後置抽離出來,作為單獨的增強類。
第四種方式:動態代理 + 分離增強類
新建增強類介面和實現類

用反射代替寫死方法,解耦代理和操作者

客戶端呼叫

但是用了反射效能太差了,而且動態代理用起來也不方便,有沒有更好的辦法?
我們發現 Demo 存在種種問題
- 靜態代理每次都要自己新建個代理類,太繁瑣,重用性又差,一個代理不能同時代理多種類;
- 動態代理可以重用,但效能太差;
- 代理類耦合進被代理類的呼叫階段,萬一我需要改下 before、after 的方法名,可能會點燃一個炸彈;
- 代理攔截了一個類,就會攔截這個類的所有方法,難道我還要在代理類里加個 if-else 判斷特定方法過濾攔截?我們可以不可以只攔截特定的方法?
- 如果我既要列印日誌,又要計算方法執行用時,每次都要去改增強類嗎?
我們的訴求很簡單:1. 效能高;2. 鬆耦合;3. 步驟方便;4. 靈活性高。
那主流的 AOP 框架是怎麼解決這個問題的呢?我們趕緊來看看!
二、AOP 方法
不同的 AOP 方法原理略微有些不同,我們先看下 AOP 實現方式有哪些:
AOP方式 | 機制 | 說明 |
---|---|---|
靜態織入 | 靜態代理 | 直接修改原類,比如編譯期生成代理類的 APT |
靜態織入 | 自定義類載入器 | 使用類載入器啟動自定義的類載入器,並加一個類載入監聽器,監聽器發現目標類被載入時就織入切入邏輯,以 Javassist 為代表 |
動態織入 | 動態代理 | 位元組碼載入後,為介面動態生成代理類,將切面植入到代理類中,以 JDK Proxy 為代表 |
動態織入 | 動態位元組碼生成 | 位元組碼載入後,通過位元組碼技術為一個類建立子類,並在子類中採用方法攔截的技術攔截所有父類方法的呼叫織入邏輯。屬於子類代理,以 CGLIB 為代表 |
所有 AOP 方法 本質就是:攔截、代理、反射 (動態情況下),實現 原理可以看作是代理 / 裝飾設計模式的泛化, 為什麼這麼說?我們來詳細分析一下。
三、靜態織入原理,以 AspectJ 為例
靜態織入原理就是 靜態代理 ,我們以 AspectJ 為例。
1. AspectJ 設計思路
前面說到 Demo 存在的種種問題,AspectJ 是怎麼解決的呢?AspectJ 提供了兩套強大的機制:
(1)切面語法 | 解決業務和切面的耦合
AspectJ 中的切面,就解決了這個問題。
@Before("execution(* android.view.View.OnClickListener.onClick(..))")
我們可以通過切面,將增強類與攔截匹配條件(切點)組合在一起,從而生成代理。這 把是否要使用切面的決定權利還給了切面 ,我們在寫切面時就可以決定哪些類的哪些方法會被代理,從而 邏輯上不需要侵入業務程式碼 。
而普通的代理模式並沒有做到切面與業務程式碼的解耦,雖然將切面的邏輯獨立進了代理類,但是決定是否使用切面的權利仍然在業務程式碼中。這才導致了 Demo 中種種的麻煩。
AspectJ 提供了兩套對切面的描述方法:
- 我們常用的基於 java 註解切面描述的方法,寫起來十分方便,相容 Java 語法;
@Aspect public class AnnoAspect { @Pointcut("execution(...)") public void jointPoint() { } @Before("jointPoint()") public void before() { //... } @After("jointPoint()") public void after() { //... } }
- 基於 aspect 檔案的切面描述方法,這種語法不相容 Java 語法。
public aspect AnnoAspect { pointcut XX(): execution(...); before(): XX() { //... } after(): XX() { //... } }
(2)織入工具 | 解決代理手動呼叫的繁瑣
那麼切面語法讓切面從邏輯上與業務程式碼解耦,但是我要怎麼 找到特定的業務程式碼織入切面 呢?
兩種解決思路:一種就是提供註冊機制,通過額外的配置檔案指明哪些類受到切面的影響,不過這還是需要干涉物件建立的過程;另外一種解決思路就是在編譯期或類載入期先掃描切面,並將切面程式碼通過某種形式插入到業務程式碼中。
那 AspectJ 織入方式有兩種:一種是 ajc 編譯,可以在編譯期將切面織入到業務程式碼中。另一種就是 aspectjweaver.jar 的 agent 代理,提供了一個 Java agent 用於在類載入期間織入切面。
2. 通過 class 反推 AspectJ 實現機制
(1) @Before
機制
國際慣例寫個 Demo
- 自定義 AutoLog 註解

- 編寫 LogAspect 切面

- 在切入點中加上註解

反編譯後(請點開大圖檢視)

發現 AspectJ 會把呼叫切面的方法插入到切入點中,且封裝了切入點所在的方法名、所在類、入參名、入參值、返回值等等資訊,傳遞給切面,這樣就 建立了切面和業務程式碼的關聯 。
我們跟進 LogAspect.aspectOf().aroundJoinPoint(localJoinPoint);
一探究竟。

我們發現了什麼?其實 Before 和 After 的插入就是在匹配到的 JoinPoint 呼叫前後插入 Advise 方法,以此來達到攔截目標 JoinPoint 的作用。 如下圖所示:

(2) @Around
機制
- 自定義 SingleClick 註解

- 編寫 SingleClickAspect 切面

- 業務方加上註解

開啟編譯後的 class 檔案(請點開大圖檢視)

我們發現和 Before、After 織入不一樣了!前者的織入只是 在匹配的 JoinPoint 前後插入 Advise 方法 ,僅僅是插入。而 Around 拆分了業務程式碼和 Advise 方法,把業務程式碼遷移到新函式中, 通過一個單獨的閉包拆分來執行,相當於對目標 JoinPoint 進行了一個代理 ,所以 Around 情況下我們除了編寫切面邏輯,還需要手動呼叫 joinPoint.proceed() 來呼叫閉包執行原方法。
我們看下 proceed() 都做了些什麼

那這個 arc 是什麼?什麼時候拿到的呢?

繼續回溯

在 AroundClosure 閉包中,會把運行時物件和當前連線點 joinPoint 物件傳入,呼叫 linkClosureAndJoinPoint() 繫結兩端,這樣在 Around 中就可以通過 ProceedingJoinPoint.proceed() 呼叫 AroundClosure,進而呼叫到目標方法了。
那麼一圖總結 Around 機制:

我們從 AspectJ 編譯後的 class 檔案可以明顯看出執行的邏輯,proceed 方法就是回撥執行被代理類中的方法。
所以 AspectJ 做的事情如下:
-
首先從檔案列表裡取出所有的檔名,讀取檔案,進行分析;
-
掃描含有 aspect 的切面檔案;
-
根據切面中定義規則,攔截匹配的 JoinPoint ;
-
繼續讀取切面定義的規則,根據 around 或 before ,採用不同策略織入切面。
(3) @Before
@After
機制與 @Around
機制區別
- Before、After 僅僅是織入了 Advise 方法
- Around 使用了代理 + 閉包的方式進行替換
3. AspectJ 底層技術總結
分析完 class 你會發現,AspectJ 實際上就是用一種 特定語言 編寫切面,通過自己的語法編譯工具 ajc 編譯器來編譯,生成一個新的代理類,該代理類增強了業務類。
-
AspectJ 就是一個 程式碼生成工具 ;
編寫一段通用的程式碼,然後根據 AspectJ 語法定義一套程式碼生成規則,AspectJ 就會幫你把這段程式碼插入到對應的位置去。
-
AspectJ 語法就是用來 定義程式碼生成規則的語法 。
擴充套件編譯器,引入特定的語法來建立 Advise,從而在編譯期間就織入了Advise 的程式碼。
如果使用過 Java Compiler Compiler (JavaCC),你會發現兩者的程式碼生成規則的理念驚人相似。JavaCC 允許你在語法定義規則檔案中,加入你自己的 Java 程式碼,用來處理讀入的各種語法元素。
四、動態織入原理,以 Spring AOP 為例
動態織入原理就是 動態代理 。
1. Spring AOP 執行原理
Spring AOP 利用擷取的方式,對被代理類進行裝飾,以取代原有物件行為的執行,不會生成新類。
2. Spring AOP VS AspectJ
可能有的小夥伴會困惑了,Spring AOP 使用了 AspectJ,怎麼是動態代理呢?
那是因為 Spring 只是使用了與 AspectJ 一樣的註解,沒有使用 AspectJ 的編譯器,轉向採用動態代理技術的實現原理來構建 Spring AOP 的內部機制(動態織入),這是與 AspectJ(靜態織入)最根本的區別。
Spring 底層的動態代理分為兩種 JDK 動態代理和 CGLib:
-
JDK 動態代理用於 對介面的代理 ,動態產生一個實現指定介面的類,注意 動態代理有個約束 : 目標物件一定是要有介面的 ,沒有介面就不能實現動態代理,只能為介面建立動態代理例項,而不能對類建立動態代理。
-
CGLIB 用於 對類的代理 ,把被代理物件類的 class 檔案載入進來,修改其位元組碼生成一個繼承了被代理類的子類。使用 cglib 就是為了彌補動態代理的不足。
3. JDK 動態代理的原理
我們前面的 Demo 第三種方式使用了動態代理,我們不禁有了疑問,動態代理類及其物件例項是如何生成的?呼叫動態代理物件方法為什麼可以呼叫到目標物件方法?

我們通過 Proxy.newProxyInstance
可以動態生成指定介面的代理類的例項。我們來看下 newProxyInstance
內部實現機制。

代理物件會實現介面的所有方法,實現的方法交由我們自定義的 handler 來處理。

我們看下 getProxyClass0
方法,只憑一個類載入器、一個介面,是怎麼建立代理類的?

注意一下:Android 中動態代理類是直接生成,而 Java 是生成代理類的位元組碼,再根據位元組碼生成代理類。
那麼客戶端就可以 getProxy()
拿到生成的代理類 com.sun.proxy.$Proxy0

這個代理類繼承自 Proxy
並實現了我們被代理類的所有介面,在各個介面方法的內部,通過反射呼叫了 InvocationHandlerImpl
的 invoke
方法。
總結下步驟:
- 獲得被代理類的介面資訊,生成一個實現了代理介面的動態代理類;
- 通過反射獲得代理類的建構函式;
- 利用建構函式生成動態代理類的例項物件,在呼叫具體方法前呼叫 invokeHandler 方法來處理。

後記
1. 設計模式不能脫離業務場景
不知不覺我們複習了一下代理模式,設計模式必須依賴大量的業務場景,脫離業務去看設計模式是沒有意義的。
因為脫離了應用場景,即使理解了模式的內容和結構,也學不會在合適的時候應用。
2. 敢於追求優雅的程式碼
首先你要敢於追求優雅的程式碼,就像我們開頭的列印日誌的需求,不斷提出問題,不斷追求更好的解決方案,在新的方案上挖掘新的問題……如果你完全不追求設計,那自然是不會想到去研究設計模式的。
本篇完成耗時 26 個番茄鍾(650 分鐘)
我是 FeelsChaotic,一個寫得了程式碼 p 得了圖,剪得了視訊畫得了畫的程式媛,致力於追求程式碼優雅、架構設計和 T 型成長。
歡迎關注 FeelsChaotic 的簡書和 掘金 ,如果我的文章對你哪怕有一點點幫助,歡迎 :heart:!你的鼓勵是我寫作的最大動力!
最最重要的,請給出你的建議或意見,有錯誤請多多指正!