深入Spring官網系列(十八):AOP詳細解析!

Hi丶ImViper發表於2020-10-30

本篇文章將作為整個Spring官網閱讀筆記的最後一篇。如果要談SpringFramework必定離不開兩點

  1. IOC(控制反轉)
  2. AOP(面向切面)

在前面的文章中我們已經對IOC做過詳細的介紹了,本文主要介紹AOP,關於其中的原始碼部分將在專門的原始碼專題介紹,本文主要涉及的是AOP的基本概念以及如何使用,本文主要涉及到官網中的第5、6兩大章

什麼是AOP

AOP為Aspect Oriented Programming的縮寫,意為:面向切面程式設計,通過預編譯方式和執行期間動態代理實現程式功能的統一維護的一種技術。AOPOOP的延續,是軟體開發中的一個熱點,也是Spring框架中的一個重要內容,是函數語言程式設計的一種衍生範型。利用AOP可以對業務邏輯的各個部分進行隔離,從而使得業務邏輯各部分之間的耦合度降低,提高程式的可重用性,同時提高了開發的效率。

  																																------------------《百度百科》

可能你看完上面這段話還是迷迷糊糊,一堆專業詞彙看起來很牛逼的樣子,不用擔心,任何東西都是需要積累的,有些東西只需要記在腦子裡,在以後實踐的過程中自然而然的就明白了。

另外放一段網上大佬寫的一段話,我覺得很好的解釋了物件導向跟面向切面

物件導向程式設計解決了業務模組的封裝複用的問題,但是對於某些模組,其本身並不獨屬於摸個業務模組,而是根據不同的情況,貫穿於某幾個或全部的模組之間的。例如登入驗證,其只開放幾個可以不用登入的介面給使用者使用(一般登入使用攔截器實現,但是其切面思想是一致的);再比如效能統計,其需要記錄每個業務模組的呼叫,並且監控器呼叫時間。可以看到,這些橫貫於每個業務模組的模組,如果使用物件導向的方式,那麼就需要在已封裝的每個模組中新增相應的重複程式碼,對於這種情況,面向切面程式設計就可以派上用場了。

面向切面程式設計,指的是將一定的切面邏輯按照一定的方式編織到指定的業務模組中,從而將這些業務模組的呼叫包裹起來

AOP中的核心概念

官網中的相關介紹如下:

在這裡插入圖片描述

是不是看得頭皮發麻,不用緊張,我們一點點看過去就好了,現在從上往下開始介紹

前置場景,假設我們現在要對所有的controller層的介面進行效能監控

切面

切點跟通知組成了切面

連線點

所有我們能夠將通知應用到的地方都是連線點,在Spring中,我們可以認為連線點就是所有的方法(除建構函式外),連線點沒有什麼實際意義,這個概念的提出只是為了更好的說明切點

通知

就是我們想要額外實現的功能,在上面的例子中,實現了效能監控的方法就是通知

切點

在連線點的基礎上,來定義切入點,比如在我們上面的場景中,要對controller層的所有介面完成效能監控,那麼就是說所有controller中的方法就是我們的切點(service層,dao層的就是普通的連線點,沒有什麼作用)。

引入

我們可以讓代理類實現目標類沒有實現的額外的介面以及持有新的欄位。

目標物件

引入中所提到的目標類,也就是要被通知的物件,也就是真正的業務邏輯,他可以在毫不知情的情況下,被織入切面,而自己專注於業務本身的邏輯。

代理物件

將切面織入目標物件後所得到的就是代理物件。代理物件是正在具備通知所定義的功能,並且被引入了的物件。

織入

把切面應用到目標物件來建立新的代理物件的過程。切面的織入有三種方式

  1. 編譯時織入
  2. 類載入時期織入
  3. 執行時織入

我們通常使用的SpringAOP都是編譯時期織入,另外Spring中也提供了一個Load Time Weaving
LTW,載入時期織入)的功能,此功能使用較少,有興趣的同學可以參考一下兩個連結:

  • https://www.cnblogs.com/wade-luffy/p/6073702.html
  • https://docs.spring.io/spring/docs/5.1.14.BUILD-SNAPSHOT/spring-framework-reference/core.html#aop-aj-ltw

上面這些名詞希望你不僅能記住而且要能理解,因為不管是Spring原始碼還是官網中都使用了這些名詞,並且從這些名稱中還衍生了一些新的名詞,比如:Advisor,雖然這些在原始碼階段會再介紹,不過如果現在能懂的話無疑就在為學習原始碼減負了。

在對AOP中的核心概念有了一定了解之後,我們就來看看,如何使用AOP,在學習使用時,第一步我們需要知道怎麼去在容器中申明上面所說的那些AOP中的元素

Spring中如何使用AOP

XML方式本文不再介紹了,筆者近兩年來沒有通過XML的方式來使用過SpringAOP,現在註解才是王道,本文也只會介紹註解的方式

1、開啟AOP

@Configuration
@ComponentScan("com.spring.study.springfx.aop")
// 開啟AOP
@EnableAspectJAutoProxy
public class Config {
}

核心點就是在配置類上新增@EnableAspectJAutoProxy,這個註解中有兩個屬性如下:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Import(AspectJAutoProxyRegistrar.class)
public @interface EnableAspectJAutoProxy {
	// 是否使用CGLIB代理,預設不使用。預設使用JDK動態代理
	boolean proxyTargetClass() default false;
	
    // 是否將代理類作為執行緒本地變數(threadLocal)暴露(可以通過AopContext訪問)
    // 主要設計的目的是用來解決內部呼叫的問題
	boolean exposeProxy() default false;

}

2、申明切面

@Aspect  // 申明是一個切面
@Component  // 切記,一定要將切面交由Spring管理,否則不起作用
public class DmzAnnotationAspect {
	//......
}

3、申明切點

我們一般都會通過切點表示式來申明切點,切點表示式一般可以分為以下幾種

切點表示式

excecution表示式
語法

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

這裡問號表示當前項是非必填的,其中各項的語義如下:

  • modifiers-pattern(非必填):方法的可見性,如public,protected;
  • ret-type-pattern(必填):方法的返回值型別,如int,void等;
  • declaring-type-pattern(非必填):方法所在類的全路徑名,如com.spring.Aspect;
  • name-pattern(必填):方法名型別,如buisinessService();
  • param-pattern(必填):方法的引數型別,如java.lang.String;
  • throws-pattern(非必填):方法丟擲的異常型別,如java.lang.Exception;

可以看到,必填的引數只有三個,返回值方法名方法引數

示例

按照上面的語法,我們可以定義如下的切點表示式

// 1.所有許可權為public的,返回值不限,方法名稱不限,方法引數個數及型別不限的方法,簡而言之,所有public的方法
execution(public * *(..))

// 2.所有許可權為public的,返回值限定為String的,方法名稱不限,方法引數個數及型別不限的方法
execution(public java.lang.String *(..)) 

// 3.所有許可權為public的,返回值限定為String的,方法名稱限定為test開頭的,方法引數個數及型別不限的方法
execution(public java.lang.String test*(..))

// 4.所有許可權為public的,返回值限定為String的,方法所在類限定為com.spring.study.springfx.aop.service包下的任意類,方法名稱限定為test開頭的,方法引數個數及型別不限的方法
execution(public java.lang.String com.spring.study.springfx.aop.service.*.test*(..))
  
// 5.所有許可權為public的,返回值限定為String的,方法所在類限定為com.spring.study.springfx.aop.service包及其子包下的任意類,方法名稱限定為test開頭的,方法引數個數及型別不限的方法
execution(public java.lang.String com.spring.study.springfx.aop.service..*.test*(..))

// 6.所有許可權為public的,返回值限定為String的,方法所在類限定為com.spring.study.springfx.aop.service包及其子包下的Dmz開頭的類,方法名稱限定為test開頭的,方法引數個數及型別不限的方法
execution(public java.lang.String com.spring.study.springfx.aop.service..Dmz*.test*(..))

// 7.所有許可權為public的,返回值限定為String的,方法所在類限定為com.spring.study.springfx.aop.service包及其子包下的Dmz開頭的類,方法名稱限定為test開頭的,方法引數限定第一個為String類,第二個不限但是必須有兩個引數
execution(public java.lang.String com.spring.study.springfx.aop.service..Dmz*.test*(String,*))

// 8.所有許可權為public的,返回值限定為String的,方法所在類限定為com.spring.study.springfx.aop.service包及其子包下的Dmz開頭的類,方法名稱限定為test開頭的,方法引數限定第一個為String類,第二個可有可無並且不限定型別
execution(public java.lang.String com.spring.study.springfx.aop.service..Dmz*.test*(String,..))

看完上面的例子不知道大家有沒有疑問,比如為什麼修飾符一直是public呢?其它修飾符行不行呢?修飾符的位置能不能寫成*這種形式呢?

答:

  1. 如果使用的是JDK動態代理,這個修飾符必須是public,因為JDK動態代理是針對於目標類實現的介面進行的,介面的實現方法必定是public的。
  2. 如果不使用JDK動態代理而使用CGLIB代理(@EnableAspectJAutoProxy(proxyTargetClass = true))那麼修飾符還可以使用protected或者預設修飾符。但是不能使用private修飾符,因為CGLIB代理生成的代理類是繼承目標類的,private方法子類無法複寫,自然也無法代理。基於此,修飾符是不能寫成*這種格式的。
@annotation表示式
語法

@annotation(annotation-type)

示例
// 代表所有被DmzAnnotation註解所標註的方法
// 使用註解的方法定義切點一般會和自定義註解配合使用
@annotation(com.spring.study.springfx.aop.annotation.DmzAnnotation)
within表示式
語法

within(declaring-type-pattern)

示例
// within表示式只能指定到類級別,如下示例表示匹配com.spring.service.BusinessObject中的所有方法
within(com.spring.service.BusinessObject)

// within表示式能夠使用萬用字元,如下表示式表示匹配com.spring.service包(不包括子包)下的所有類
within(com.spring.service.*)        

// within表示式能夠使用萬用字元,如下表示式表示匹配com.spring.service包及子包下的所有類
within(com.spring.service..*)    

官網中一共給出了9中切點表示式的定義方式,但是實際上我們常用的就兩種,就是excecution表示式以及annotation表示式。所以下文對於其餘幾種本文就不做詳細的介紹了,大家有興趣的可以瞭解,沒有興趣的可以直接跳過。可以參考官網連結

@within表示式
語法

@within(annotation-type)

annotation表示式的區別在於,annotation表示式是面向方法的,表示匹配帶有指定註解的方法,而within表示式是面向類的,表示匹配帶有指定註解的類。

示例
// 代表所有被DmzAnnotation註解所標註的類
// 使用註解的方法定義切點一般會和自定義註解配合使用
@within(com.spring.study.springfx.aop.annotation.DmzAnnotation)
arg表示式
語法

args(param-pattern)

示例
// 匹配所有隻有一個String型別的方法
args(String)
// 匹配所有有兩個引數並且第一個引數為String的方法
args(String,*)
// 匹配所有第一個引數是String型別引數的方法
args(String,..)
@args表示式
語法

@args(annotation-type)

示例

@args(com.spring.annotation.FruitAspect)

@annotation表示式以及@within表示式類似,@annotation表示式表示匹配使用了指定註解的方法,@within表示式表示式表示匹配了使用了指定註解的類,而@args表示式則代表使用了被指定註解標註的類作為方法引數

this表示式
// 代表匹配所有代理類是AccountService的類
this(com.xyz.service.AccountService)
target表示式
// 代表匹配所有目標類是AccountService的類
target(com.xyz.service.AccountService)

this跟target很雞肋,基本用不到

定義切點

@Aspect
@Component
public class DmzAnnotationAspect {
    @Pointcut("execution(public * *(..))")
    private void executionPointcut() {}
	
    @Pointcut("@annotation(com.spring.study.springfx.aop.annotation.DmzAnnotation)")
    private void annotationPointcut() { }
    
    // 可以組合使用定義好的切點
    
    // 表示同時匹配滿足兩者
    @Pointcut("executionPointcut() && annotationPointcut()")
    private void annotationPointcutAnd() {}
	
    // 滿足其中之一即可
    @Pointcut("executionPointcut() || annotationPointcut()")
    private void annotationPointcutOr() {}
	
    // 不匹配即可
    @Pointcut("!executionPointcut()")
    private void annotationPointcutNot() {}
}

現在我們已經在一個切面中定義了兩個切點了,現在開始編寫通知

4、申明通知

通知的型別

Before

在目標方法之前執行,如果發生異常,會阻止業務程式碼的執行

AfterReturning

跟Before對應,在目標方法完全執行後(return後)再執行

AfterThrowing

方法丟擲異常這個通知仍然會執行(這裡的方法既可以是目標方法,也可以是我們定義的通知)

After(Finally)

切記,跟Before對應的是AfterReturning,一個在目標方法還沒執行前執行,一個在目標方法完全執行後(return後)再執行,這個After型別的通知型別我們在編寫程式碼時的Finally,即使方法丟擲異常這個通知仍然會執行(這裡的方法既可以是目標方法,也可以是我們定義的通知)。

一般我們使用After型別的通知都是為了完成資源的釋放或者其它類似的目的

Around

最強大的通知型別,可以包裹目標方法,其可以傳入一個ProceedingJoinPoint用於呼叫業務模組的程式碼,無論是呼叫前邏輯還是呼叫後邏輯,都可以在該方法中編寫,甚至其可以根據一定的條件而阻斷業務模組的呼叫,可以更改目標方法的返回值

實際應用

@Aspect
@Component
public class DmzAspect {
	
    // 申明的切點
    @Pointcut("execution(public * *(..))")
    private void executionPointcut() {}
    @Pointcut("@annotation(com.spring.study.springfx.aop.annotation.DmzAnnotation)")
    private void annotationPointcut() {}
	
    // 前置通知,在目標方法前呼叫
    @Before("executionPointcut()")
    public void executionBefore() {
        System.out.println("execution aspect Before invoke!");
    }
    
    // 後置通知,在目標方法返回後呼叫
    @AfterReturning("executionPointcut()")
    public void executionAfterReturning() {
        System.out.println("execution aspect AfterReturning invoke!");
    }
	
    // 最終通知,正常的執行時機在AfterReturning之前
    @After("executionPointcut()")
    public void executionAfter() {
        System.out.println("execution aspect After invoke!");
    }

 	// 異常通知,發生異常時呼叫
    @AfterThrowing("executionPointcut()")
    public void executionAfterThrowing() {
        System.out.println("execution aspect AfterThrowing invoke!");
    }
	
    // 環繞通知,方法呼叫前後都能進行處理
    @Around("executionPointcut()")
    public void executionAround(ProceedingJoinPoint pjp) throws Throwable{
        System.out.println("execution aspect Around(before) invoke!");
        System.out.println(pjp.proceed());
        System.out.println("execution aspect Around(after) invoke!");
    }
}

通知中的引數

在上面應用的例子中,只有在環繞通知的方法上我新增了一個ProceedingJoinPoint型別的引數。這個ProceedingJoinPoint意味著當前執行中的方法,它繼承了JoinPoint介面。

JoinPoint

JoinPoint可以在任意的通知方法上作為第一個引數申明,代表的時候通知所應用的切點(也就是目標類中的方法),它提供了以下幾個方法:

  • getArgs(): 返回當前的切點的引數
  • getThis(): 返回代理物件
  • getTarget(): 返回目標物件
  • getSignature(): 返回這個目標類中方法的描述資訊,比如修飾符,名稱等
ProceedingJoinPoint

ProceedingJoinPoint在JoinPoint的基礎上多提供了兩個方法

  • proceed():直接執行當前的方法,基於此,我們可以在方法的執行前後直接加入對應的業務邏輯
  • proceed(Object[] args):可以改變當前執行方法的引數,然後用改變後的引數執行這個方法

通知的排序

當我們對於一個切點定義了多個通知時,例如,在一個切點上同時定義了兩個before型別的通知。這個時候,為了讓這兩個通知按照我們期待的順序執行,我們需要在切面上新增org.springframework.core.annotation.Order註解或者讓切面實現org.springframework.core.Ordered介面。如下:

@Aspect
@Component
@Order(-1)
public class DmzFirstAspect {
    // ...
}

@Aspect
@Component
@Order(0)
public class DmzSecondAspect {
    // ...
}

AOP的應用

AOP的實際應用非常多,我這裡就給出兩個例子

  1. 全域性異常處理器
  2. 利用AOP列印介面日誌

全域性異常處理器

需要用到兩個註解:@RestControllerAdvice及@ExceptionHandler`,總共分為以下幾步:

  1. 定義自己專案中用到的錯誤碼及對應異常資訊
  2. 封裝自己的異常
  3. 申明全域性異常處理器並針對業務中的異常做統一處理

定義錯誤碼及對應異常資訊

@AllArgsConstructor
@Getter
public enum ErrorCode {

    INTERNAL_SERVICE_ERROR(500100, "服務端異常"),

    PASSWORD_CAN_NOT_BE_NULL(500211, "登入密碼不能為空"),

    PASSWORD_ERROR(500215, "密碼錯誤");
    
    private int code;

    private String msg;
}

// 統一返回的引數
@Data
public class Result<T> {
    private int code;
    private String msg;
    private T data;
    
    
    public static <T> Result<T> success(T data){
        return new Result<T>(data);
    }
    
    public static <T> Result<T> error(ErrorCode cm){
        return new Result<T>(cm.getMsg);
    }
}

封裝對應異常

public class GlobalException extends RuntimeException {
    
    private static final long serialVersionUID = 1L;
    
    private int errorCode;
    
    public CreativeArtsShowException(int errorCode) {
        this.errorCode = errorCode;
    }

    public CreativeArtsShowException(ErrorCode errorCode) {
        super(errorCode.getMsg());
        this.errorCode =  errorCode.getCode();
    }
}

申明異常處理器

//該註解定義全域性異常處理類
//@ControllerAdvice
//@ResponseBody
// 使用@RestControllerAdvice可以替代上面兩個註解
@RestControllerAdvice
//@ControllerAdvice(basePackages ="com.example.demo.controller") 可指定包
public class GlobalExceptionHandler {
    @ExceptionHandler(value=GlobalException.class) //該註解宣告異常處理方法
    public Result<String> exceptionHandler(HttpServletRequest request, Exception e){
        e.printStackTrace();
        // 在這裡針對異常做自己的處理
    }
}

其實SpringMVC中提供了一個異常處理的基類(org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler)。我們只需要將自定義的異常處理類繼承這個ResponseEntityExceptionHandler然後複寫對應的方法即可完成全域性異常處理。這個類中的方法很簡單,所以這裡就不放程式碼了。

這個類中已經定義了很多的異常處理方法,如下:

@ExceptionHandler({
			HttpRequestMethodNotSupportedException.class,
			HttpMediaTypeNotSupportedException.class,
			HttpMediaTypeNotAcceptableException.class,
			MissingPathVariableException.class,
			MissingServletRequestParameterException.class,
			ServletRequestBindingException.class,
			ConversionNotSupportedException.class,
			TypeMismatchException.class,
			HttpMessageNotReadableException.class,
			HttpMessageNotWritableException.class,
			MethodArgumentNotValidException.class,
			MissingServletRequestPartException.class,
			BindException.class,
			NoHandlerFoundException.class,
			AsyncRequestTimeoutException.class
		})

所以我們只需要複寫對應異常處理的方法即可完成自己在當前業務場景下異常的處理。但是需要注意的是,它只會對上面這些框架丟擲的異常進行處理,對於我們自定義的異常還是會直接丟擲,所以我們自定義的異常處理還是需要在其中進行定義。

介面日誌

我們在開發中經常會列印日誌,特別是介面的入參日誌,如下:

@RestController
@RequestMapping("/test/simple")
@Validated
@Slf4j
public class ValidationController {

    @GetMapping("/valid")
    public String testValid(
            @Max(10) int age, @Valid @NotBlank String name) {
        log.info("介面入參:" + age + "      " + name);
        return "OK";
    }
}

如果每一個介面都需要新增這樣一句程式碼的話就顯得太LOW了,基於此我們可以使用AOP來簡化程式碼,按照以下幾步即可:

  1. 自定義一個註解
  2. 申明切面

定義一個註解

@Target({ElementType.TYPE,ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface Log {

}

申明切面

@Aspect
@Component
@Slf4j
public class LogAspect {
    @Pointcut("@annotation(com.spring.study.springfx.aop.annotation.Log)")
    private void pointcut() {
    }

    @Before("pointcut()")
    public void before(JoinPoint joinPoint) {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        Parameter[] parameters = method.getParameters();
        Object[] args = joinPoint.getArgs();
        String methodName = method.getName();
        Class<?> declaringClass = method.getDeclaringClass();
        String simpleName = declaringClass.getSimpleName();
        StringBuilder sb = new StringBuilder();
        sb.append(simpleName).append(".").append(methodName).append(" [");
        for (int i = 0; i < parameters.length; i++) {
            String name = parameters[i].getName();
            sb.append(name);
            sb.append(":");
            sb.append(args[i]);
            sb.append(";");
        }
        sb.setLength(sb.length() - 1);
        sb.append("]");
        log.info(sb.toString());
    }
}

基於上面的例子測試:

@RestController
@RequestMapping("/test/simple")
@Validated
@Slf4j
public class ValidationController {

    @Log
    @GetMapping("/valid")
    public String testValid(
            @Max(10) int age, @Valid @NotBlank String name) {
        log.info("介面入參:" + age + "      " + name);
        return "OK";
    }
}

// 控制檯輸出日誌:
// ValidationController.testValid [age:0;name:11]

總結

這篇文章到這裡就結束啦,這也是《Spring官網閱讀筆記》系列筆記的最後一篇。其實整個SpringFrameWork可以分為三部分

  1. IOC
  2. AOP
  3. 事務(整合JDBC,MyBatis)

而IOC跟AOP又是整個Spring的基石,這一系列的筆記有10篇以上是IOC相關的知識。AOP的只有這一篇,這是因為Spring簡化了AOP的使用,如果要探究其原理以及整個AOP的體系的話必定要深入到原始碼中去,所以思來想去還是決定將其放到原始碼閱讀系列筆記中去。

大家可以關注我接下來的一系列文章哦~

碼字不易,要是覺得不錯,記得點個贊哈,感謝!

相關文章