Spring AOP學習筆記05:AOP失效的罪因

木瓜芒果 發表於 2020-06-29

  前面的文章中我們介紹了Spring AOP的簡單使用,並從原始碼的角度學習了其底層的實現原理,有了這些基礎之後,本文來討論一下Spring AOP失效的問題,這個問題可能我們在平時工作中或多或少也會碰到。這個話題應該從同一個物件內的巢狀方法呼叫攔截失效說起。

1. 問題的現象

  假設我們有如下物件類定義(同一物件內方法巢狀呼叫的目標物件示例):

public class NestableInvocationDemo {
    public void method1(){
        method2();
        System.out.println("method1 executed!");
    }

    public void method2(){
        System.out.println("method2 executed!");
    }
}

  這個類定義中需要我們關注的是它的某個方法會呼叫同一物件上定義的其他方法。這通常是比較常見的,在NestableInvocationDemo類中,method1()方法呼叫了同一個物件的method2()方法。

  現在,我們要使用Spring AOP攔截該類定義的method1()和method2()方法,比如一個簡單的效能檢測,我們定義一個Aspect:

@Aspect
public class PerformanceTraceAspect {

    @Pointcut("execution(public void *.method1())")
    public void method1(){}

    @Pointcut("execution(public void *.method2())")
    public void method2(){}

    @Pointcut("method1() || method2()")
    public void compositePointcut(){};

    @Around("compositePointcut()")
    public Object performanceTrace(ProceedingJoinPoint joinpoint) throws Throwable{
        StopWatch watch = new StopWatch();
        try{
            watch.start();
            return joinpoint.proceed();
        }finally{
            watch.stop();
            System.out.println("PT in method[" + joinpoint.getSignature().getName() + "]>>>>" + watch.toString());
        }
    }
}

配置檔案如下: 

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns="http://www.springframework.org/schema/beans"
       xmlns:aop = "http://www.springframework.org/schema/aop"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
     http://www.springframework.org/schema/beans/spring-beans-4.0.xsd
     http://www.springframework.org/schema/aop
     http://www.springframework.org/schema/aop/spring-aop-4.3.xsd">
     
    <aop:aspectj-autoproxy/>
    <bean id = "nestableInvocationDemo" class = "xxx.xxx.NestableInvocationDemo"></bean>
    <bean class = "xxx.xxx.PerformanceTraceAspect"></bean>
</beans>

執行如下程式碼:

public static void main(String[] args) { 

    ClassPathXmlApplicationContext factory = new ClassPathXmlApplicationContext("spring/demo/aop.xml");
    NestableInvocationDemo demo = factory.getBean("nestableInvocationDemo", NestableInvocationDemo.class);
    demo.method2();
    demo.method1();

}

輸出如下結果:

method2 executed!
PT in method[method2]>>>>StopWatch '': running time (millis) = 11; [] took 11 = 100%
method2 executed!
method1 executed!
PT in method[method1]>>>>StopWatch '': running time (millis) = 0; [] took 0 = 0%

   發現問題沒有?當我們從外部直接呼叫NestableInvocationDemo物件的method2()時,顯示攔截成功了,但是當呼叫method1()時,卻只有method1()方法的執行攔截成功,其內部的method2()方法執行卻沒有被攔截,因為輸出日誌只有PT in method[method1]的資訊。這說明部分AOP失效了,這是什麼原因呢,我們接著往下看。

 

2. 原因的分析

  這種結果的出現,歸根結底是由Spring AOP的實現機制造成的。我們知道,Spring AOP採用代理模式實現AOP,具體的橫切邏輯會被新增到動態生成的代理物件中,只要呼叫的是目標物件的代理物件上的方法,通常就可以保證目標物件上的方法可以被攔截。就像NestableInvocationDemo的method2()方法執行一樣,當我們呼叫代理物件上的method2()時,目標物件的method2()就會被成功攔截。

  不過,代理模式的實現機制在處理方法呼叫的時序方面,會給使用這種機制實現的AOP產品造成一個小小的“缺憾”。我們來看一下代理物件方法與目標物件方法的呼叫時序:

proxy.method2{
    記錄方法呼叫開始時間;
    target.method2;
    記錄方法呼叫結束時間;
    計算消耗的時間並記錄到日誌;
}

  在代理物件方法中,不管如何新增橫切邏輯,也不管新增多少橫切邏輯,有一點是確定的。那就是,終歸需要呼叫目標物件上的同一方法來執行最初所定義的方法邏輯。

  如果目標物件中原始方法呼叫依賴於其他物件,那沒問題,我們可以為目標物件注入所依賴物件的代理,並且可以保證相應Joinpoint被攔截並織入橫切邏輯。而一旦目標物件中的原始方法呼叫直接呼叫自身方法的時候,也就是說,它依賴於自身所定義的其他方法的時候,問題就來了,看下面的圖會更清楚。

Spring AOP學習筆記05:AOP失效的罪因

  在代理物件的method1方法執行經歷了層層攔截器之後,最終會將呼叫轉向目標物件上的method1,之後的呼叫流程全部是走在TargetObject之上,當method1呼叫method2時,它呼叫的是TargetObject上的method2,而不是ProxyObject上的method2。要知道,針對method2的橫切邏輯,只織入到了ProxyObject上的method2方法中,所以,在method1中所呼叫的method2沒有能夠被成功攔截。

 

3. 解決方案

  知道原因,我們才可以“對症下藥”了。

  當目標物件依賴於其他物件時,我們可以通過為目標註入依賴物件的代理物件,來解決相應的攔截問題。那麼,當目標物件依賴於自身時,我們也可以嘗試將目標物件的代理物件公開給它,只要讓目標物件呼叫自身代理物件上的相應方法,就可以解決內部呼叫的方法沒有被攔截的問題。

   Spring AOP提供了AopContext來公開當前目標物件的代理物件,我們只要在目標物件中使用AopContext.currentProxy()就可以取得當前目標物件所對應的代理物件。現在,我們重構目標物件,讓它直接呼叫它的代理物件的相應方法,如下面程式碼所示:

public class NestableInvocationDemo {
    public void method1(){
        ((NestableInvocationDemo)AopContext.currentProxy()).method2();
        System.out.println("method1 executed!");
    }

    public void method2(){
        System.out.println("method2 executed!");
    }
}

  要使AopContext.currentProxy()生效,我們在生成目標物件的代理物件時,需要設定expose-proxy為true,具體如下設定:

  在基於配置檔案的配置中,可按如下方式配置:

<aop:aspectj-autoproxy expose-proxy = "true"/>

  在基於註解地配置中,可按如下方式配置:

@EnableAspectJAutoProxy(proxyTargteClass = true, exposeProxy = true)

  現在,我們可以得到想要的攔截結果:

method2 executed!
PT in method[method2]>>>>StopWatch '': running time (millis) = 12; [] took 12 = 100%
method2 executed!
PT in method[method2]>>>>StopWatch '': running time (millis) = 0; [] took 0 = 0%
method1 executed!
PT in method[method1]>>>>StopWatch '': running time (millis) = 0; [] took 0 = 0%

  這種方式是可以解決問題,但是不是很優雅,因為我們的目標物件都直接繫結到了Spring AOP的具體API上了。所以,我們考慮能夠通過其他方式來解決這個問題,既然我們知道能夠通過AopContext.currentProxy()取得當前目標物件對應的代理物件,那完全可以在目標物件中宣告對其代理物件的依賴,通過IoC容器來幫助我們注入這個代理物件。

  注入方式可以有多種:

  • 可以在目標物件中宣告一個例項變數作為其代理物件的引用,然後由構造方法注入或者setter方法注入將AopContext.currentProxy()取得的Object注入給這個宣告的例項變數;
  • 在目標物件中宣告一個getter方法,如getThis(),然後通過Spring的IoC容器的方法注入或者方法替換,將這個方法的邏輯替換為return AopContext.currentProxy()。這樣,在呼叫自身方法的時候,直接通過getThis().method2()就可以了;
  • 宣告一個Wrapper類,並且讓目標物件依賴於這個類。在Wrapper類中直接宣告一個getProxy()或者類似的方法,將return AopContext.currentProxy()類似邏輯新增到這個方法中,目標物件只需要getWrapper().getProxy()就可以取得相應的代理物件。Wrapper類分離了目標物件與Spring API的直接耦合。至於讓這個Wrapper以Util類出現,還是在目標物件中直接構造,或者依賴注入到目標物件,都可以;
  • 為類似的目標物件宣告統一的介面定義,然後通過BeanPostProcessor處理這些介面實現類,將實現類的某個取得當前物件的代理物件的方法邏輯覆蓋掉。這個與方法替換所使用的原理一樣,只不過可以藉助Spring的IoC容器進行批量處理而已。

  實際上,這種情況的出現僅僅是因為Spring AOP採用的是代理機制實現。如果像AspectJ那樣,直接將橫切邏輯織入目標物件,那麼代理物件和目標物件實際上就合為一體了,呼叫也不會出現這樣的問題。

 

4. 總結 

  本文揭示了Spring AOP實現機制導致的一個小小的陷阱,分析了問題產生的原因,並給出了一些解決方案。

  應該說,Spring AOP作為一個輕量的AOP框架,在簡單與強大之間取得了很好的平衡。合理地使用Spring AOP,將幫助我們更快更好地完成各種工作,也希望大家在Spring AOP地使用之路上愉快地前行。