Spring AOP:概念和用法

在一個應用系統中,我們會有一些核心業務邏輯之外的關注點,比如安全、日誌、事務,這些關注點橫跨整個業務系統,與具體業務功能交織在一起。對於此類關注點,面向對象編程束手無策。AOP(面向切面編程)是解決該問題的技術概念模型,一個切面是對某個橫切關注點的模塊化,織入將這個切面插入目標模塊而不需要目標模塊修改代碼。

有很多的AOP實現方案,比如強大的AspectJ;Spring AOP借鑑了AspectJ的概念和工具,但是比AspectJ要輕量級很多,絕大多數情況下能滿足需求。

AOP是Spring知識圖譜裏面比較難懂的一部分,能夠搞明白Spring AOP基本就夠了,因此本章略去了“如何在Spring中使用AspectJ”的介紹。另外由於基於註解的AOP是主流形式,本章也跳過了“xml格式的AOP配置方法”。

本章對應的官方文檔地址

概念術語

AOP是一種完全不同於面向對象的編程理念,涉及一系列該領域的術語,所以要學習AOP首先要理解這些術語。本人的切身體驗,只要理解了術語,AOP就掌握了大半。注意,下面的術語並不是Spring特有的。

切面(Aspect)

切面是應用系統中某個橫切關注點的模塊化。事務的處理是一個典型的橫切關注點;所謂模塊化,對Spring AOP來說,即用一個類來實現切面。切面類是一個普通的java類,加上@Aspect註解,或者在xml裏面使用aop相關標籤進行配置。由於xml配置現在使用已經較少,本章不再介紹基於xml的aop配置,僅關注基於註解的配置。

連接點(Join point)

連接點指應用程序的一個執行點,在連接點上,AOP纔可以將切面功能插入進去。對Spring AOP來說,連接點就是指方法的執行。

通知 (Advice)

通知是指切面在連接點上執行的行爲。通知有不同的類型,包括“around”、 “before” 和“after”,它們的含義後面會解釋。Spring將通知建模爲攔截器(interceptor),並在連接點上維護了一個攔截器鏈(interceptor chain)。

切點(Pointcut)

切點是謂詞邏輯,用來匹配連接點和通知。通知總會與一個切點相關聯,以確定在哪些連接點上執行。Spring AOP借用了AspectJ的切點表達式語言來聲明切點。

引入(Introduction)

指動態地給現有的類型添加新的行爲,Spring AOP允許我們給一個bean引入新的接口實現。

目標(Target Object)

被一個或多個切面通知的對象,也即切面織入的目標bean。

AOP代理(AOP Proxy)

爲了綁定通知和連接點,Spring需要創建目標對象的代理對象。Spring創建的Proxy要麼是一個JDK動態代理,或CGLIB代理。注意:其他AOP框架也許不需要創建代理。

織入

織入是指把切面應用到目標對象並創建代理對象的過程。切面在指定的連接點被織入到目標對象中,在目標對象的聲明週期中,可以有多個織入點:編譯時、類加載時、運行時;Spring AOP的織入總是發生在運行時。

上面的術語很多,目前我們只需要記住切面從技術上就是一個java類,它包含了切點+通知,切點描述了切面想要在哪些目標方法上插入邏輯,通知則定義插入的位置和邏輯。這幾個概念及其之間的關係是Spring AOP的核心。

@AspectJ註解

@AspectJ指一系列註解,可以在一個普通的java上面定義切面、切點和通知。它是AspectJ項目引入的,Spring借用了這套註解,並使用AspectJ提供的工具庫來做切點表達式解析和連接點匹配。

啓用@AspectJ

在@Configuration類上面加上@EnableAspectJAutoProxy即可:

@Configuration
@EnableAspectJAutoProxy
public class AppConfig {
}

切面

只要在一個普通的bean上加上@Aspect註解即可,

@Component
@Aspect
public class NotVeryUsefulAspect {
}

Aspect類和普通的java類一樣,可以有字段,方法;最重要的是包含了切點、通知和引入的定義。在Spring裏面,Aspect必須要聲明爲一個bean,才能被掃描到且被處理爲一個切面,所以上面的聲明加上了@Component。

另外,一個切面不會被另一個切面織入,一旦Spring發現某個bean是一個切面,就會將其排除在AOP織入目標之外。

切點

AspectJ類型匹配符

在介紹切點表達式之前,我們先簡單學習一下AspectJ的類型匹配符,它的規則有三條:

  • *:匹配任意字符串,不包括分隔符;
  • …:相當於模式*的重複,用於有多個段的模式匹配,如在類型模式中匹配任何數量子包;而在方法參數模式中匹配任何數量參數;不能匹配類名;另外也不能用在某個模式的開頭處;
  • +:匹配指定類型的所有子類型,放在類名的後面;

例子:

模式 匹配
java.lang.String 匹配String類型;
java.*.String 匹配java包下的任何“一級子包”下的String類型,如匹配java.lang.String,但不匹配java.lang.ss.String
java…* 匹配java包及任何子包下的任何類型; 如匹配java.lang.String、java.lang.annotation.Annotation
java.lang.*ing 匹配任何java.lang包下的以ing結尾的類型;
java.lang.Number+ 匹配java.lang包下的任何Number的自類型; 如匹配java.lang.Integer,也匹配java.math.BigInteger

注:此部分內容源來自互聯網

切點表達式

切點表達式用來定義切點,指明切面要通知的bean方法。切點表達是切點指示器(PCD, Pointcut Designaor)組成,Spring AOP支持AspectJ PCD的一個子集。每個PCD定義了一個匹配規則,多個PCD可以通過邏輯操作符結合起來;下面是Spring支持的所有PCD。

execution

這是切點表達式的主要PCD,它用來匹配一個方法的執行,定義模式如下:

execution(modifiers-pattern? ret-type-pattern declaring-type-pattern?name-pattern(param-pattern)
                throws-pattern?)
  • modifiers-pattern: 方法的權限修飾符,如public,可選;
  • ret-type-pattern: 返回值類型模式,可是使用*通配符;
  • declaring-type-pattern:對象類型模式,可選;
  • name-pattern:方法名模式,可使用*通配符;
  • param-pattern:參數模式,()代表無參數,(…)代表任意參數,(*,String)代表兩個參數,且第二個是String類型;

其實declaring-type-pattern還可以拆成兩部分,包名部分和類名部分,可以只有類名部分,此時匹配Aspect所在package下面的類。

示例:

  1. execution(public * *(..))
    返回值是*, 沒有類型限定,方法名是*,參數是…, 因此匹配所有pulic方法

  2. execution(* set*(..))
    沒有權限限定,返回值是*, 沒有類型限定,方法名是set*,參數是…,因此匹配所有名字以set開頭的方法

  3. execution(* com.xyz.service.AccountService.*(..))
    沒有權限限定,返回值是*,類型是com.xyz.service.AccountService,方法名是*,參數是是…,因此匹配AccountService的所有方法

  4. execution(* com.xyz.service..*(..))
    沒有權限限定,包限定爲com.xyz.service…,類限定爲*,方法限定爲…,因此匹配com.xyz.service及子包下任意類定義的任意方法。

注:上面的示例來自源文檔,經過親測,源文檔有些示例解釋是錯誤的。

within

執行方法的對象的class類型。
比如within(com.xyz.service.*)表示,方法必須在com.xyz.service這個package的某個類裏面; within(com.xyz.service..*)表示,方法必須在com.xyz.service及其子包的某個類裏面。
within(AccountService)不會匹配AccountService的子類對象,除非聲明爲within(AccountService+)

this

方法執行的bean,必須是某個類型,類型模式參數不能使用*通配符,可以使用+
並且這裏的bean在Spring AOP裏是指代理bean對象。
this(com.xyz.service.Interface)限定織入的代理必須實現了該接口。

target

方法執行的target bean必須是某個類型,在Spring AOP裏是指被代理的對象;不能使用*通配符,可以使用+
target(com.xyz.service.Interface)限定織入的目標對象必須實現該接口。

注:this和target的不同,來源於代理對象和目標對象之間類型信息的不同。

args

限定方法執行參數的類型匹配模式,不能使用*通配符,可以使用+
args(java.io.Serializable)限定方法有一個參數,且類型是Serializable。
注意:execution指示器裏的參數列表匹配的是方法的簽名參數,args匹配的是參數的運行時類型。

@target

方法執行的目標對象class,擁有某個註解。比如@target(org.springframework.transaction.annotation.Transactional)

@within

執行方法的聲明類型,擁有某個註解。

@annotation

執行方法的本身擁有某個註解。

bean

這是Spring額外添加的PCD,限制方法執行target bean的名字。
bean(*Service)限定只有名字以Service結尾的bean。

PCD組合

PCD本質上是一個謂詞邏輯,因此可以通過邏輯操作符結合在一起,支持3個操作符:&& || !,分別是:與、或、非。 比如@Pointcut("within(com.xyz.service.*) && args(java.io.Serializable)")

聲明切點

有了切點表達式,就可以聲明切點了,聲明切點使用@Pointcut註解。

@Aspect 
public class NotVeryUsefulAspect {

	@Pointcut("execution(* transfer(..))") // the pointcut expression
	private void transfer() {} // the pointcut signature
	
	@Pointcut("transfer && within(com.service..*")
	private void serviceTransfer() {} // the pointcut signature
}

@Pointcut註解加在一個方法上面,方法的實現體無關緊要,關鍵是transfer這個方法名成爲了切點的名字。後者又可以在另一個切點表達式中使用。我們可以把切點名,理解爲切點表達式的別名

需要注意的是,切點匹配的是一個方法的執行,而不是靜態的方法定義。

通知(Advice)

Spring AOP支持三種類型的通知,Before、After、Around。

Before

Before是指在方法執行之前執行通知:

@Aspect
public class BeforeExample {

	@Poincut("execution(* com.xyz.myapp.dao..(..))")
    private void daoOperation() {}
	
    @Before("daoOperation")
    public void doAccessCheck() {
        // ...
    }
}

這裏我們先定義切點,通知通過名字引用該切點,這是複用切點定義的推薦方法。從技術上,也可以直接將切點表達式內嵌在通知定義裏:

@Before("execution(* com.xyz.myapp.dao..(..))")
public void doAccessCheck() {
    // ...
}

After

After是指在方法執行之後執行通知

@After("daoOperation")
public void doAccessCheck() {
    // ...
}

After還可以區分正常返回和異常返回:

@AfterReturning("daoOperation")
public void doAccessCheck() {
    // ...
}
@AfterThrowing("daoOperation)")
public void doRecoveryActions() {
    // ...
}

After通知還可以捕獲返回值或拋出的異常:

@AfterReturning(
    pointcut="daoOperation",
    returning="retVal")
  	public void doAccessCheck(Object retVal) {
    // ...
}

@AfterThrowing(
    pointcut="daoOperation",
    throwing="ex")
 	public void doRecoveryActions(DataAccessException ex) {
    // ...
}

Around

Around通知是最強大的形式,它完全攔截了方法的調用,讓我們可以在方法執行前、後插入邏輯,甚至跳過方法的執行或修改返回值。

@Aspect
public class AroundExample {
    @Around("pointCutName")
    public Object doBasicProfiling(ProceedingJoinPoint pjp) throws Throwable {
        // start stopwatch
        Object retVal = pjp.proceed();
        // stop stopwatch
        return retVal;
    }
}

Around通知方法的第一個參數必須是ProceedingJoinPoint,它包含了此次方法執行的封裝,調用ProceedingJoinPoint.proceed()以繼續方法執行(執行目標方法,或其他通知)。

通知的參數

很多情況下,通知需要知道目標方法的一些信息。上面介紹的Around通知有一個ProceedingJoinPoint參數,通過它可以得到很多信息,實際上所有的通知都可以加上一個JoinPoint類型的參數(如果有多個參數,必須JoinPoint必須放在第一個)。

JoinPoint包含以下信息:

  • getArgs(): 方法參數
  • getThis(): proxy對象引用;
  • getTarget(): target對象引用;
  • getSignature(): 方法簽名;

唯一的缺陷是這些信息都是無類型的,需要我們自己通過java反射或或強制類型轉換來使用。

參數綁定

可以通過參數綁定來捕獲連接點的實參和返回值,以及執行切點匹配的相關參數。前面展示了@AfterReturn和@AfterThrow如何綁定返回值和異常,而更普遍的參數綁定的方式是通過切點指示器。

args

如果在args裏面放置一個參數名而不是一個類型名,那麼方法調用的參數就能夠綁定到這個名字。

@Aspect
public class BeforeExample {

	@Poincut("execution(* com.xyz.myapp.dao..(..)) && args(name)")
    private void daoOperation(name) {}
	
    @Before("daoOperation(name)")
    public void doAccessCheck(String name) {
        // ...
    }
}

這個示例中,args限定了方法調用只有一個參數,同時將參數值綁定到name這個名字。在@Before通知裏,可以使用這個名字來獲取方法調用的參數。

如果方法有多個參數,但是我們只想綁定第一個,可以這樣寫:args(name,...);如果既想用args來綁定參數,又想限定參數類型,可以使用兩個args:args(name) && args(String)

其他PCD

this和target指示器可以分別綁定代理對象和目標對象到通知裏面。

@Before("daoOperation(name) && this(bean)")
public void doAccessCheck(String name, BeanType bean) {
    // ...
}

@within, @target, @annotation, and @args可以綁定他們所匹配的註解對象。

@Before("daoOperation(name) && @annotation(a)")
public void doAccessCheck(String name, AnnotationType a) {
    // ...
}

綁定參數的名字

我們知道,java方法在Release模式編譯之後,參數名字是丟失了的,所以Spring AOP把綁定傳參數傳遞給通知方法時,只能依據參數的類型來推斷。爲了解決這個問題,通知註解增加了argNames屬性,指定了綁定參數傳遞的順序。

@Before(pointCut="daoOperation(name) && @annotation(a)", 
        argNames="name,a")
public void doAccessCheck(String name, AnnotationType a) {
    // ...
}

不過需要注意的是,JointPoint參數永遠位於第一個,也不需要在argsNames裏面指定:

@Before(pointCut="daoOperation(name) && @annotation(a)",
        argNames="name,a")
public void doAccessCheck(JointPoint joinPoint, String name, AnnotationType a) {
    // ...
}

通知執行順序

如果不同的切面,同時切入了同一個連接點(JoinPoint),那麼它的執行順序是不確定的。可以通過@Order註解來定義相對順序:

@Component
@Aspect 
@Order(1)
public class NotVeryUsefulAspect {
}

如果同一個切面的不同通知,都織入了同一個連接點,那麼無法通過技術手段來定義相對順序。

引入(Introduction)

"引入"指爲現有對象動態添加新的接口,實現方式如下:

@Component
@Aspect 
public class NotVeryUsefulAspect {
    @DeclareParents(value = "beans.service.*",defaultImpl= IntroductionImpl.class)
    private IntroductionInterface mixin;
}

@DeclareParents註解附加在切面的一個成員變量上面,這個成員名字沒有意義,可以是static的。它的類型決定了要引入的接口類型。註解的value屬性值是AspectJ類型表達式,defaultImpl屬性指向接口實現類。

上面的定義給beans.service下面所有的類,添加了IntroductionInterface實現。

切面的實例化模式

切面也是一個bean,默認情況下也是singleton模式的。所有的匹配的連接點都共享這個切面實例。

Spring支持AspectJ提供的perthis和pertarget兩種模式,看一個實例:

@Aspect("perthis(com.xyz.myapp.SystemArchitecture.businessService())")
public class MyAspect {

    private int someState;

    @Before(com.xyz.myapp.SystemArchitecture.businessService())
    public void recordServiceUsage() {
        // ...
    }
}

對匹配的連接點所在每個proxy對象,創建一個MyAspect實例,pertarget是類似的機制。

注: 說實話,這塊沒看明白,proxy和target對象難道不是一一對應的嗎?那麼perthis和pertarget還有什麼區別呢?望明白的同行不吝賜教。

示例

現在我們通過一個簡單的示例來展示Spring AOP的用法,它的目標是通過Around通知來攔截一個方法,並修改調用的實參。

先定義目標service,只有一個方法,就是簡單打印參數:

@Service
public class NormalService implements PrintInterface {
    public void print(String value) {
        System.out.println("NormalService.print:" + value);
    }
}

定義切面,包含切點和Around通知,並且綁定了連接點的實參到通知方法(param)。這個通知做了兩件事,一是記錄了連接點的執行軌跡,二是替換了連接點的調用參數爲“gogo”

@Component
@Aspect
public class SmallAspect {

    @Pointcut("execution(* beans.service.NormalService.print(..)) && args(param)")
    private void pointCut(String param) {
    }

    @Around("pointCut(param)")
    private void aroundAdvice(ProceedingJoinPoint joinPoint, String param) throws Throwable {
        System.out.println("\naroundAdvice before " + joinPoint.getTarget().getClass().getSimpleName() + "." + joinPoint.getSignature().getName());
        System.out.println("param is " + param);
        joinPoint.proceed(new Object[]{"gogo"});
        System.out.println("aroundAdvice after " + joinPoint.getTarget().getClass().getSimpleName() + "." + joinPoint.getSignature().getName());
        System.out.println("\n");
    }
}

基本這樣就好了,我們可以來寫main函數運行這個示例,看看效果:

@Configuration
@EnableAspectJAutoProxy
public class Main {
    public static void main(String[] args) {
        AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(Main.class);
        PrintInterface normalService = (PrintInterface)context.getBean("normalService");
        normalService.print("raw");
    }

    @Bean
    public NormalService normalService() {
        return new NormalService();
    }

    @Bean
    public SmallAspect aspect() {
        return new SmallAspect();
    }
}

完整的示例代碼:SpringBasic的子工程aop

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