1. 程式人生 > >[AOP] 2. AOP的兩種實現-Spring AOP以及AspectJ

[AOP] 2. AOP的兩種實現-Spring AOP以及AspectJ

在接觸Spring以及種類繁多的Java框架時,很多開發人員(至少包括我)都會覺得註解是個很奇妙的存在,為什麼加上了@Transactional之後,方法會在一個事務的上下文中被執行呢?為什麼加上了@Cacheable之後,方法的返回值會被記錄到快取中,從而讓下次的重複呼叫能夠直接利用快取的結果呢?

隨著對AOP的逐漸應用和了解,才明白註解只是一個表象,在幕後Spring AOP/AspectJ做了大量的工作才得以實現這些神奇的功能。

那麼,本文就來聊一聊Spring AOP和AspectJ的那些事,它們究竟有什麼魔力才讓這一切成為現實。

Spring AOP

基於代理(Proxy)的AOP實現

首先,這是一種基於代理(Proxy)的實現方式。下面這張圖很好地表達了這層關係:

這張圖反映了參與到AOP過程中的幾個關鍵元件(以@Before Advice為例):

  1. 呼叫者Beans - 即呼叫發起者,它只知道目標方法所在Bean,並不清楚代理以及Advice的存在
  2. 目標方法所在Bean - 被呼叫的目標方法
  3. 生成的代理 - 由Spring AOP為目標方法所在Bean生成的一個代理物件
  4. Advice - 切面的執行邏輯

它們之間的呼叫先後次序反映在上圖的序號中:

  1. 呼叫者Bean嘗試呼叫目標方法,但是被生成的代理截了胡
  2. 代理根據Advice的種類(本例中是@Before Advice),對Advice首先進行呼叫
  3. 代理呼叫目標方法
  4. 返回呼叫結果給呼叫者Bean(由代理返回,沒有體現在圖中)

為了理解清楚這張圖的意思和代理在中間扮演的角色,不妨看看下面的程式碼:

@Component
public class SampleBean {

  public void advicedMethod() {

  }

  public void invokeAdvicedMethod() {
    advicedMethod();
  }

}

@Aspect
@Component
public class SampleAspect {

  @Before("execution(void advicedMethod())"
) public void logException() { System.out.println("Aspect被呼叫了"); } } sampleBean.invokeAdvicedMethod(); // 會打印出 "Aspect被呼叫了" 嗎?

SampleBean扮演的就是目標方法所在Bean的角色,而SampleAspect扮演的則是Advice的角色。很顯然,被AOP修飾過的方法是advicedMethod(),而非invokeAdvicedMethod()。然而,invokeAdvicedMethod()方法在內部呼叫了advicedMethod()。那麼會打印出來Advice中的輸出嗎?

答案是不會

如果想不通為什麼會這樣,不妨再去仔細看看上面的示意圖。

這是在使用Spring AOP的時候可能會遇到的一個問題。類似這種間接呼叫不會觸發Advice的原因在於呼叫發生在目標方法所在Bean的內部,和外面的代理物件可是沒有半毛錢的關係哦。我們可以把這個代理想象成一箇中介,只有它知道Advice的存在,呼叫者Bean和目標方法所在Bean知道彼此的存在,但是對於代理或者是Advice卻是一無所知的。因此,沒有通過代理的呼叫是絕無可能觸發Advice的邏輯的。如下圖所示:

Spring AOP的兩種實現方式

Spring AOP有兩種實現方式:

  • 基於介面的動態代理(Dynamic Proxy)
  • 基於子類化的CGLIB代理

我們在使用Spring AOP的時候,一般是不需要選擇具體的實現方式的。Spring AOP能根據上下文環境幫助我們選擇一種合適的。那麼是不是每次都能夠這麼”智慧”地選擇出來呢?也不盡然,下面的例子就反映了這個問題:

@Component
public class SampleBean implements SampleInterface {

  public void advicedMethod() {

  }

  public void invokeAdvicedMethod() {
    advicedMethod();
  }

}

public interface SampleInterface {}

在上述程式碼中,我們為原來的Bean實現了一個新的介面SampleInterface,這個介面中並沒有定義任何方法。這個時候,再次執行相關測試程式碼的時候就會出現異常(摘錄了部分異常資訊):

org.springframework.beans.factory.BeanCreationException: 
Error creating bean with name 'com.destiny1020.SampleBeanTest': 
Injection of autowired dependencies failedCaused by: 
org.springframework.beans.factory.NoSuchBeanDefinitionException: 
No qualifying bean of type [com.destiny1020.SampleBean] found for dependency: 
expected at least 1 bean which qualifies as autowire candidate for this dependency. 

也就是說在Test類中對於Bean的Autowiring失敗了,原因是建立SampleBeanTest Bean的時候發生了異常。那麼為什麼會出現建立Bean的異常呢?從異常資訊來看並不明顯,實際上這個問題的根源在於Spring AOP在建立代理的時候出現了問題。

這個問題的根源可以在這裡得到一些線索:

文件中是這樣描述的(每段後加上了翻譯):

Spring AOP defaults to using standard JDK dynamic proxies for AOP proxies. This enables any interface (or set of interfaces) to be proxied.

Spring AOP預設使用標準的JDK動態代理來實現AOP代理。這能使任何藉口(或者一組介面)被代理。

Spring AOP can also use CGLIB proxies. This is necessary to proxy classes rather than interfaces. CGLIB is used by default if a business object does not implement an interface. As it is good practice to program to interfaces rather than classes; business classes normally will implement one or more business interfaces. It is possible to force the use of CGLIB, in those (hopefully rare) cases where you need to advise a method that is not declared on an interface, or where you need to pass a proxied object to a method as a concrete type.

Spring AOP也使用CGLIB代理。對於代理classes而非介面這是必要的。如果一個業務物件沒有實現任何介面,那麼預設會使用CGLIB。由於面向介面而非面向classes程式設計是一個良好的實踐;業務物件通常都會實現一個或者多個業務介面。強制使用CGLIB也是可能的(希望這種情況很少),此時你需要advise的方法沒有被定義在介面中,或者你需要向方法中傳入一個具體的物件作為代理物件。

因此,上面異常的原因在於:

強制使用CGLIB也是可能的(希望這種情況很少),此時你需要advise的方法沒有被定義在介面中。

我們需要advise的方法是SampleBean中的advicedMethod方法。而在新增介面後,這個方法並沒有被定義在該介面中。所以正如文件所言,我們需要強制使用CGLIB來避免這個問題。

強制使用CGLIB很簡單:

@Configuration
@EnableAspectJAutoProxy(proxyTargetClass = true)
@ComponentScan(basePackages = "com.destiny1020")
public class CommonConfiguration {}

@EnableAspectJAutoProxy註解中新增屬性proxyTargetClass = true即可。
CGLIB實現AOP代理的原理是通過動態地建立一個目標Bean的子類來實現的,該子類的例項就是AOP代理,它建立起了目標Bean到Advice的聯絡。

當然還有另外一種解決方案,那就是將方法定義宣告在新建立的介面中並且去掉之前新增的proxyTargetClass = true

@Component
public class SampleBean implements SampleInterface {

  @Override
  public void advicedMethod() {

  }

  @Override
  public void invokeAdvicedMethod() {
    advicedMethod();
  }

}

public interface SampleInterface {

  void invokeAdvicedMethod();

  void advicedMethod();

}

@Configuration
@EnableAspectJAutoProxy
@ComponentScan(basePackages = "com.destiny1020")
public class CommonConfiguration {}

這樣就讓業務物件實現了一個介面,從而能夠使用基於標準JDK的動態代理來完成Spring AOP代理物件的建立。

從Debug Stacktrace的角度也可以看出這兩種AOP實現方式上的區別:

  • JDK動態代理

  • CGLIB

關於動態代理和CGLIB這兩種方式的簡要總結如下:

  • JDK動態代理(Dynamic Proxy)

    • 基於標準JDK的動態代理功能
    • 只針對實現了介面的業務物件
  • CGLIB

    • 通過動態地對目標物件進行子類化來實現AOP代理,上面截圖中的SampleBean$$EnhancerByCGLIB$$1767dd4b即為動態建立的一個子類
    • 需要指定@EnableAspectJAutoProxy(proxyTargetClass = true)來強制使用
    • 當業務物件沒有實現任何介面的時候預設會選擇CGLIB

AspectJ

AspectJ是Eclipse旗下的一個專案。至於它和Spring AOP的關係,不妨可將Spring AOP看成是Spring這個龐大的整合框架為了整合AspectJ而出現的一個模組。

畢竟很多地方都是直接用到AspectJ裡面的程式碼。典型的比如@Aspect@Around@Pointcut註解等等。而且從相關概念以及語法結構上而言,兩者其實非常非常相似。比如Pointcut的表示式語法以及Advice的種類,都是一樣一樣的。

那麼,它們的區別在哪裡呢?

最大的區別在於兩者實現AOP的底層原理不太一樣:

  • Spring AOP: 基於代理(Proxying)
  • AspectJ: 基於位元組碼操作(Bytecode Manipulation)

用一張圖來表示AspectJ使用的位元組碼操作,就一目瞭然了:

通過編織階段(Weaving Phase),對目標Java型別的位元組碼進行操作,將需要的Advice邏輯給編織進去,形成新的位元組碼。畢竟JVM執行的都是Java原始碼編譯後得到的位元組碼,所以AspectJ相當於在這個過程中做了一點手腳,讓Advice能夠參與進來。

而編織階段可以有兩個選擇,分別是載入時編織(也可以成為執行時編織)和編譯時編織:

載入時編織(Load-Time Weaving)

顧名思義,這種編織方式是在JVM載入類的時候完成的。

使用它需要進行相關的配置,舉例如下:

在類路徑的META-INF目錄下建立一個檔名為aop.xml:

<aspectj>
  <weaver>
    <include within="com.destiny1020..*" />
  </weaver>
  <aspects>
    <aspect name="com.destiny1020.SampleAspect" />
  </aspects>
</aspectj>

然後新增啟動引數,直接使用AspectJ提供的或者使用Spring提供的工具:

# AspectJ
-javaagent:path_to/aspectjweaver-{version}.jar

# Spring
-javaagent:path_to/org.springframework.instrument-{version}.jar

當使用Spring提供的工具時,還需要進行一些配置,以JavaConfig為例:

@Configuration
@EnableLoadTimeWeaving
@ComponentScan(basePackages = "com.destiny1020")
public class CommonConfiguration {}

重點就是上述的@EnableLoadTimeWeaving

編譯時編織(Compile-Time Weaving)

需要使用AspectJ的編譯器來替換JDK的編譯器。可以藉助Maven AspectJ來實現,下面是一例:

<plugin>
    <groupId>org.codehaus.mojo</groupId>
    <artifactId>aspectj-maven-plugin</artifactId>
    <version>1.4</version>
    <dependencies>
        <dependency>
            <groupId>org.aspectj</groupId>
            <artifactId>aspectjrt</artifactId>
            <version>${aspectj.version}</version>
        </dependency>
        <dependency>
            <groupId>org.aspectj</groupId>
            <artifactId>aspectjtools</artifactId>
            <version>${aspectj.version}</version>
        </dependency>
    </dependencies>
    <executions>
        <execution>
            <phase>process-sources</phase>
            <goals>
                <goal>compile</goal>
                <goal>test-compile</goal>
            </goals>
        </execution>
    </executions>
    <configuration>
        <outxml>true</outxml>
        <source>${java.version}</source>
        <target>${java.version}</target>
    </configuration>
</plugin>

然後直接通過mvn test進行測試:

自定義的編譯錯誤/警告

舉個例子,有兩個Service1和Service2分別位於兩個包Package1和Package2下,只能在Package2中呼叫來自本包內部的方法,在Service1中呼叫Service2中提供的方法會導致編譯錯誤(能夠用訪問控制符解決的問題強行用這種方式來解決,當然只是為了說明問題:)):

@Aspect
public class EmitCompilationErrorAspect {

  @DeclareError("call (* com.destiny1020.biz.package2..*.*(..))"
      + "&& !within(com.destiny1020.biz.package2..*)")
  public static final String notInBizPackage2 = "只能在Package2中呼叫來自Package2的方法";

}
package com.destiny1020.biz.package1;

import com.destiny1020.biz.package2.ServiceInPackage2;

public class ServiceInPackage1 {

  ServiceInPackage2 service2 = new ServiceInPackage2();

  public void invokeMethodInPackage2() {
    service2.doBizInPackage2();  // 這裡理應會出現編譯錯誤
  }

}

實際情況也正式如此:

在宣告編譯錯誤Pointcut的時候,出現了兩個新概念:

  • call
  • within

這兩個新出現的Pointcut原語只能在使用AspectJ作為AOP實現的時候才可用。它們表達的是什麼意思呢:

  • call:針對所有的呼叫者(caller),即哪裡呼叫了Pointcut表示式匹配的方法,在該方法被執行之前就會被匹配到;而我們經常使用的execution則是針對所有的被呼叫方法,而不會care是誰呼叫的該方法
  • within:這個很好理解,它的Pointcut表示式是一個用來匹配完整限定類名的表示式,比如上例中的!within(com.destiny1020.biz.package2..*)意味不在包com.destiny1020.biz.package2中的類。

在使用AspectJ的編譯時編織功能時,由於使用了AspectJ Compiler來完成程式碼的編譯,因此可以根據編碼規範新增相應的編譯錯誤/警告,來進一步地讓程式碼更加規範。這個特性對於輔助實現大型專案的編碼規範還是很有益處的。

哪種方式更好

先下結論:It Depends.

得根據具體需求,不過我個人認為在對AOP的需求不那麼深入和迫切的時候,使用Spring AOP足矣。

畢竟Spring作為一個以整合起家的框架,在設計Spring AOP的時候也是為了減輕開發人員負擔而做了不少努力的。它提供的開箱即用(Out-of-the-box)的眾多AOP功能讓很多開發人員甚至都不知道什麼是AOP,就算知道了AOP是Spring的一大基石或者@Transactional和@Cacheable等等常用註解是藉助了AOP的力量,但是再深入恐怕就有點勉為其難了。這是優點也是缺點,當需要對AOP的實現做出精細化調整的時候,就會有力不從心的感覺。

這個時候,就可以考慮使用AspectJ。AspectJ的功能更加全面和強大。支援全部的Pointcut型別。

這裡進行了一個簡單的比較,摘錄並簡單翻譯(括號內是我新增的補充)如下:

Spring-AOP Pros

  • 比AspectJ更簡單,不需要使用Load-Time Weaving以及AspectJ編譯器(為了Compile-Time Weaving)
  • 當使用@Aspect註解時可以很方便的遷移到AspectJ AOP實現
  • 使用代理模式和裝飾模式

Spring-AOP Cons

  • 由於是基於代理的AOP,所以基本上只能選擇方法execution這一個Pointcut原語
  • 在類本身中呼叫另一個方法的時候Aspects不會生效
  • 有一點執行時的額外開銷
  • 無法為不是從Spring Factory中建立的物件新增Aspect(只對Spring Bean有效)

AspectJ Pros

  • 支援所有的Pointcut原語,這意味著你可以做任何事情
  • 執行時開銷比Spring AOP少
  • 能夠新增各種編譯錯誤來保障程式碼質量(這一條是我自己新增的)

AspectJ Cons

  • 當心。檢查是否發生了意料之外的Weaving操作
  • 使用Compile-Time Weaving時需要額外的構建步驟(使用AspectJ Compiler),在使用Load-Time Weaving時需要一些配置(-javaassist)

參考資料