從今天開始,我們會花 3 天的時間進到 Spring AOP 技術的相關組件細節。
我們都知道 AOP 是一種針對 OOP data flow 的橫向封裝。傳統的 Spring Web 在接收到 API 請求後,依序會經過 Controller, Service, Repository 等縱向邏輯。而 AOP 的其中一種用途,就是可以讓我們在 layer 和 layer 之間完成一個統一的封裝邏輯,完成參數效驗、權限檢查、或日誌打印 ...等,省去開發者需要在每個 method 前後手動敲打原始邏輯的大量冗餘。
原生的 Spring 框架將與 AOP 有關的類別與介面統一收納在 spring-aop 模組裡面,並整合了 AspectJ 的 @Aspect、@Pointcut ...等註解。今天要介紹的 AopProxy,則是 Spring AOP 模組裡底層的核心介面之一,也是整個 AOP 功能的幕後推手,可以說如果沒有它,就沒有整個 Spring AOP。
AOP 介面提供了 2 個方法:getProxy 負責用來取得代理物件,本身是個多載,有分為無參數的 getProxy() 和帶有 classloader 參數的 getProxy(ClassLoader loader),兩者主要區別跟 Java 核心的運行機制比較有關,這裡可以先不用鑽研下去,所以就在這裡打住。
第二個方法是 getProxyClass,負責取得代理物件的實際型別,核心原因跟昨天提到 Factory-Bean 的 get-Object-Type 非常類似,同樣都是為了解耦型別宣告和物件實例化才額外抽出來的方法。AopProxy 的具體實作有 2 個,分別是 Jdk-Dynamic-Aop-Proxy(後面簡稱 JDK 動態代理)和 Cglib-Aop-Proxy(後面簡稱 Cglib 代理)。前者用來處理至少實作了一個介面的代理物件、後者則處理未實作任何介面的代理物件,也因為這兩個實體類也都很常見且同等知名,文章的後半段主要都會集中在這兩個物件上。
JdkDynamicAopProxy
JDK 動態代理是 Spring 早期代理功能的唯一實作機制。內部主要由 java lang reflect 的 Proxy 類別與 Invocation-Handler 介面來實現功能:說得具體一點,當我們利用 JDK 動態代理生成新的代理物件時,底層會調用到一個 Proxy 的靜態方法 newProxyInstance 進行代理物件的創建,newProxyInstance 會讓系統經過一個「在 JVM 裡面動態新增一個 class,並立刻 new 出新物件」的過程。
新的 Class 會繼承 Proxy 類別,且因為 Java 不允許多重繼承的嚴格條件,不能讓新的 proxy 繼承原物件,因此,new-Proxy-Instance 繞道 —— 不以繼承的方式定義物件的原代理資訊,而是改用介面實作,規定輸入的參數一定要有 interface,進而讓系統知道 proxy 物件有哪些方法可以呼叫、並提供呼叫端能把 Proxy 物件轉型成 XxxInterface 的能力。而這也是為什麼 JDK 動態代理只能針對有介面的類別進行代理的根本原因。
接著說說 Invocation-Handler:它同樣也是 reflect 的原生介面。內部提供一個 invoke 方法,目的是收斂代理物件執行目標方法的渠道,提供一個唯一的執行入口,並決定要在執行的前後插入什麼邏輯。具體來說,JdkDynamicAopProxy 的 invoke 方法主要可以分為 4 個步驟:
首先會先判斷目標方法是不是 equals, hashCode ...這類基礎方法,如果是,這邊就會直接提早短路,不走後續的 AOP 邏輯;第二步是向 AdvisedSupport 獲取攔截器鏈,從容器裡找出所有切在這個方法上的 Advisor, Advice;第三步就是核心的呼叫,用責任鏈的方式依序執行鏈上的 AOP 邏輯,一般切面具體的實現就是在這裡發生;最後一步則是返回值處理,如果原始的目標方法最後回傳 this,Spring 會在這裡把 this 替換回代理物件。
CglibAopProxy
因為 JDK 動態代理依賴「目標類別必須實作介面」這件事,這導致早期的 Spring 很常出現為了應付規範而寫的樣板程式,使系統充斥只有單一 impl 實作的 IXxxService 介面。為了解決這個問題,Spring 在隨後的幾個版本便引入了以 CGLIB 作為底層核心的另一種 AOP 實作方案。
CGLIB 本身是一個第三方套件,且跟 JDK 動態代理那種「透過介面反推方法清單」的做法不同,CGLIB 會使用一種比 java code 還要底層的 ASM 技術,在系統執行期間,動態新增一個「繼承自目標類別」的新代理類,並同樣創建唯一的代理物件出來。
這個做法的優點是繞開了「只能對有介面實作的類別進行代理」的這項限制,使系統可以減去冗餘的樣板介面,進而降低維護成本。不過,類似於 JDK 的動態代理,因為底層仍是使用繼承(或委託)之類的技術,因此無論是哪種代理,都無法對標注 final, private 等宣告的方法進行 AOP 的擴充,這點仍需注意。
接著講點預設實作的歷史:以 Spring boot 的演進來看,在還沒有 CGLIB 代理的時代,所有 AOP 切面都是由 JDK 動態代理完成的。這個應該沒有什麼問題;CGLIB 出現了之後,Spring boot 便進入「有介面就用 JDK 動態代理、沒有介面則改用 CGLIB 代理」的混合時代;但以現今的版本來說,Spring boot 底層已經將所有的代理功能都交由 CGLIB 代理執行了。
主要的原因是 private XxxServiceImpl xxxService 用 JDK 動態代理注入的話,可能會引發一些轉型上的問題:因為 Impl 是實體類,實作 XxxService 介面的代理類(Proxy)跟 Impl 並沒有繼承關係,所以嘗試注入的時候就會遇到 ClassCastException。實務上,如果系統可以保證這種寫法不會出現,且想要將實作的機制調整回混合方式的話,還是可以設定 spring.aop.proxy-target-class 為 false,切換回原本的混合模式。
最後,簡單講講 self-invocation 的問題:如果一個 Bean 有切到代理的相關邏輯,IoC 容器最後持有(並注入到其他 Bean)的物件,都會是最外層的代理物件。但如果我們在目標物件裡面,讓方法 A 使用 this.B() 呼叫同一個類別內的方法 B,需要注意這個 this 呼叫並不會觸發到 B 原本可能帶有的 AOP 邏輯。
原因是「不管哪種代理方式,代理物件最終都會將執行權轉發給目標物件的方法 A,因此真正執行 A 的仍然是目標物件,這時候的 this 就會是原本的目標物件,進而略過外層的代理邏輯。」這個算是 @Transactional 和 @Async 的常見小坑,可以留意一下。
但也不是沒有解決方式,最懶人的解法是注入 @Lazy 的自己,並把原有的 this.B() 都改為 xxxService.B(),透過依賴注入的方式取得代理物件,重新走回外層代理邏輯,進而觸發 B 的 AOP;另一種解法是重構整個類別,避免 AOP this 的情況發生,以 SRP 和單測的角度來說,這是我比較推薦的做法。
期待後續各位的閱讀與分享,我是 Pax,我們明天見。