閱讀本文之前建議先了解動態代理,可以參看我的另一篇部落格 輕鬆理解 Java 靜態代理/動態代理
Spring AOP 簡介
Spring AOP 的基本概念
AOP (Aspect-Oriented Programming),即 面向切面程式設計, 它與 OOP (Object-Oriented Programming, 物件導向程式設計) 相輔相成, 提供了與 OOP 不同的抽象軟體結構的視角.
在 OOP 中, 我們以類(class)作為我們的基本單元, 而 AOP 中的基本單元是 Aspect(切面)
AOP是 Spring 是最難理解的概念之一,同時也是非常重要的知識點,因為它真的很常用。
面向切面程式設計
在面向切面程式設計的思想裡面,把功能分為兩種
- 核心業務:登陸、註冊、增、刪、改、查、都叫核心業務
- 周邊功能:日誌、事務管理這些次要的為周邊業務
在面向切面程式設計中,核心業務功能和周邊功能是分別獨立進行開發,兩者不是耦合的;
然後把切面功能和核心業務功能 "編織" 在一起,這就叫AOP
AOP 的目的
AOP能夠將那些與業務無關,卻為業務模組所共同呼叫的邏輯或責任(例如事務處理、日誌管理、許可權控制等)封裝起來,便於減少系統的重複程式碼,降低模組間的耦合度,並有利於未來的可擴充性和可維護性。
AOP 術語和流程
進一步瞭解AOP之前,我們先來看看AOP中使用到的一些術語,以及AOP執行的流程。
術語
- 連線點(join point):對應的是具體被攔截的物件,因為Spring只能支援方法,所以被攔截的物件往往就是指特定的方法。具體是指一個方法
- 切點(point cut):有時候,我們的切面不單單應用於單個方法,也可能是多個類的不同方法,這時,可以通過正則式和指示器的規則去定義,從而適配連線點。切點就是提供這樣一個功能的概念。具體是指具體共同特徵的多個方法。
- 通知(advice):它會根據約定織入流程中,需要弄明白它們在流程中的順序和執行的條件,有這幾種:
- 前置通知(before advice)
- 環繞通知(around advice)
- 後置通知(after advice)
- 異常通知(afterThrowing advice)
- 事後返回通知(afterReturning advice)
- 目標物件(target):即被代理的物件,通俗理解各個切點的所在的類就是目標物件。
- 引入(introduction):是指引入新的類和其方法,增強現有Bean的功能。
- 織入(weaving):它是一個通過動態代理技術,為原有服務物件生成代理物件,然後將與切點定義匹配的連線點攔截,並按約定將各類通知織入約定流程的過程。
- 切面(aspect):定義切點、各類通知和引入的內容,AOP將通過它的資訊來增強Bean的功能或將對應的方法織入流程。
上述的描述還是比較抽象的,配合下面的流程講解以及例子,應該充分掌握這些概念了。
流程
畫了一張圖,通過張圖可以清晰的瞭解AOP的整個流程,以及上面各個術語的意義和關係。
圖片的流程順序基於Spring 5
五大通知執行順序
不同版本的Spring是有一定差異的,使用時候要注意
-
Spring 4
- 正常情況:環繞前置 ==> @Before ==> 目標方法執行 ==> 環繞返回 ==> 環繞最終 ==> @After ==> @AfterReturning
- 異常情況:環繞前置 ==> @Before ==> 目標方法執行 ==> 環繞異常 ==> 環繞最終 ==> @After ==> @AfterThrowing
-
Spring 5.28
- 正常情況:環繞前置 ==> @Before ==> 目標方法執行 ==> @AfterReturning ==> @After ==> 環繞返回 ==> 環繞最終
- 異常情況:環繞前置 ==> @Before ==> 目標方法執行 ==> @AfterThrowing ==> @After ==> 環繞異常 ==> 環繞最終
例子
圖例
舉一個實際中的例子來說明一下方便理解:
房東的核心訴求其實就是籤合同,收錢,淺綠部分都是次要的,交給中介就好。
不過有的人可能就有疑問了,讓房東帶著不是更好嗎,租客溝通起來不是更輕鬆嗎?為啥非要分成兩部分呢?
那麼請看下面這種情況
當我們有很多個房東的時候,中介的優勢就體現出來了。代入到我們實際的業務中,AOP能夠極大的減輕我們的開發工作,讓關注點程式碼與業務程式碼分離!實現解藕!
實際的程式碼
用一個實際程式碼案例來感受一下
- 建立一個房東
@Component("landlord")
public class Landlord {
public void service() {
System.out.println("籤合同");
System.out.println("收錢");
}
}
- 建立中介
@Component
@Aspect
class Broker {
@Before("execution(* pojo.Landlord.service())")
public void before(){
System.out.println("帶租客看房");
System.out.println("談錢");
}
@After("execution(* pojo.Landlord.service())")
public void after(){
System.out.println("給鑰匙");
}
}
3.在 applicationContext.xml 中配置自動注入
<?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">
<context:component-scan base-package="aspect" />
<context:component-scan base-package="pojo" />
<aop:aspectj-autoproxy/>
</beans>
- 測試
public class Test {
public static void main(String[] args) {
ApplicationContext context =
new ClassPathXmlApplicationContext("applicationContext.xml");
Landlord landlord = (Landlord) context.getBean("landlord", Landlord.class);
landlord.service();
}
}
5.執行看到效果:
帶租客看房
談錢
籤合同
收錢
給鑰匙
這個例子中我們用到了@Before
和@After
兩個註解,其實就是設定的前置通知和後置通知。
最後的結果似乎與我們之前圖例中的順序不同,給鑰匙在收錢之後了,這個問題留到後面再解決,目前只需要簡單感受一下aop的使用即可。
預告:這種情況下應該使用環繞通知來完成這個需求
使用 Spring AOP
使用註解開發AOP
目前使用註解的方式進行Spring開發才是主流,包括SpringBoot中,已經是全註解開發,所以我們採用@AspectJ的註解方式,重新實現一下上面的用例,來學習AOP的使用。
註解 | 說明 |
---|---|
@Before |
前置通知,在連線點方法前呼叫 |
@Around |
環繞通知,它將覆蓋原有方法,可以想象成前置+原方法+後置 |
@After |
後置通知,在連線點方法後呼叫 |
@AfterReturning |
返回通知,在連線點方法執行並正常返回後呼叫,要求連線點方法在執行過程中沒有發生異常 |
@AfterThrowing |
異常通知,當連線點方法異常時呼叫 |
第一步:選擇連線點
Spring 是方法級別的 AOP 框架,我們主要也是以某個類額某個方法作為連線點,另一種說法就是:選擇哪一個類的哪一方法用以增強功能。
@Component
public class Landlord {
public void service() {
System.out.println("籤合同");
System.out.println("收錢");
}
}
我們在這裡就選擇上述 Landlord 類中的 service() 方法作為連線點。
第二步:建立切面
選擇好了連線點就可以建立切面了,我們可以把切面理解為一個攔截器,當程式執行到連線點的時候,被攔截下來,在開頭加入了初始化的方法,在結尾也加入了銷燬的方法而已,在 Spring 中只要使用 @Aspect
註解一個類,那麼 Spring IoC 容器就會認為這是一個切面了:
@Component
@Aspect
class Broker {
@Before("execution(* com.aduner.demo03.pojo.Landlord.service())")
public void before(){
System.out.println("帶租客看房");
System.out.println("談錢");
}
@After("execution(* com.aduner.demo03.pojo.Landlord.service())")
public void after(){
System.out.println("給鑰匙");
}
}
切面的類仍然是一個 Bean ,需要
@Component
註解標註
在上面的註解中定義了 execution 的正規表示式,Spring會通過這個正則式去匹配、去確定對應的方法(連線點)是否啟用切面程式設計
execution(* com.aduner.demo03.pojo.Landlord.service())
依次對這個表示式作出分析:
- execution:執行方法的時候會觸發
*
:任意返回型別的方法- com.aduner.demo03.pojo.Landlord:類的全限定名
- service():攔截的方法的名稱
- 如果是service(*) 就是表示任意引數的service方法
第三步:定義切點
每一個註解都重複寫了同一個正則式,這顯然比較冗餘。為了克服這個問題,Spring定義了切點(Pointcut)的概念,切點的作用就是向Spring描述哪些類的哪些方法需要啟用AOP程式設計,這樣可以有效的降低程式碼的複雜度,而且有利於維護的方便。
@Component
@Aspect
class Broker {
@Pointcut("execution(* com.aduner.demo03.pojo.Landlord.service())")
public void pointcut() {
}
@Before("pointcut()")
public void before() {
System.out.println("帶租客看房");
System.out.println("談錢");
}
@After("pointcut()")
public void after() {
System.out.println("給鑰匙");
}
}
第四步:配置好config
@Configuration
@EnableAspectJAutoProxy
@ComponentScan(basePackages = "com.aduner.demo03.*",
excludeFilters = {@ComponentScan.Filter(classes = {Service.class})})
public class AppConfig {
}
第五步:測試 AOP
@Test
void testAspect(){
ApplicationContext ctx = new AnnotationConfigApplicationContext( AppConfig.class ) ;
Landlord landlord=ctx.getBean(Landlord.class);
landlord.service();
((ConfigurableApplicationContext)ctx).close();
}
結果
……
帶租客看房
談錢
籤合同
收錢
給鑰匙
……
環繞通知
現在我們來解決一下前面遺留的那個問題,收錢和給鑰匙的問題。
我們需要的應該是給了鑰匙之後再收錢,但是現在是反過來的。
要實現這個需求,用到環繞通知,這是 Spring AOP 中最強大的通知,整合了前置通知和後置通知。
環繞通知(Around)是所有通知中最為強大的通知,強大也意味著難以控制。一般而言,使用它的場景是在你需要大幅度修改原有目標物件的服務邏輯時,否則都儘量使用其他的通知。
環繞通知是一個取代原有目標物件方法的通知,當然它也提供了回撥原有目標物件方法的能力。
- 我們先來修改一下Landlord
@Component
public class Landlord {
public void service(int steps) {
if (steps == 1) {
System.out.println("籤合同");
} else if(steps==2){
System.out.println("收錢");
}
else {
System.out.println("籤合同");
System.out.println("收錢");
}
}
}
我們將service新增一個引數,第一步籤合同,第二部收錢,如果沒有制定第一步或者第二步,就一起執行。
- 然後重新編寫一下我們的切面
@Component
@Aspect
class Broker {
@Pointcut("execution(* com.aduner.demo03.pojo.Landlord.service(*))")
public void pointcut() {
}
@Around("pointcut()")
public void around(ProceedingJoinPoint joinPoint) {
System.out.println("帶租客看房");
System.out.println("談價格");
try {
// joinPoint.proceed(); 這樣就是執行原方法
joinPoint.proceed(new Object[]{1}); //重新指定方法的引數
System.out.println("交鑰匙");
joinPoint.proceed(new Object[]{2});
} catch (Throwable throwable) {
throwable.printStackTrace();
}
}
}
- 修改一下剛剛的測試類,給到一個初始引數
@Test
void testAspect(){
ApplicationContext ctx = new AnnotationConfigApplicationContext( AppConfig.class ) ;
Landlord landlord=ctx.getBean(Landlord.class);
landlord.service(0);
((ConfigurableApplicationContext)ctx).close();
}
執行!成功!
……
帶租客看房
談價格
籤合同
交鑰匙
收錢
……
ProceedingJoinPoint物件
注意到切面編寫中Around裡面try
中的joinPoint.proceed()
方法
ProceedingJoinPoint物件是JoinPoint的子介面,該物件只用在@Around的切面方法中,新增了以下兩個方法。
Object proceed() throws Throwable //執行目標方法
Object proceed(Object[] var1) throws Throwable //傳入的新的引數去執行目標方法
前面的例子中我們顯示把房東的工作分為了兩步,然後再環繞通知中重新賦予引數並呼叫了兩次,在兩次中間插入了中介的工作。
實際開發中,上面這樣的寫法其實又會造成新的耦合,而且還會造成其他通知的混亂(呼叫了兩次方法,其實會讓有些通知返回兩次)。
當然這只是一個例子,為了幫助更好的理解環繞通知。
多個切面
Spring可以支援多個切面同時執行,如果剛好多個切面的切點相同,切面的執行順序就是一個關鍵了。
預設情況下,切面的執行順序是混亂的,如果需要指定切面的執行順序,我們需要用到@Order
註解
@Component
@Aspect
@Order(1)
public class FirstAspect {
……
}
--------------------
@Component
@Aspect
@Order(2)
public class SecondAspect {
……
}
@Order
註解中的值就是切片的順序,但是他們不是順序執行的而是包含關係。
總結
- AOP的出現是為了對程式解耦,減少系統的重複程式碼,提高可擴充性和可維護性。
- 常見的應用場景有許可權管理、快取、記錄跟蹤、優化、校準、日誌、事務等等等等……總之AOP的使用是非常常見的。
- 需要注意不同Spring版本之間的AOP通知順序是有差別的。補充:Spring5.28為分界線。
- 環繞通知很靈活、強大,但是也就意味著很難控制,如非必要,優先使用其他通知來完成。
- 多切面作用同一個切點時候注意切片順序。