在一個應用系統中,我們會有一些核心業務邏輯之外的關注點,比如安全、日誌、事務,這些關注點橫跨整個業務系統,與具體業務功能交織在一起。對於此類關注點,面向對象編程束手無策。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下面的類。
示例:
-
execution(public * *(..))
返回值是*, 沒有類型限定,方法名是*,參數是…, 因此匹配所有pulic方法 -
execution(* set*(..))
沒有權限限定,返回值是*, 沒有類型限定,方法名是set*,參數是…,因此匹配所有名字以set開頭的方法 -
execution(* com.xyz.service.AccountService.*(..))
沒有權限限定,返回值是*,類型是com.xyz.service.AccountService,方法名是*,參數是是…,因此匹配AccountService的所有方法 -
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();
}
}