Spring AOP詳解及其用法(一)

引言

在企業級服務中,經常面臨多個業務類中需要完成一些相同的事情,如日誌記錄、異常處理、事物管理、安全管理等,這些多個業務類共同關注的點也叫橫切關注點( cross-cutting concern )。如果在每個業務類中都加上這些橫切關注點邏輯,不僅工作量會很大,而且容易產生冗餘代碼。這時候爲解決橫切關注點的面向切面編程(AOP)應運而生,AOP補充了面向對象編程(OOP)。OOP中模塊化的關鍵單元是類,而在AOP中模塊化的單元是切面。切面支持跨多個類型和對象的關注點(例如事務管理)。

​ Spring的一個關鍵組件就是AOP框架,雖然Spring IOC容器並不依賴Spring AOP(這意味着你在不需要的時候可以不必在項目中引入Spring AOP依賴),但是AOP補充了Spring IoC,提供了一個非常強大的中間件解決方案。
Spring提供了兩種簡單而強大的自定義切面的方式:

  • 基於Schema的,也就是在XML配置文件中提供AOP配置和基於註解的AOP配置
  • 基於註解的AOP配置

這兩種方式都提供了豐富的通知功能和使用 AspectJ 切點表達式語言 的支持,但是在織入時仍然使用Spring AOP。

AOP在Spring框架中使用主要用於:

  • 提供聲名式企業服務,最重要的服務莫過於聲名式事物管理
  • 讓開發者實現自定義切面,在開發過程中使用AOP補充OOP編程的不足

1. AOP概念

首先,讓我們弄清楚一些AOP的核心核心概念和技術術語:

切面(Aspect):一個跨多個類關注的模塊,在企業級Java應用中的事物管理(Transaction Management)就是一個橫切關注點的很好例子。在Spring AOP中通過給普通POJO類在XML文件中進行AOP配置後者給普通POJO類添加@Aspect註解實現切面的定義。

連接點(Joint Point): 程序執行過程中一個點,例如方法的執行或者異常處理,在Spring AOP中,連接點始終代表方法的執行;

通知(Advice):切面在特定的連接點上發生的行爲,不通類型的通知包括"Around",“Before”,“After”,"After Returning"等通知(通知類型之後再討論)。很多AOP框架包含Spring,將通知看成攔截器和維護圍繞連接點的攔截器鏈。

切點(Pointcut): 匹配連接點的正則表達式,通知與切點表達式緊密關聯,並且運行在任意匹配切點表達式的連接點上(例如具有指定名字的方法的執行)。連接點與切入點表達式匹配的概念是AOP的核心,Spring默認使用AspectJ切入點表達式語言。

引入(Introduction):代表類型聲明其他方法或字段,Spring AOP允許你將新的接口(和相應的實現)引入任何被通知的對象。例如,你可以使用一個Introduction來讓一個bean實現一個IsModified接口,以簡化緩存。(Introduction在AspectJ社區中稱內部類聲明。)

目標對象(Target Object):被一個或多個切面通知的對象,也被稱作通知對象。由於Spring AOP是由運行時代理實現的,因此目標對象永遠是代理對象。

AOP代理:AOP框架爲實現切面邏輯而創建的一個通知方法執行的對象,在Spring框架中,AOP代理是指JDK動態代理或者CGLIB代理。

織入(Weaving):將切面與其他應用程序類型或對象鏈接以創建通知對象。這可以在編譯時(例如,使用AspectJ編譯器)、加載時或運行時完成。與其他純Java AOP框架一樣,Spring AOP在運行時執行織入。

Spring AOP包含以下5種通知:

  • 前置通知(Before Advice): 連接點方法執行前的通知,並不能阻止連接點方法流程的執行,除非執行過程中拋出異常;
  • 返回通知(After Returning Advice): 連接點正常執行流程之後返回時的通知(不拋出異常的情況下);
  • 異常通知(After Throwning Advice):連接點方法執行過程中拋出異常時的通知;
  • 後置通知(After Advice): 無論連接點方法是否發生異常都會執行的通知;
  • 環繞通知(Around Advice): 環繞連接點方法執行過程的通知,這是AOP 5種通知中功能最強大的通知 。環繞通知可以自定義連接點方法執行前後過程中的行爲。它也能選擇是執行連接點方法流程,還是通過返回連接點方法的返回值或拋出異常的方式剪切被通知方法的執行。

雖然環繞通知是5種通知中功能最強大的通知,Spring AOP也提供了各種類型的通知,但是我們還是建議你使用能實現你業務需求最弱功能的通知。例如你僅僅需要拿到一個方法的返回值去更新緩存,你最好使用後置通知。雖然使用環繞通知也能實現相同的業務,但是使用最準確的通知能夠簡化程序執行並儘可能地避免潛在的錯誤。

所有通知參數都是靜態類型的,因此你可以使用確定類型的通知參數(例如一個方法執行的返回值類型),而不是對象數組。

匹配切點表達式的連接點概念是AOP中的關鍵,它將AOP與只提供攔截的舊技術區分開來。切入點使通知能夠獨立於面向對象的層次結構。例如,你可以給分佈在服務層中的多個業務操作對象加上一個環繞通知以提供聲名式事物管理。

2. Spring AOP的功能和目標

Spring AOP是基於純Java對象實現的,不需要特殊的編譯過程。Spring AOP不需要控制類加載器層次結構,因此適合在servlet容器或應用服務器中使用。Spring AOP目前只支持方法級別的連接點攔截,通知必須加在Spring容器管理的Bean方法上,屬性級別的攔截目前還沒有實現。如果你需要屬性訪問和更新連接點,可以考慮使用AspectJ語言。

Spring AOP的AOP方法與大多數其他AOP框架不同。其目的不是提供最完整的AOP實現(儘管Spring AOP非常強大)。相反,其目標是提供AOP實現和Spring IoC之間的緊密集成,以幫助解決企業應用程序中的常見問題。

Spring框架的AOP功能通常與Spring IoC容器一起使用,切面(Aspect)是通過使用普通的bean定義語法來配置的(儘管這允許強大的“自動代理”功能)。這是與其他AOP實現的一個重要區別。使用Spring AOP不能輕鬆或有效地完成某些事情,比如通知非常細粒度的對象(通常是域對象)。在這種情況下,AspectJ是最佳選擇。然而,我們的經驗是,Spring AOP爲企業Java應用程序中大多數適合AOP的問題提供了一個優秀的解決方案。

Spring AOP從未試圖與AspectJ競爭來提供全面的AOP解決方案。我們認爲基於代理的框架(如Spring AOP)和成熟的框架(如AspectJ)都是有價值的,它們是互補的,而不是競爭的。Spring將Spring AOP和IoC與AspectJ無縫集成,從而在一致的基於Spring的應用程序體系結構中支持AOP的所有使用。這種集成並不影響Spring AOP API或AOP Alliance API,Spring AOP保持向後兼容。有關Spring AOP api的討論,請參閱下一章。

Spring框架的一個核心原則就是非侵入性,它的思想就是不強迫你將框架中指定的類或接口引入到你的業務或領域模型中。然而在某些地方,Spring框架給你引入Spring框架依賴到你代碼庫中的可選項。之所以提供這些選項,是因爲在某些場景中,以這種方式閱讀或編寫某些特定功能的代碼可能更容易。然而,Spring框架幾乎總是爲你提供這樣的選擇:你可以自由地做出明智的決定,即哪種選擇最適合你的特定用例或場景。

3 AspectJ支持

@AspectJ指在普通Java類上加上註解使之成爲切面類,@AspectJ註解是作爲AspectJ項目的一部分引入AspectJ5版本的。Spring使用AspectJ提供的用於切入點解析和匹配的庫來解釋與AspectJ 5相同的註解。但是AOP運行時仍然是純Spring AOP,並且不依賴於AspectJ編譯器。

3.1 開啓@AspectJ支持

要在Spring配置中使用@AspectJ切面,您需要啓用Spring對基於@AspectJ切面配置Spring AOP的支持,並根據這些切面是否通知自動代理bean。通過自動代理,如果Spring確定一個bean由一個或多個切面通知,它將自動爲該bean生成一個代理來攔截方法調用,並確保根據需要執行通知。

可以通過XML或java風格的配置啓用@AspectJ支持。在這兩種情況下,你還需要確保AspectJ的aspectjweaver.jar包位於應用程序的類路徑上(版本1.8或更高)。這個庫可以從AspectJ發行版的lib目錄或Maven中央存儲庫中獲得。

3.2 使用Java配置開啓@AspectJ支持

使用Java @Configurtion 配置開啓@AspectJ支持,你需要在Java配置類上添加@EnableAspectJAutoProxyl註解,示例代碼如下:

@Configuration
@EnableAspectJAutoProxy(proxyTargetClass=false,exposeProxy=false)
public class AspectjConfig{
    
}

3.3 使用XML配置開啓@AspectJ支持

使用基於XML配置開啓@AspectJ支持,在應用上下文applicationContext.xml配置文件中添加aop:aspectj-autoproxy元素標籤,示例代碼如下:

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:context="http://www.springframework.org/schema/context"    
    xmlns:aop="http://www.springframework.org/schema/aop"
    xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context
http://www.springframework.org/schema/context/spring-context.xsd        
http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop.xsd">
        <!--開啓aspectj自動代理支持-->
        <context: conponent-scan base-package="com.example"/>    
        <aop:aspectj-autoproxy proxy-target-class="false" exposeproxy="false"/>
        <!-- 在這裏配置bean -->
</beans>

Spring AOP可以使用JDK動態代理或CGLIB動態代理,如果目標對象沒有實現任何接口,Spring AOP會創建基於CGLIB的動態代理;如果目標對象實現了一個或多個接口,那麼Spring AOP會創建基於JDK的動態代理。如果需要強制使用CGLIB動態代理,可以將proxy-target-class屬性設置爲true(如果是使用註解風格,則將@EnableAspectJAutoProxy註解的proxyTargetClass方法值改爲true),這樣即使目標對象實現了一個或多個接口,Spring AOP也會創建CGLIB動態代理。而expose-proxy屬性設置爲true時(使用@EnableAspectJAutoProxy註解時將其exposeProxy方法值改爲true),則可以從ApplicationContext應用上下文中拿到動態代理對象。

4 聲名切面

開啓了@AspectJ支持後,任何在應用上下文中定義並具有@Aspect註解的Bean就是一個切面,它會被Spring容器自動發現並用來配置Spring AOP。下面的代碼示例展示了配置一個切面最小的配置需要:

  1. 在應用上下文中配置一個常規bean定義
<bean id="myAspect" class="com.example.aspect.AspectBean">
    <!-- configure properties of the aspect here -->
</bean>
  1. com.example.aspect.AspectBean上添加org.aspectj.lang.annotation.Aspect註解
package com.example.aspect;
import org.aspectj.lang.annotation.Aspect;

@Aspect
public class AspectBean {

}

切面類可以和其他類一樣具有方法和屬性,也可以包含切點、通知和引用聲名。

通過組件掃描自動發現切面
你可以在你的Spring XML文件中通過一個常規的bean定義,也可以通過類路徑掃描自動發現註冊切面,與其他Spring 管理的Bean一樣。注意添加@Aspect註解對於Spring在類路徑中自動發現切面還不夠,還需要添加@Component註解。

5 聲名切點

5.1 切點定義

切入點確定感興趣的連接點,從而使我們能夠控制何時執行通知。Spring AOP只支持Spring bean的方法執行連接點,因此可以將切入點看作是與Spring bean上的方法執行相匹配的。切入點聲明有兩部分:

  • 簽名:由名稱和任何參數組成;
  • 切點表達式:它確定我們對哪個方法執行感興趣。在AOP的@AspectJ註釋風格中,切入點簽名由一個常規方法定義提供,切入點表達式通過使用@Pointcut註解來表示(作爲切入點簽名的方法必須是void返回類型)。

下面的簡易代碼示例希望能幫助讀者弄清楚切點簽名和和切點表達式:

@Pointcut("execution(* transfer(..))") // 起點表達式
private void anyOldTransfer() { // 切點簽名
    
} 
5.2 Spring中支持的切點表達式

Spring AOP支持以下幾種AspectJ 切點指示器(PCD)用於切點表達式中

5.2.1 常用的切點指示器
  • execution: 用於匹配連接點方法執行,這是使用Spring AOP時使用的主要切點指示器,也是控制粒度最小的切點指示器;
  • within: 限制匹配連接點目標對象爲確定的類;
  • this: 限制匹配連接點爲具有指定bean引用類型的實例;
  • target: 限制匹配連接點目標對象爲指定類的實例;
  • args: 限制匹配連接點目標對象方法參數爲指定類型;
  • @target: 限制匹配連接點目標對象頭部有指定的註解類;
  • @args: 限制匹配連接點目標對象方法參數具有指定類型的註解;
  • @within: 限制匹配連接點目標對象具有指定類型的註解;
  • @anotation: 限制匹配連接點目標對象頭上具有指定類型的註解;

Spring AOP也支持另外一個命名爲bean的切點指示器,它限制匹配連接點爲指定名稱的bean或一系列bean集合(使用通配符時)的方法,使用示例如下:

bean(idOrNameOfBean)

idOrNameOfBean字符可以是任何Spring Bean的名字,限制通配符支持使用*字符。因此,如果你爲你的Spring bean建立一些命名約定,你可以編寫一個bean 切點指示器表達式來選擇它們。與其他切點指示器一樣,bean切點指示器也可以使用&&(and),||(or)或!(negation)等邏輯操作符。

注意:bean 切點指示器只在Spring AOP中受支持,而在原生AspectJ織入中不受支持,它是AspectJ定義的標準切點指示器的特定於spring的擴展,因此不能在@Aspect模型中聲明的切面中使用bean 切點指示器。

5.2.2 聯合使用切點指示器

你可以使用&&、||或!操作符聯合使用多個切點表達式,也可以通過名字來引用切點表達式。下面的代碼示例展示了3種切點表達式的使用:

@Pointcut("execution(public * (..))")
private void anyPublicOperation() {} //匹配任意public訪問修飾符修飾的方法

@Pointcut("within(com.xyz.someapp.trading..)") //匹配包名以com.xyz.someapp.trading開頭的任意類的所有方法
private void inTrading() {} 

@Pointcut("anyPublicOperation() && inTrading()")
private void tradingOperation() {} //匹配以com.xyz.someapp.trading開頭的任意類中任意以public訪問修飾符修飾的方法

最佳實踐是從較小的命名組件構建更復雜的切入點表達式,如前面所示。當通過名稱引用切入點時,應用普通的Java可見性規則(你可以在同一類型中看到private修飾的切入點、層次結構中的protect修飾的切入點、任何地方的public切入點,等等)。可見性不影響切入點匹配。

5.2.3 共享切點定義

在使用企業應用程序時,開發人員通常希望從幾個切面引用應用程序的模塊和特定的操作集。官方文檔建議定義一個“SystemArchitecture”切面,它可以捕獲用於此目的的公共切入點表達式。這樣一個切面通常類似於下面的例子:

package com.xyz.someapp;

import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;

@Aspect
public class SystemArchitecture {

    /*
     * 匹配定義在web層且目標對象在com.xyz.someapp.web包及其子包中的所有類的任意方法
     */
    @Pointcut("within(com.xyz.someapp.web..)")
    public void inWebLayer() {}

    /*
     * 匹配定義在service層且目標對象在com.xyz.someapp.service包及其子包中所有類中的任          *意方法
     */
    @Pointcut("within(com.xyz.someapp.service..)")
    public void inServiceLayer() {}

    /*
     *匹配定義在數據訪問層且目標對象在com.xyz.someapp.dao包及其子包中所有類中的任意方法 
     */
    @Pointcut("within(com.xyz.someapp.dao..)")
    public void inDataAccessLayer() {}

    /*
     * 一個業務服務是定義在服務層接口中任意方法的執行
     * 這種假定所有接口放在service包中,而實現類在其子包中
     * 如果你把所有接口按功能分組(例如服務層接口在com.xyz.someapp.abc.service包和                      * com.xyz.someapp.def.service包中,這樣你可以這樣使用切點表達式:
     * "execution(* com.xyz.someapp..service..(..))"
     * 同樣,你可以使用bean切點指示器如"bean(Service)"書寫切點表達式
     * 這假定你以同樣的風格命名Spring service Bean
     */
    @Pointcut("execution( com.xyz.someapp..service..(..))")
    public void businessService() {}

    /* 
     *匹配數據庫訪問層中目標對象在com.xyz.someapp.dao..(..)及其子包中的任意方法
     */
    @Pointcut("execution( com.xyz.someapp.dao..(..))")
    public void dataAccessOperation() {}

}

你可以在任何需要切點表達式的地方引用在這樣一個切面中定義的切點。

5.2.4 切點表達式解讀與使用示例

在使用Spring AOP中,開發者最常使用execution切點指示器,execution切點表達式格式如下:

execution(modifiers-pattern? ret-type-pattern declaring-type-pattern?name-pattern(param-pattern)
                throws-pattern?)

除了返回類型模式(前面代碼段中的rt-type-pattern)、名稱模式和參數模式之外,其他所有部分都是可選的

  • modifiers-pattern:方法訪問修飾符模式,可選;
  • ret-type-pattern: 方法返回類型模式,必須要有,*通配符代表任意返回類型;
    declaring-type-pattern:聲名類型模式,可選項,有的話使用包含包名的全限定類名;
  • name-pattern: 方法名模式,方法名,通配符代表任意方法名;
  • (param-pattern):方法參數模式,()代表沒有參數,(…)代表0個或多個參數

下面的代碼示例展示了一些常用的切點表達式的使用:

execution(public * *(..)) //匹配任意公共方法
    
execution(* set*(..)) //匹配任意方法名以set字符開頭的方法   
    
//匹配com.xyz.service.AccountService類下任意方法
execution(* com.xyz.service.AccountService.*(..)) 
    
//匹配com.xyz.service包及其子包下任意類的所有方法        
 execution(* com.xyz.service.*.*(..))
    
within(com.xyz.service.*) //匹配com.xyz.service包下任意接口的所有方法
    
within(com.xyz.service..*) //匹配com.xyz.service包及其子包下任意類的所有方法  

//匹配實現AccountService接口的代理的任意方法:  
this(com.xyz.service.AccountService) 

 //匹配實現AccountService接口的目標對象的任意方法:   
target(com.xyz.service.AccountService)
   
args(java.io.Serializable) //匹配只帶一個參數的方法,且該參數爲可序列化參數
 
//匹配具有Transactional的目標對象任意方法    
@target(org.springframework.transaction.annotation.Transactional)

//匹配目標對象聲名有Transactional的方法    
 @within(org.springframework.transaction.annotation.Transactional)

//匹配帶有個參數的方法,且運行時參數類型綁定有Classified註解
 @args(com.xyz.security.Classified)
 
 //匹配Spring容器中id或name屬性值爲tradeService的bean實例的方法
 bean(tradeService)
 
 //匹配Spring容器中id或name屬性值以Service結尾的bean實例的方法   
 bean(*Service)

6 聲名通知

通知與切點表達式緊密相連,並在程序運行時執行與切點匹配的前置(before)、後置(After)或環繞(Around)方法。切點明確了在哪裏進行代碼織入,而通知則確定了何時織入增強邏輯,通知可以是一個切點名的引用,也可以是在某處聲名的切點表達式。

6.1 前置通知(Before Advice)

你可以在一個切面中使用@Before註解聲名前置通知,示例代碼如下:

  1. 在Before註解中聲名切點表達式
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;

@Aspect
public class BeforeExample {

    @Before("com.xyz.myapp.SystemArchitecture.dataAccessOperation()")
    public void doAccessCheck() {
       //切點方法執行前執行的邏輯
    }

}
  1. 在Before註解屬性值中引入切點方法名
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.annotation.Before;

@Aspect
public class BeforeExample {

    @Pointcut("com.xyz.myapp.SystemArchitecture.dataAccessOperation()")
    public void  pointcut() {
        
    }
    @Before("pointcut")
    public void beforeAdvice(){
        //切點方法執行前執行的邏輯
    }

}

@Before註解的源碼如下:

package org.aspectj.lang.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
public @interface Before {
    String value();   //起點表達式值或引用

    String argNames() default ""; //參數名
}
6.2 正常返回通知

After Returning 通知在匹配的方法正常返回時執行,你可以在切面中使用@AfterReturning註解聲名After Returning通知,示例代碼如下:

import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.AfterReturning;

@Aspect
public class AfterReturningExample {

    @AfterReturning(
        pointcut="com.xyz.myapp.SystemArchitecture.dataAccessOperation()",
        returning="retVal")
    public void doAccessCheck(Object retVal) {
        //正常返回時執行的邏輯 
    }

}

returning屬性中的名字必須與通知方法中的參數名一致,當被攔截切點方法執行返回時,返回值會作爲與參數名對應的參數傳遞給通知方法。也可以通過retVal的類型限制匹配固定類型的返回值,上面的實例中Object類型可以匹配任意類型的返回值。

**注意:**在使用返回通知後返回一個完全不同的引用是不可能的

@AfterReturning註解源碼如下:

package org.aspectj.lang.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
public @interface AfterReturning {
    String value() default ""; //切點表達式引用方法名

    String pointcut() default ""; //切達表達式內容

    String returning() default ""; //切點方法返回值名

    String argNames() default ""; //切點方法參數名
}
6.3 異常通知(After Throwning Advice)

當匹配的連接點方法在程序執行發生異常時會執行異常通知。你可以在切面類中使用@AfterThrowing註解聲名異常通知,示例代碼如下:

import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.AfterThrowing;

@Aspect
public class AfterThrowingExample {

    @AfterThrowing("com.xyz.myapp.SystemArchitecture.dataAccessOperation()")
    public void doRecoveryActions() {
        // 拋出異常時執行的邏輯
    }

}

通常你需要在發生指定類型的異常時運行異常通知,你也需要在通知體中獲取程序拋出的異常信息。你可以使用throwing屬性限制匹配和綁定異常到通知參數中,使用示例如下:

import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.AfterThrowing;

@Aspect
public class AfterThrowingExample {

    @AfterThrowing(
        pointcut="com.xyz.myapp.SystemArchitecture.dataAccessOperation()",
        throwing="ex")
    public void doRecoveryActions(DataAccessException ex) {
        // 拋出DataAccessException類型異常時執行的邏輯
    }

}

throwing屬性中的名字必須與通知方法參數中的名字一致,異常將傳遞到通知方法中對應的參數值中。拋出的異常類型可以指定連接點爲拋出指定異常類型的方法,本例中限定拋出DataAccessException類異常的連接點。

6.4 後置通知(After Advice)

當存在匹配的連接點方法時,後置通知總是會被執行。你可以使用@After註解聲名最終通知,最終通知可以同時處理正常返回和發生異常時的情況。最終通知通常用來釋放資源或者處理類似目的,下面的代碼示例展示瞭如何使用最終通知:

import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.After;

@Aspect
public class AfterFinallyExample {

    @After("com.xyz.myapp.SystemArchitecture.dataAccessOperation()")
    public void doReleaseLock() {
        //釋放版本鎖處理 
    }

}

@After註解類源碼如下:

package org.aspectj.lang.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
public @interface After {
    String value(); //切點表達式值或切點方法名應用

    String argNames() default ""; //切點方法參數名
}
6.5 環繞通知(Around Advice)

最後一種通知是環繞通知,環繞通知會在匹配的連接點方法周圍執行,它有機會在方法執行之前和之後執行工作,並確定何時、如何執行,甚至是否真正執行方法。如果需要以線程安全的方式(例如,啓動和停止計時器)共享方法執行前後的狀態,通常會使用Around通知。始終使用最不低強度的通知來滿足你的需求(也就是說,如果before通知可以滿足需求的話,就不要使用around通知)。

通過只用@Around註解來聲名環繞通知,通知方法的第一個參數必須是一個ProceedingJoinPoint類型的參數,調用ProceedingJoinPoint對象的proceed()方法會觸發底層方法的執行,proceed方法也可以傳遞一個對象數組,當方法執行時,數組中的值用作方法執行的參數。以下代碼示例展示如何使用環繞通知:

import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.ProceedingJoinPoint;

@Aspect
public class AroundExample {

    @Around("com.xyz.myapp.SystemArchitecture.businessService()")
    public Object doBasicProfiling(ProceedingJoinPoint pjp) throws Throwable {
        // start stopwatch
        Object retVal = pjp.proceed();
        // stop stopwatch
        return retVal;
    }
}

環繞通知方法的返回值就是被攔截方法調用時的返回值,例如一個簡單的緩存切面在緩存裏有值時可以從緩存裏返回一個值,沒有的話就執行proceed()方法,注意proceed()方法在環繞通知體中可以被執行一次、多次或者零次,這些情況都是合法的。

7 獲取通知中的參數

Spring AOP提供了5中通知,這意味着你可以在通知簽名中聲名你需要的參數(參考前面的正常返回通知和異常通知中的代碼示例),而不是一直使用對象數組。我們接下來再看如何獲取通知方法中的參數值和其他與上下文相關的參數值。首先,我們來看看如何編寫通用的通知,從而找出通知當前通知的方法。

7.1 獲取當前連接點(JoinPoint)

任意通知方法都可以聲明第一個參數爲org.aspectj.lang.JoinPoint類型的參數(注意,環繞通知方法需要聲明的第一個參數爲ProceedingJoinPoint類型,它是JoinPoint接口的子類)。JoinPoint接口提供了下面這些非常有用的方法:

  • Object[] getArgs() : 返回切點方法參數數組

  • Object getThis() : 返回切點方法代理對象

  • Object getTarget() : 返回切點方法目標對象

  • Signature getSignature() : 返回被通知方法的簽名(方法完整描述)

  • String toString() : 被通知方法轉字符串

7.5 給通知方法傳遞參數

到現在,我們已經學會了如何在通知方法中綁定切點方法的返回值和異常值(使用正常返回通知和異常通知),爲了是切點方法的參數值可用,你可以使用args切點指示器綁定形式。如果在args表達式中使用參數名代替類型名,則在調用通知方法時將傳遞相應參數的值作爲通知方法的參數值。舉個例子可以說明,假設你需要通知一個攜帶第一個參數爲Account類型參數的Dao操作,同時你需要在通知方法中訪問該account參數值,你可以這樣寫:

@Before("com.xyz.myapp.SystemArchitecture.dataAccessOperation() && args(account,..)")
public void validateAccount(Account account) {
  // ...
}

args(account,…)`作爲切點表達式的一部分,起着兩個左右:第一,限制匹配連接點方法至少攜帶一個參數,且第一個參數爲Account類實例;第二,使得攜帶的Account類型參數在通知方法中可用。

另一種編寫方法是聲明一個切入點,該切入點在匹配連接點時提供Account對象值,然後從通知中引用指定的切入點。用法示例如下:

@Pointcut("com.xyz.myapp.SystemArchitecture.dataAccessOperation() && args(account,..)")
private void accountDataAccessOperation(Account account) {}

@Before("accountDataAccessOperation(account)")
public void validateAccount(Account account) {
    // ...
}

代理對象(this),目標對象(target)和註解(@within, @target, @annotation, and @args)也能以類似的風格綁定,下面的兩份代碼示例展示瞭如何使用@Auditable註解類匹配連接點方法,並獲取註解類的方法值:

  1. 定義Auditable註解類
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Auditable {
    AuditCode value();
}
  1. 在通知方法的切點表達式中使用Auditable註解類
@Before("execution(* ..Sample+.sampleGenericMethod(*)) && args(param)")
public void beforeSampleMethod(MyType param) {
    // Advice implementation
}
7.6 通知方法與泛型

Spring AOP`一樣能處理類和方法參數申明中的泛型。假設你有如下代碼示例中的泛型類:

public interface Sample<T> {
    void sampleGenericMethod(T param);
    void sampleGenericCollectionMethod(Collection<T> param);
}

你可以將方法類型的攔截限制爲特定的參數類型,方法是將通知方法參數指定爲要攔截方法的參數類型,示例用法如下:

@Before("execution(* ..Sample+.sampleGenericMethod(*)) && args(param)")
public void beforeSampleMethod(MyType param) {
    // Advice implementation
}

這種方法不適用泛型集合,所以你不能像下面這樣定義切點表達式:

@Before("execution(* ..Sample+.sampleGenericCollectionMethod(*)) && args(param)")
public void beforeSampleMethod(Collection<MyType> param) {
    // Advice implementation
}

爲使上面的代碼生效,我們需要檢查集合中的每一個元素,也需要處理泛型中的空值,這是不合理的。爲取得類似的效果,我們可以將上面通知方法中的參數類型改爲Collection<?>並手工檢查泛型中的元素類型。

7.7 通過參數的名確定參數

通知調用中的參數綁定,依賴於切點表達式中聲明的參數名匹配通知方法和切點方法簽名中聲明的參數名。參數名無法通過Java反射獲得,因此Spring AOP使用以下策略來確定參數名:

@Before(value="com.xyz.lib.Pointcuts.anyPublicMethod() && target(bean) && @annotation(auditable)",
argNames="bean,auditable")
public void audit(Object bean, Auditable auditable) {
AuditCode code = auditable.value();
 // ... use code and bean*
}

如果第一個參數是JoinPoint,ProcedingJoinPoint或JoinPoint.staticPart類型中的一個,你可以你可以將參數名從argNames屬性值中移除。例如你要修改前置通知接收一個JoinPont對象,那麼argNames屬性可以不包含在切點表達式中,示例代碼如下:

@Before("com.xyz.lib.Pointcuts.anyPublicMethod()")
public void audit(JoinPoint jp) {
    // ... use jp
}

使用argNames屬性有點笨拙,因此如果沒有指定argNames屬性,Spring AOP將查看該類的調試信息,並嘗試從局部變量表中確定參數名。只要使用調試信息(-g:vars)編譯了類,就會出現此信息。

7.8 處理參數

我們在前面提到過,我們將描述如何使用在Spring AOP和AspectJ中一致工作的參數來編寫proceed()調用。解決方案是確保通知簽名按順序綁定每個方法參數,下面的例子演示瞭如何做到這一點:

@Around("execution(List<Account> find*(..)) && " +
        "com.xyz.myapp.SystemArchitecture.inDataAccessLayer() && " +
        "args(accountHolderNamePattern)")
public Object preProcessQueryPattern(ProceedingJoinPoint pjp,
        String accountHolderNamePattern) throws Throwable {
    String newPattern = preProcess(accountHolderNamePattern);
    return pjp.proceed(new Object[] {newPattern});
}

8 通知順序

如果多個通知的代碼片段發生在同一個切點上將會發生什麼?Spring AOP遵循與AspectJ相同的優先規則來確定通知執行的順序。在進程進來時,優先級高的通知方法先運行,例如兩個前置通知,優先級高的通知先執行;在進程從連接點方法出去時,優先級高的通知後執行,例如兩個後置通知方法片段,優先級高的後執行。

當來自不同切面的兩個通知邏輯需要在同一個切點上執行時,除非你指定優先級順序,否則兩個通知執行的順序將是未知的。你可以通過指定不同切面的優先級控制兩個切面中通知執行的順序,在Spring項目中通常通過使切面類實現org.springframework.core.Ordered接口或者添加@Order註解來控制切面的優先級。

8.1 通過實現 Ordered接口定義優先級
package com.example.aspect;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.core.Ordered
@Aspect    
public class OrderAspect implements Ordered{
    
    private int order = 1;
    
    public int getOrder() {
        return this.order;
    }

    public void setOrder(int order) {
        this.order = order;
    }
    @Before("execution(public * com.example.service.impl.*Service.save*(..))")
    public void beforeAdviceMethod(){
        //通知邏輯實現
    }
}
8.2 通過添加@Order註解定義優先級
package com.example.aspect;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.core.annotation.Order;

@Aspect
@Order(value=1)
public class OrderAspect{
    
    @Before("execution(public * com.example.service.impl.*Service.save*(..))")
    public void beforeAdviceMethod(){
        //通知邏輯實現
    }
}

當定義在同一個切面中的兩個同類通知需要運行在同一處切點時,此時通知的順序是未知的,因爲沒有辦法通過反射來檢索javac編譯類的聲明順序。解決方法是考慮將這些通知方法分解爲每個切面類的每個連接點上的一個通知方法,或者將這些通知片段重構爲可以在切面級上排序的獨立切面類。

9 引入

引入(在AspectJ中稱爲類型間聲明)使切面能夠聲明被通知的對象實現給定的接口,並代表這些對象提供接口的實現。

你可以通過使用@DeclareParents註解創建引用,這個註解用來聲明匹配的類具有一個新的父類。例如,給定一個命名爲UsageTracked的接口和實現這個接口的實現類,命名爲DefaultUsageTracked。以下代碼示例表明所有com.xzy.myapp.service包下的實現類都要實現UsageTracked接口。

@Aspect
public class UsageTracking {

   @DeclareParents(value="com.xzy.myapp.service.*+", defaultImpl=DefaultUsageTracked.class)
    public static UsageTracked mixin;

    @Before("com.xyz.myapp.SystemArchitecture.businessService() && this(usageTracked)")
    public void recordUsage(UsageTracked usageTracked) {
        usageTracked.incrementUseCount();
    }

}

要實現的接口由被@DeclareParents註解聲名的屬性類型決定,如上例中mixin字段的類型爲UsageTracked,那麼UsageTracked接口就是要實現的接口。@DeclareParents註解的value屬性值是一個AspectJ類型模式。任何匹配類型的bean都要實現UsageTracked接口。注意,在前面示例的before建議中,服務bean可以直接用作UsageTracked接口的實現。如果以編程方式訪問一個bean,您將編寫以下代碼:

UsageTracked usageTracked = (UsageTracked) appContext.getBean("myService");

10 一個使用AOP的完整示例

現在我們已經知道怎麼單獨使用Spring AOP的部分功能了,那麼現在讓我們來綜合使用它做些有用的事情。業務層方法有時會因爲併發問題(例如獲取鎖失敗),如果操作重試,那麼可能在下一次中成功。對於存在這種併發問題的業務層服務,重試解決問題的合適方法(冪等操作,不需要返回給用戶來解決衝突)。我們想通過透明地重試操作以避免客戶看到樂觀鎖失敗異常(PessimisticLockingFailureException)。這種需求明顯在服務層中很切多個服務類,因此通過切面解決是一個理想的解決方案。

10.1 定義一個切面類

因爲我們要進行重試操作,所以需要使用環繞通知,這樣就可以多次調用proceed()方法。下面的代碼示例展示了一個切面的基本實現:

@Aspect
public class ConcurrentOperationExecutor implements Ordered {

    private static final int DEFAULT_MAX_RETRIES = 2;

    private int maxRetries = DEFAULT_MAX_RETRIES;
    private int order = 1;

    public void setMaxRetries(int maxRetries) {
        this.maxRetries = maxRetries;
    }

    public int getOrder() {
        return this.order;
    }

    public void setOrder(int order) {
        this.order = order;
    }

    @Around("com.xyz.myapp.SystemArchitecture.businessService()")
    public Object doConcurrentOperation(ProceedingJoinPoint pjp) throws Throwable {
        int numAttempts = 0;
        PessimisticLockingFailureException lockFailureException;
        do {
            numAttempts++;
            try {
                return pjp.proceed();
            }
            catch(PessimisticLockingFailureException ex) {
                lockFailureException = ex;
            }
        } while(numAttempts <= this.maxRetries);
        throw lockFailureException;
    }

}

注意,由於ConcurrentOperationExecutor切面實現了Ordered接口,我們可以設置通知的優先級高於事物切面的通知。最大重試次數(maxRetries屬性)和order屬性都是通過Spring配置的。主要的行爲都發生在doConcurrentOperation環繞通知中。注意當每次在businessService()方法運行重試邏輯時,程序嘗試執行proceed()方法,如果因爲捕獲到PessimisticLockingFailureExceptio異常導致失敗就再重複執行一次,知道重試次數大於最大重試次數爲止。

10.2 註冊切面類到Spring容器

將ConcurrentOperationExecutor切面類作爲bean註冊到Spring管理的容器對應的Spring XML配置代碼如下:

<aop:aspectj-autoproxy/>

<bean id="concurrentOperationExecutor" class="com.xyz.myapp.service.impl.ConcurrentOperationExecutor">
    <property name="maxRetries" value="3"/>
    <property name="order" value="100"/>
</bean>
10.3 切面改進

爲了改進切面,使它只重試冪等操作,我們可以定義以下冪等註解類:

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.Method})
public @interface Idempotent {
    // marker annotation
}

然後,我們可以使用@Idempotent註解來註釋服務操作的實現。對切面類的更改是隻重試冪等操作,這涉及到細化切入點表達式,以便只匹配冪等操作,如下所示:

@Around("com.xyz.myapp.SystemArchitecture.businessService() && " +
        "@annotation(com.xyz.myapp.service.Idempotent)")
public Object doConcurrentOperation(ProceedingJoinPoint pjp) throws Throwable {
    // ...
}

總結

本文筆者主要參考了Spring官方文檔中的面向切面編程部分講解了Spring AOP的一些基本概念,以及如何在項目中開啓AspectJ的支持,講解了基於註解的風格的切面的定義、切點表達式的定義和5種通知的使用。使用的demo代碼基本都是官方文檔中的代碼片段,在筆者的下一篇文章中將使用基於SpringBoot的項目,講解利用Spring AOP特性實現用戶登錄日誌記錄,接口調用耗時日誌記錄和一些操作權限驗證等功能。

參考文檔鏈接: Spring5官方文檔Spring Core部分之Aspect Oriented Programming with Spring

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章