我不容許你還不明白Spring AOP(一看就明白)

語言: CN / TW / HK

愛你孤身走暗巷 愛你不跪的模樣 愛你對峙過絕望 — 《孤勇者》

一. 「為什麼需要aop」

在我們的程式碼中除了寫業務程式碼,還要兼顧系統性的需求,比如許可權認證、日誌、事務處理等。這些程式碼獨立於CRUD程式碼,一些熱愛程式設計的同學似乎也能找到一些程式設計的樂趣。

可是這些系統性的功能寫多了,又會陷入複製貼上的陷阱,散落在各個業務邏輯中,實在是不雅觀,專注於CURD的同事也會找上門,臃腫的程式碼讓他們難以維護,比如列印日誌,有些需要列印日誌的方法只能手動新增,方法分散在不同的包中,還需要一一找出。特別是在debug的過程中,有些方法的日誌是臨時的,問題解決後也是需要手動清除,這都是編碼之外繁重的負擔,面對不斷變更的需求,編碼還來不及,哪還有時間一一手動新增或刪除。

本以為寫點系統性的功能比CRUD高階,到頭來還被CRUDer數落一番,屬實鬱悶。程式設計師解悶最好的方式就是讀《java程式設計思想》,在看到java的三大特性之一封裝的時候,來了靈感,解決複雜性最好的方式就是封裝,行業慣例就是再加一層,應用在這些系統性的功能上就是有一個統一的抽象層專門解決系統性功能,業務程式碼需要該功能的時候宣告一下就可以。就像幼兒園老師發糖果一樣,想要棒棒糖的舉手,想要彩虹糖的舉手,想要風車糖的舉手。這就是給程式碼解耦。

aopall.png

二. 「SpringAOP定義」

如果核心功能是一塊麵包,aop就是切開面包加進肉餅,這樣就組裝成了可口的漢堡。在程式設計世界裡,這個肉餅就是許可權認證、日誌、事務處理等。雖然沒有肉餅,麵包也能吃,頂多味道差點。

在美食世界裡,好吃是很主觀的,餓的時候吃什麼都香,飽的時候什麼都不想吃,因此好吃的定義絕非出自飽漢之口,餓漢面對食物往往會誇大其詞,比如,明明是一塊餅裡抹點肉泥,卻稱肉夾饃。當然肉夾饃的名字意為“肉餡的夾饃”,非陝西本地人往往只會從字面上理解。

朝鮮冷麵在東北地區確實是冷湯冷麵的,適合夏天吃。到了徐州當地就是熱的,這就是美食在地域上的差異。在徐州喝冷麵的同時,會搭配當地特色的肉夾饃,這裡的肉夾饃名副其實,饃吃完了,肉還沒吃完,每次還得專門提醒一下師傅,少放點肉。

三. SpringAOP體系

應用aop都離不開時間地點事件,也就是在什麼時間什麼地點做了什麼事,比如情人節男生在街上送花虐狗。聖誕節聖誕老爺爺在孩子們的床頭襪子裡塞滿禮物。雙十一剁手黨們在電商平臺剁手。舉不勝數。

那麼aop的專業術語是怎樣解釋時間地點事件的呢?簡單來說,切點就是在什麼地方,通知就是在什麼時間做什麼事,他們組合起來就是切面,切面就是在什麼時間什麼地點做了什麼事。

這張圖中涉及到了接入點的概念,可以這樣理解,我們在網上買東西的時候,除了付錢還要提供收貨地址,寫在快遞單上的收貨地址就是切點,快遞員按收貨地址送貨上門的地方就是接入點了。一個觀念名義上的,一個現實存在地。

按照這個思路,aop的過程就像一次網購送貨過程,使用者提供收貨地址,快遞員從包裹上獲得送貨地址,按照送貨平臺規定的時間將包裹裡的商品,送貨上門。這麼說有點繞,那就上圖,一圖勝千言。

aop具體展開.png

四. 一個例項

例項:主要作用是驗證引數,有兩個引數,可以在一個切面類裡驗證,也可以通過兩個切面類分開驗證,多個切面類作用在一個目標方法或類上的場景還是比較普遍,這裡就使用兩個切面類分別驗證引數,藉助@Order可以決定兩個切面類的執行順序。

「1.建立一個自定義註解」

@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface CheckParam {}

「2.建立兩個切面類」 切面類1,驗證引數id

``` @Aspect @Component @Order(1) public class CheckIDAdvice {

@Pointcut("@annotation(com.dawang.annotation.CheckParam)")     private void checkId() {     }

@Around("checkId()")     public Object checkIdAround(ProceedingJoinPoint joinPoint) throws Throwable {

Object[] objects = joinPoint.getArgs();         Integer id = (Integer) objects[1];         if(id != null){             System.out.println("切面CheckIDAdvice驗證引數id:"+id );         }

return joinPoint.proceed();     } } ```

切面類2,驗證引數name

``` @Aspect @Component @Order(2) public class CheckNameAdvice {

@Pointcut("@annotation(com.dawang.annotation.CheckParam)")     private void checkName() {     }

@Around("checkName()")     public Object checkNameAround(ProceedingJoinPoint joinPoint) throws Throwable {

Object[] objects = joinPoint.getArgs();         String name = (String) objects[0];         if(name != null){             System.out.println("切面CheckNameAdvice驗證引數name:"+name );         }

return joinPoint.proceed();     } } ```

「3.自定義註解作用在目標方法上」

@Service public class TestServiceImpl implements TestService {     @CheckParam()     public String test(String name, Integer id) {         return "name:"+name + " id:"+id;     } }

「4.測試」

``` @RunWith(SpringRunner.class) @SpringBootTest(classes = SpringaopDemoApplication.class) class SpringaopTests {

@Autowired     private TestService testService;

@Test     public void test1(){         testService.test("admin",1);     } } ```

結果:

Image.png

五. @Pointcut註解表示式

5.1 「定義」

@Pointcut(value="表示式標籤 (表示式格式)")

@Pointcut註解表示式標籤有很多比如:execution,within,this,target,args,@within,@target,@args,@annotation,bean,使用最多的就是execution表示式

5.2 「execution表示式」

@Pointcut用來定義切點,藉助屬性execution()描述目標類或目標方法所在的位置,execution表示式可以直接指定目標類或目標方法,也可以指定目標類和目標方法所在的範圍,通常後一種指定範圍的方式用途較廣。

我有一個同事,最近剛買了房,大家興趣濃厚,都在打聽在哪買的。他本人說買在地鐵口。我們問哪個地鐵口?他說就是歐尚旁邊的那個地鐵口。哪邊的歐尚?我家旁邊的歐尚你家在哪?在地鐵口。

在同事眼中地鐵口附近,歐尚旁邊都是指向家的位置,但是你家又不是我家,我們大家又怎麼知道你家到底住在哪? 在計算機世界裡就不會原地繞圈子了,比如execution表示式就是定位接入點的位置,趕緊學好execution表示式,幫助迷路的同事回家。

execution表示式的定義execution(<修飾符模式>?<返回型別模式><方法名模式>(<引數模式>)<異常模式>?)除了返回型別模式、方法名模式和引數模式外,其它項都是可選的。

execution表示式三板斧:返回型別模式,方法名模式,引數模式,其他的都是錦上添花,可有可無。這裡提到模式是指萬用字元匹配模式,是因為返回型別、方法名,引數等大多數情況下不會明確的指定確定值,會通過萬用字元確定值的範圍。

常用萬用字元:

* :匹配任何數量字元

+:匹配指定型別及其子型別;僅能作為字尾放在型別模式後邊

.. :匹配任何數量字元的重複,如在型別模式中匹配任何數量子包;而在方法引數模式中匹配任何數量引數(0個或者多個引數)

注意:如果需要匹配子類使用‘+’,如果匹配子包使用‘..*’,這在後面會有涉及。

「5.2.1.方法名模式」

在三板斧裡,使用通常使用方法名模式和引數模式進行匹配連線點,而方法名模式又分了三種方式:

  1. 通過方法簽名匹配連線點
  2. 通過類匹配連線點
  3. 通過類包匹配連線點

execution.png

「1.通過方法簽名匹配連線點」

在execution表示式中方法簽名可通過直接指明匹配或字首匹配或字尾匹配的方式進行匹配,除了方法簽名,三板斧中的其他部分就按萬用字元的方式書寫即可比如:

匹配目標類的test方法

execution表示式:execution( * test(..))

說明:第一個‘*’對應三板斧的返回型別模式,test對應三板斧的方法名模式,‘..’對應三板斧的引數模式

既然說到了方法簽名字首字尾方式,那就匹配目標類所有以util為字尾的方法,那就舉個方法簽名字尾的例子:execution( Util(..))說明:‘*Util’是以Util為字尾的方法,如DateUtil,StringUtil等

「2.通過類匹配連線點」 這裡的類匹配,還是屬於方法名模式的範圍,只是方法名不明確指出,方法所屬的全限定類名需要明確寫出,全限定類名 = 包名+類名。比如想要匹配Person類下的所有方法

execution表示式:execution( * com.demo.Person.*(..))

說明:第一個‘ ’對應三板斧的返回型別模式,第二個‘’對應三板斧的方法名模式中的任意方法名,‘..’對應三板斧的引數模式 如果Person類還有子類,子類中的方法也要匹配上那麼只要在Person類後加上‘+’即可,也就是execution( * com.demo.Person+. (..))

「3.通過類包匹配連線點」

這個類包有點類似於掃描bean時的包路徑,也就是在該路徑下的類和方法都能匹配到。

比如,想要匹配com.demo包下的所有類和方法

execution表示式:execution( com.domo.(..))

說明:第一個‘’對應三板斧的返回型別模式,第二個‘’對應三板斧的方法名模式中的任意類名和方法名,‘..’對應三板斧的引數模式 以上的方式有個缺點,如果com.dome包下還有子包com.dome.controller,那就匹配不上了,針對這種問題,解決方式就是使用‘..’表示本包和子包下的說有類和方法execution表示式:execution( com.domo..*(..))說明:com.domo包下的類和方法可以匹配到,com.domo.controller下的類和方法也可以匹配到。

「5.2.2.引數模式」

1.「 ‘*’和‘..’的使用」

在方法名模式中通過方法簽名,類名,包名的方式進行匹配,那麼在引數模式中就比較靈活了,通常會通過引數型別進行匹配,在以上的匹配模式中,引數模式都是使用‘..’表示任意型別引數且引數個數不限,除此之外還會使用‘’表示任意型別的引數,引數個數為一個,這點要格外注意。比如execution( test(String,)))和execution( test(String,..)))就不完全等價,前者匹配目標類中的test方法引數只能有兩個,第一個是String型別,第二個是任意型別。但是第二個execution表示式的test方法引數可能是一個,也可能是兩個,甚至更多,無論是哪個,第一個引數為String型別是確定的,其餘引數可以是任意個數和任意型別。

2. 「‘+’的使用」

如果引數的型別是一個引用型別,凡是使用該引用型別的引數都是可以匹配到的,但是它的子類就沒有這麼幸運了,對於引數模式中明確指定的型別,匹配時就嚴格按此型別進行匹配,如果既要匹配本類還要匹配子類,那麼只能通過‘+’來解決了,在引用類引數後新增‘+’即可,這樣既能匹配本類,也能匹配子類。execution表示式:execution(* test(Object+)))說明:Object類是String類的父類,在execution表示式中既能匹配test(Object object),又能匹配test(String str)

案例

匹配任意方法:execution( (..))

匹配任何以get開始的方法:execution( get(..))

匹配TestService介面的任意方法,在實際執行時,真正匹配的是實現類中實現的方法:execution( com.dome.TestService.(..))

匹配TestService介面的任意方法和實現類的所有方法,哪怕實現類中的非實現方法也能匹配:execution( com.dome.TestService+.(..)

匹配dao包裡的任意方法: execution( com.domo.dao. .* (..))

匹配dao包和所有子包裡的任意類的任意方法:execution( com.demo.dao...(..))*

5.3 「within表示式」

within表示式中的表示式格式不一定是型別全限定名,可能是一個類或者包,它的作用就是匹配這個類或包下的方法

``` 切點 @Pointcut(within("Person"))

接入點test方法 public class Person{    public void test(String o){} } ```

5.4 「this表示式」

我們直到spring aop是通過代理實現的,在實現過程中使用代理物件表示目標物件,那麼this表示式中的表示式格式就是代理物件的型別,通常使用JDK動態代理物件,JDK動態代理的特點就是隻能對實現了介面的類生成代理,那麼這時這個代理物件的型別時一個介面型別,this表示式中的表示式格式就是一個介面,另外還要求這個介面必須是型別全限定名,介面中有方法簽名,當有類實現這個介面時,這個類中的方法就是要匹配的目標。

``` 切點 @Pointcut(this(String))

接入點test方法 public class Son implements Person{    public void test(Object o){} }

public interface Person{    public void test(Object o); } ```

5.5 「target表示式」

target表示式中的表示式格式是一個介面,介面中有方法簽名,當有類實現了這個介面,類中實現的方法就是匹配的目標。

``` 切點 @Pointcut(target(String))

接入點test方法 public class Son implements Person{    public void test(Object o){} } public interface Person{    public void test(Object o); } ```

5.6 「args表示式」

args表示式中的表示式格式是引數的型別,這個型別是全限定名的,不支援萬用字元。目標方法引數型別並非書面書寫的型別,只有在執行時才能確定,為了使用args表示式,通常目標方法的引數型別是個父類,表示式格式的引數型別是子類,才能成立,這樣在執行時才能確定真實型別的匹配方式比較耗費資源,不建議使用。

``` 切點 @Pointcut(args(String))

接入點test方法 public class Person{    public void test(Object o){} } ```

5.7「 @within表示式」

@within表示式中的表示式格式是一個註解的全限定名,如果一個類使用了這個註解,那麼這個類中的方法就是需要匹配的目標。值得注意的是,這個註解在定義的沒有使用 @Inherited,也就是說明子類不會繼承父類的這個註解,那麼@within表示式遇到這種情況,也就不會匹配到子類裡的方法,只對標註這個註解的父類起作用。

``` 切點 @Pointcut(@within(org.springframework.transaction.annotation.Transactional))

接入點test方法 @Transactional public class Person{    public void test(){} } ```

5.8「@target表示式」

@target表示式中的表示式格式是一個註解的全限定名,如果一個類使用了這個註解,那麼這個類中的方法就是需要匹配的目標。面對在定義註解時有沒有使用@Inherited,它的處理方式和@within一樣。

``` 切點 @Pointcut(@target(org.springframework.transaction.annotation.Transactional))

接入點test方法 @Transactional public class Person{     public void test(){} } ```

5.9「@args表示式」

@args表示式中的表示式格式是一個註解的全限定名,如果一個方法的引數是一個類,並且這個在定義的時候使用了這個註解,那麼這個方法就會被匹配到。注意,這個方法方法必須只有一個引數,還有註解時標註在引數類的原始定義上,不是標註在這個方法引數上。

``` 切點 @Pointcut(@args(org.springframework.transaction.annotation.Transactional))

接入點test方法 public void test(Person p){}

Person類定義 @Transactional public class Person(){} ```

5.10「@annotation表示式」

@annotation表示式中的表示式格式是一個註解的全限定名,這次不再遮遮掩掩,只要方法上標註了這個註解都能匹配到,用途較廣。

``` 切點 @Pointcut(@annotation(org.springframework.transaction.annotation.Transactional))

接入點test方法 @Transactional public void test(){} ```

5.11「bean表示式」

bean表示式中的表示式格式是一個bean的字串名字,這個名字可以使用萬用字元,如果容器中的bean的名字匹配表示式,那麼bean中的方法就是匹配的目標。

``` 切點 @Pointcut(bean("testService"))

接入點test方法 @Service("testService") public class TestService{    public void test(){} } ```

六. 5種通知型別

spring有5種不同的通知型別,分別是前置通知,返回後通知,丟擲異常後通知,後置通知和環繞通知。

  1. 「前置通知」

    它是一個使用@Before標註的增強方法,在目標業務方法之前執行

  2. 「返回後通知」

    它是一個使用@AfterReturning標註的增強方法,在目標方法正常返回後執行,這裡的正常方法是指目標方法執行沒有發生異常。如果如果目標方法發生了異常就使用“丟擲異常後通知”

  3. 「丟擲異常後通知」

    它是一個使用@AfterThrowing標註的增強方法,在目標方法執行時發生異常時執行。

  4. 「後置通知」

    它是一個使用@After標註的增強方法,無論目標方法是否發生了異常,這個增強方法都會被執行。

  5. 「環繞通知」

    它是一個使用@Around標註的增強方法,在這個增強方法裡可以呼叫執行目標方法,這樣在目標方法的前後可以新增一些處理邏輯,這就是所謂的環繞。這就提供了比較大的發揮空間,我們可以決定它的執行時間,可以改變它的引數值,可以改變目標方法的返回值,甚至阻止目標方法的執行。這個環繞通知集中了其他幾個增強通知的特性,功能很強大,對於一些簡單的通知,殺雞焉用牛刀,交給其他通知方法處理吧。

七. 5種通知型別傳遞引數

當增強方法作用在目標方法上時,如果目標方法還有引數傳遞,那麼在增強方法中怎麼獲得這些引數?

其實,每個增強方法都是可以攜帶任意引數的,通常第一個引數的型別是JoinPoint型別,顧名思義就是連線點目標方法,這個JoinPoint類中定義了 getArgs方法,可以返回目標方法的引數。

值得注意的是,在環繞通知裡,第一個引數的型別是ProceedingJoinPoint,無需驚慌,這只是JoinPoint的子類,仍可使用getArgs方法獲得目標方法的引數。

  1. 「前置通知」

@Before("pointcut()")  public void beforeOne(JoinPoint point){     System.out.println("目標方法的引數:" + Arrays.toString(point.getArgs())); }

  1. 「返回後通知」

    @AfterReturning屬性returning的值需和引數列表的形參保持一致

@AfterReturning(value="pointcut()", returning="returnValue") public void afterReturningOne(JoinPoint point, Object returnValue){    System.out.println("目標方法的引數:" + Arrays.toString(point.getArgs()));     System.out.println("返回值:" + returnValue); }

  1. 「丟擲異常後通知」

    @AfterThrowing屬性throwing的值需和引數列表的形參保持一致形參的型別宣告為Throwable,意味著對目標方法丟擲的異常不加限制

@AfterThrowing(value="pointcut()", throwing="ex") public void afterThrowingOne(JoinPoint point, Throwable ex){    System.out.println("目標方法的引數:" + Arrays.toString(point.getArgs()));    System.out.println("異常:" + ex); }

  1. 「後置通知」

@After(value="pointcut()") public void afterOne(JoinPoint point){    System.out.println("目標方法的引數:" + Arrays.toString(point.getArgs())); }

  1. 「環繞通知」

    這裡必須返回目標方法的呼叫值

@Around(value="pointcut()") public Object process(ProceedingJoinPoint point) throws Throwable{    System.out.println("目標方法的引數:" + Arrays.toString(point.getArgs()));     return point.proceed(args); }

  1. 「彩蛋」

    除了以上使用JoinPoint的方式獲取目標方法的引數,其實還有更簡便的方式:args表示式 比如,前置通知使用args表示式獲得目標方法引數這裡需要注意,args表示式裡的格式應該是型別的宣告,為何這裡成了自定義的引數變數,無需驚慌,自定義的引數變數對應方法引數裡的形參,它所需要的型別宣告,轉移到了形參裡的型別宣告。這樣做的好處是不僅可以按引數型別匹配目標方法,還可以獲得目標方法的引數值,一舉兩得。

@Before("pointcut() && args(name, id)") public void beforeOne(String name,int id){    System.out.println("目標方法的引數:name:" + name+" id:"+id); }

八 . 總結

臉皮厚能吃肉,臉皮薄吃不著。使用springaop很多年了,這次厚著臉皮給大家再說道說道。

  • 為什麼需要aop,彌補oop的不足
  • SpringAOP的定義,切點,接入點,通知,切面等幾大技術術語的解析
  • SpringAOP體系,aop的執行過程
  • 一個例項,一個驗證引數的例子,涉及到自定義註解
  • @Pointcut註解表示式,涉及到10種匹配接入點的方式
  • 5種通知型別,前置通知,後置通知,環繞通知,返回後通知,丟擲異常後通知
  • 5種通知型別傳遞引數,如何在增強方法中獲得目標方法的引數

最後奉上本文涉及到的原始碼程式設計師控制代碼/spring-demo-all (gitee.com),不止是原始碼,有彩蛋!

經歷的痛苦愈多,體會到的喜悅就愈多。 我是控制代碼,我們下期見!