用心整理 | Spring AOP 乾貨文章,圖文並茂,附帶 AOP 示例 ~

Spring AOP 是 Java 面試的必考點,咱們須要瞭解 AOP 的基本概念及原理。那麼 Spring AOP 究竟是啥,爲何面試官這麼喜歡問它呢?本文先介紹 AOP 的基本概念,而後根據 AOP 原理,實現一個接口返回統一格式的小示例,方便你們理解 Spring AOP 到底如何用!

1、爲何要使用 AOP ?

<!--more-->html

在實際的開發過程當中,咱們的應用程序會被分爲不少層。一般來說一個 Java 的 Web 程序會擁有如下幾個層次:前端

  • Web 層:主要是暴露一些 Restful API 供前端調用。
  • 業務層:主要是處理具體的業務邏輯。
  • 數據持久層:主要負責數據庫的相關操做(增刪改查)。

雖然看起來每一層都作着全然不一樣的事情,可是實際上總會有一些相似的代碼,好比日誌打印和異常處理等。若是咱們選擇在每一層都獨立編寫這部分代碼,那麼長此以往代碼將變的很難維護。因此咱們提供了另外的一種解決方案:AOP。這樣能夠保證這些通用的代碼被聚合在一塊兒維護,並且咱們能夠靈活的選擇何處須要使用這些代碼。java

2、什麼是 AOP ?

AOP(Aspect Oriented Programming,面向切面編程),能夠說是 OOP(Object Oriented Programing,面向對象編程)的補充和完善。OOP 引入封裝、繼承和多態性等概念來創建一種對象層次結構,用來模擬公共行爲的一個集合。當咱們須要爲分散的對象引入公共行爲的時候,OOP則顯得無能爲力。也就是說,OOP 容許你定義從上到下的關係,但並不適合定義從左到右的關係。例如日誌功能,日誌代碼每每水平地散佈在全部對象層次中,而與它所散佈到的對象的核心功能毫無關係。對於其餘類型的代碼,如權限管理、異常處理等也是如此。這種散佈在各處的無關的代碼被稱爲橫切(cross-cutting)代碼,在 OOP 設計中,它致使了大量代碼的重複,而不利於各個模塊的重用。git

而 AOP 技術則偏偏相反,它利用一種稱爲 「橫切」 的技術,剖解開封裝的對象內部,並將那些影響了多個類的公共行爲封裝到一個可重用模塊,並將其名爲 「Aspect」 ,即切面。所謂「切面」,簡單地說,就是將權限、事務、日誌、異常等與業務邏輯相對獨立的功能抽取封裝,便於減小系統的重複代碼,下降模塊間的耦合度,增長代碼的可維護性。AOP 表明的是一個橫向的關係,若是說 「對象」 是一個空心的圓柱體,其中封裝的是對象的屬性和行爲;那麼面向切面編程,就彷彿一把利刃,將這些空心圓柱體剖開,以得到其內部的消息,而後又以巧奪天功的妙手將這些剖開的切面復原,不留痕跡。github

切面理解:用刀將西瓜分紅兩瓣,切開的切口就是切面;炒菜、鍋與爐子共同來完成炒菜,鍋與爐子就是切面。Web 層級設計中,Controller 層、Service 層、Dao 層,每一層之間也是一個切面。編程中,對象與對象之間,方法與方法之間,模塊與模塊之間都是一個個切面。web

推薦網上的一篇通俗易懂的 AOP 理解:https://blog.csdn.net/qukaiwe...面試

3、AOP 使用場景

  • 權限控制
  • 日誌存儲
  • 統一異常處理
  • 緩存處理
  • 事務處理
  • ……

4、AOP 專業術語

AOP有不少專業術語,初看這麼多術語,可能一會兒不大理解,多讀幾遍,相信很快就會搞懂。spring

一、Advice(通知)

  • 前置通知(Before advice):在目標方法調用前執行通知
  • 環繞通知(Around advice):在目標方法調用先後都可執行自定義邏輯
  • 返回通知(After returning advice):在目標方法執行成功後,調用通知
  • 異常通知(After throwing advice):在目標方法拋出異常後,執行通知
  • 後置通知(After advice):在目標方法完成(無論是拋出異常仍是執行成功)後執行通知

二、JoinPoint(鏈接點)

就是 Spring 容許你放通知(Advice)的地方,不少,基本每一個方法的前、後(二者都有也行)或拋出異常時均可以是鏈接點,Spring 只支持方法鏈接點,和方法有關的前先後後都是鏈接點。數據庫

Tips:可使用鏈接點獲取執行的類名、方法名和參數名等。編程

三、Pointcut(切入點)

是在鏈接點的基礎上來定義切入點。好比在一個類中,有 15 個方法,那麼就會有幾十個鏈接點,但只想讓其中幾個方法的先後或拋出異常時乾點什麼,那麼就用切入點來定義這幾個方法,讓切入點來篩選鏈接點。

四、Aspect(切面)

是通知(Advice)和切入點(Pointcut)的結合,通知(Advice)說明了幹什麼和何時(經過@Before、@Around、@After、@AfterReturning、@AfterThrowing來定義執行時間點)幹,切入點(Pointcut)說明了在哪(指定方法)幹,這就是一個完整的切面定義。

五、AOP 代理

AOP Proxy:AOP 框架建立的對象,代理就是目標對象的增強。AOP 巧妙的例用動態代理優雅的解決了 OOP 力所不及的問題。Spring 中的 AOP 代理能夠是 jdk 動態代理,也能夠是 cglib 動態代理。前者基於接口,後者基於子類。

5、AOP 示例:實現 Spring 接口返回統一(正常/異常)格式

讀完上面這麼多抽象概念,若是不來一個 AOP 具體示例,吸取效果或者理解深度可能不是那麼好。因此,請接着往下看:

一、定義返回格式

import lombok.Data;

@Data
public class Result<T> {
    
    // code 狀態值:0 表明成功,其餘數值表明失敗
    private Integer code;
    
    // msg 返回信息。若是code爲0,msg則爲success;若是code爲1,msg則爲error
    private String msg;

    // data 返回結果集,使用泛型兼容不一樣的類型
    private T data;
    
}

二、定義一些已知異常

import lombok.AllArgsConstructor;
import lombok.NoArgsConstructor;

@AllArgsConstructor
@NoArgsConstructor
public enum ExceptionEnum {

    UNKNOW_ERROR(-1, "未知錯誤"),
    NULL_EXCEPTION(-2, "空指針異常:NullPointerException"),
    INVALID_EXCEPTION(1146, "無效的數據訪問資源使用異常:InvalidDataAccessResourceUsageException");

    public Integer code;

    public String msg;

}

三、異常類捕獲並返回

//@ControllerAdvice
@Component
@Slf4j
public class ExceptionHandle {

    //    @ExceptionHandler(value = Exception.class)
//    @ResponseBody
    public Result exceptionGet(Throwable t) {
        log.error("異常信息:", t);
        if (t instanceof InvalidDataAccessResourceUsageException) {
            return ResultUtil.error(ExceptionEnum.INVALID_EXCEPTION);
        } else if (t instanceof NullPointerException) {
            return ResultUtil.error(ExceptionEnum.NULL_EXCEPTION);
        }
        return ResultUtil.error(ExceptionEnum.UNKNOW_ERROR);
    }

}

製做一個結果返回工具類:

public class ResultUtil {

    /**
     * @return com.study.spring.entity.Result
     * @description 接口調用成功返回的數據格式
     * @param: object
     */
    public static Result success(Object object) {
        Result result = new Result();
        result.setCode(0);
        result.setMsg("success");
        result.setData(object);
        return result;
    }

    /**
     * @return com.study.spring.entity.Result
     * @description 接口調用失敗返回的數據格式
     * @param: code
     * @param: msg
     */
    public static Result error(Integer code, String msg) {
        Result result = new Result();
        result.setCode(code);
        result.setMsg(msg);
        result.setData(null);
        return result;
    }

    /**
     * 返回異常信息,在已知的範圍內
     *
     * @param exceptionEnum
     * @return
     */
    public static Result error(ExceptionEnum exceptionEnum) {
        Result result = new Result();
        result.setCode(exceptionEnum.code);
        result.setMsg(exceptionEnum.msg);
        result.setData(null);
        return result;
    }

}

四、pom 依賴

必需要添加 spring aop 等相關依賴:

<!-- web 依賴-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- aop 依賴 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- 用於日誌切面中,以 json 格式打印出入參 -->
<dependency>
    <groupId>com.google.code.gson</groupId>
    <artifactId>gson</artifactId>
    <version>2.8.5</version>
</dependency>
<!-- lombok 簡化代碼-->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>

五、自定義註解

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

    String desc() default "create17";

}

上述代碼內有些概念須要解釋說明:

  • @Retention:定義註解的保留策略

    • @Retention(RetentionPolicy.SOURCE) :註解保留在源碼中,當 Java 文件編譯成 class 字節碼文件的時候,註解被遺棄。
    • @Retention(RetentionPolicy.CLASS :默認的保留策略,註解會保留在 class 字節碼文件中,但運行( jvm 加載 class 字節碼文件)時會被遺棄。
    • @Retention(RetentionPolicy.RUNTIME) :註解保留在 class 字節碼文件中,在運行時也能夠經過反射獲取到。
  • @Target:定義註解的做用目標,可多個,用逗號分隔。

    • @Target(ElementType.TYPE) :做用於接口、類、枚舉、註解
    • @Target(ElementType.FIELD) :做用於字段、枚舉的常量
    • @Target(ElementType.METHOD) :做用於方法,不包含構造方法
    • @Target(ElementType.PARAMETER) :做用於方法的參數
    • @Target(ElementType.CONSTRUCTOR) :做用於構造方法
    • @Target(ElementType.LOCAL_VARIABLE) :做用於本地變量
    • @Target(ElementType.ANNOTATION_TYPE) :做用於註解
    • @Target(ElementType.PACKAGE) :做用於包
  • @Document:說明該註解將被包含在javadoc中。
  • @Inherited:說明子類能夠繼承父類中的該註解。
  • @interface:聲明自定義註解。
  • desc():定義一個屬性,默認爲 create17。具體使用爲:@HandleResult(desc = "描述內容...")

到這裏,一個完整的自定義註解就定義完成了。

六、切面實現

1)首先咱們定義一個切面類 HandleResultAspect
  • 使用 @Aspect 註解來定義切面,將當前類標識爲一個切面供容器管理,必不可少。
  • 使用 @Component 註解來定義組件,將當前類標識爲一個組件供容器管理,也必不可少。
  • 使用 @Slf4j 註解來打印日誌;
  • 使用 @Order(i) 註解來表示切面的順序,後文會詳細講。
@Aspect
@Component
@Slf4j
@Order(100)
public class HandleResultAspect {
    ...
}
2)接下來,咱們定義一個切點。

使用 @Pointcut 來定義一個切點。

@Pointcut("@annotation(com.study.spring.annotation.HandleResult)")
// @Pointcut("execution(* com.study.spring.controller..*.*(..))")
public void HandleResult() {
}

對於 execution 表達式,官網對 execution 表達式的介紹爲:

execution(<修飾符模式>?<返回類型模式><方法名模式>(<參數模式>)<異常模式>?)

除了返回類型模式、方法名模式和參數模式外,其它項都是可選的。這個解釋可能有點難理解,下面咱們經過一個具體的例子來了解一下。在 HandleResultAspect 中咱們定義了一個切點,其 execution 表達式爲:* com.study.spring.controller..*.*(..)),下表爲該表達式比較通俗的解析:

標識符 含義
execution() 表達式的主體
第一個 * 符號 表示返回值的類型,* 表明全部返回類型
com.study.spring.controller AOP 所切的服務的包名,即須要進行橫切的業務類
包名後面的 .. 表示當前包及子包
第二個 * 表示類名,* 表示全部類
最後的 .*(..) 第一個 . 表示任何方法名,括號內爲參數類型,.. 表明任何類型參數

上述的 execution 表達式是把 com.study.spring.controller 下全部的方法看成一個切點。@Pointcut 除了可使用 execution 表達式以外,還可用 @annotation 來指定註解切入,好比可指定上面建立的自定義註解 @HandleResult ,@HandleResult 在哪裏被使用,哪裏就是一個切點。

3)說一下 Advice(通知)有關的切面註解
  • @Before:修飾的方法會在進入切點以前執行。在這個部分,咱們須要打印一個開始執行的日誌,好比:類型、方法名、參數名等。
@Before(value = "HandleResult() && @annotation(t)", argNames = "joinPoint,t")
public void doBefore(JoinPoint joinPoint, HandleResult t) throws Exception {
    // 類名
    String className = joinPoint.getTarget().getClass().getName();
    // 方法名
    String methodName = joinPoint.getSignature().getName();
    // 參數名
    Object[] args = joinPoint.getArgs();
    StringBuilder sb = new StringBuilder();
    if (args != null && args.length > 0) {
        for (Object arg : args) {
            sb.append(arg).append(", ");
        }
    }
    log.info("接口 {} 開始被調用, 類名: {}, 方法名: {}, 參數名爲: {} .",
             t.desc(), className, methodName, sb.toString());
}
  • @Around:修飾的方法會環繞整個切點,能夠在切入點先後織入代碼,並能夠自由地控制什麼時候執行切點。通俗點講就是:在進入切點前執行一部分邏輯,而後進入切點執行業務邏輯(ProceedingJoinPoint.proceed() 方法可用來接收業務邏輯的返回信息),最後出切點執行另外一部分邏輯。
@Around("HandleResult()")
public Result doAround(ProceedingJoinPoint point) {
    long startTime = System.currentTimeMillis();
    log.info("---HandleResultAspect--Around的前半部分----------------------------");
    Object result;
    try {
        // 執行切點。point.proceed 爲方法返回值
        result = point.proceed();
        // 打印出參
        log.info("接口原輸出內容: {}", new Gson().toJson(result));
        // 執行耗時
        log.info("執行耗時:{} ms", System.currentTimeMillis() - startTime);
        return ResultUtil.success(result);
    } catch (Throwable throwable) {
        return exceptionHandle.exceptionGet(throwable);
    }
}
  • @After:修飾的方法和 @Before 相對應,不管程序執行正常仍是異常,均執行該方法。
@After("HandleResult()")
public void doAfter() {
    log.info("doAfter...");
}
  • @AfterReturning:在切點正常執行後,執行該方法,通常用於對返回值作些加工處理的場景。

returning 可接收接口最終地返回信息。

@AfterReturning(pointcut = "@annotation(t)", returning = "res")
public void afterReturn(HandleResult t, Object res) {
    log.info("接口 {} 被調用已結束, 最終返回結果爲: {} .",
             t.desc(), new Gson().toJson(res));
}
  • @AfterThrowing:在切點拋出異常後,執行該方法。

throwing 可用來獲取異常信息。

@AfterThrowing(throwing = "throwable", pointcut = "HandleResult()")
public void afterThrowing(Throwable throwable) {
   log.info("After throwing...", throwable);
}

關於這些通知的執行順序以下圖所示:

如下爲切面實現的所有代碼:

@Aspect
@Component
@Slf4j
@Order(100)
public class HandleResultAspect {

    @Autowired
    private ExceptionHandle exceptionHandle;

    /**
     * @return void
     * @description 定義切點
     */
    @Pointcut("@annotation(com.study.spring.annotation.HandleResult)")
//    @Pointcut("execution(* com.study.spring.controller..*.*(..))")
    public void HandleResult() {
    }

    /**
     * @return void
     * @description 打印接口名、類名、方法名及參數名
     * @param: joinPoint
     * @param: t
     */
    @Before(value = "@annotation(t)", argNames = "joinPoint,t")
    public void doBefore(JoinPoint joinPoint, HandleResult t) throws Exception {
        // 類名
        String className = joinPoint.getTarget().getClass().getName();
        // 方法名
        String methodName = joinPoint.getSignature().getName();
        // 參數名
        Object[] args = joinPoint.getArgs();
        StringBuilder sb = new StringBuilder();
        if (args != null && args.length > 0) {
            for (Object arg : args) {
                sb.append(arg).append(", ");
            }
        }
        log.info("接口 {} 開始被調用, 類名: {}, 方法名: {}, 參數名爲: {} .",
                t.desc(), className, methodName, sb.toString());
    }

    /**
     * @return java.lang.Object
     * @description 定義@Around環繞,用於什麼時候執行切點
     * @param: proceedingJoinPoint
     */
    @Around("HandleResult()")
    public Result doAround(ProceedingJoinPoint point) {
        long startTime = System.currentTimeMillis();
        log.info("---HandleResultAspect--Around的前半部分----------------------------");
        Object result;
        try {
            // 執行切點。point.proceed 爲方法返回值
            result = point.proceed();
            // 打印出參
            log.info("接口原輸出內容: {}", new Gson().toJson(result));
            // 執行耗時
            log.info("執行耗時:{} ms", System.currentTimeMillis() - startTime);
            return ResultUtil.success(result);
        } catch (Throwable throwable) {
            return exceptionHandle.exceptionGet(throwable);
        }
    }

    /**
     * @return void
     * @description 程序不管正常仍是異常,均執行的方法
     * @param:
     */
    @After("HandleResult()")
    public void doAfter() {
        log.info("doAfter...");
    }

    /**
     * @return void
     * @description 當程序運行正常,所執行的方法
     * 以json格式打印接口執行結果
     * @param: t
     * @param: res
     */
    @AfterReturning(pointcut = "@annotation(t)", returning = "res")
    public void afterReturn(HandleResult t, Object res) {
        log.info("接口 {} 被調用已結束, 接口最終返回結果爲: {} .",
                t.desc(), new Gson().toJson(res));
    }

    /**
     * @return void
     * @description 當程序運行異常,所執行的方法
     * 可用來打印異常
     * @param: throwable
     */
    @AfterThrowing(throwing = "throwable", pointcut = "HandleResult()")
    public void afterThrowing(Throwable throwable) {
        log.info("After throwing...", throwable);
    }

}

6、多切面的執行順序

在生產中,咱們的項目可能不止一個切面,那麼在多切面的狀況下,如何指定切面的優先級呢?

咱們可使用 @Order(i) 註解來定義切面的優先級,i 值越小,優先級越高。

好比咱們再建立一個切面,代碼示例以下:

@Aspect
@Component
@Order(50)
@Slf4j
public class TestAspect2 {

    @Pointcut("@annotation(com.study.spring.annotation.HandleResult)")
    public void aa(){

    }

    @Before("aa()")
    public void bb(JoinPoint joinPoint){
        log.info("我是 TestAspect2 的 Before 方法...");
    }

    @Around("aa()")
    public Object cc(ProceedingJoinPoint point){
        log.info("我是 TestAspect2 的 Around 方法的前半部分...");
        Object result = null;
        try {
            result = point.proceed();
        } catch (Throwable throwable) {
            throwable.printStackTrace();
        }
        log.info("我是 TestAspect2 的 Around 方法的後半部分...");
        return result;
    }

    @After("aa()")
    public void doAfter() {
        log.info("我是 TestAspect2 的 After 方法...");
    }

    @AfterReturning("aa()")
    public void afterReturn() {
        log.info("我是 TestAspect2 的 AfterReturning 方法...");
    }

    @AfterThrowing("aa()")
    public void afterThrowing() {
        log.info("我是 TestAspect2 的 AfterThrowing 方法...");
    }
}

切面 TestAspect2 爲 @Order(50),以前的切面 HandleResultAspect 爲 Order(100)。測試接口返回的日誌以下圖所示:

總結一下規律就是:

  • 在執行切點以前,@Order 從小到大被執行,也就是說 Order 越小的優先級越高;
  • 在執行切點以後,@Order 從大到小被執行,也就是說 Order 越大的優先級越高;

也就是:先進後出的原則。爲了方便咱們理解,我畫了一個圖,以下圖所示:

7、如何設置在特定環境下使用AOP

通常在項目開發中,都會設置三個環境:開發、測試、生產。那麼若是我只想在 開發 和 測試 環境下使用某切面該怎麼辦呢?咱們只須要在指定的切面類上方加上註解 @Profile 就能夠了,以下所示:

這樣就指定了 HandleResultAspect 該切面只能在 dev(開發)環境、test(測試)環境下生效,prod(生產)環境不生效。固然,你須要建立相應的 application-${dev/test/prod}.yml 文件,最後在 application.yml 文件內指定 spring.profiles.active 屬性爲 dev 或 test 才能夠生效。

8、總結

本文篇幅較長,但總算對 Spring AOP 有了一個簡單的瞭解。從 AOP 的起源到概念、使用場景,而後深刻了解其專業術語,利用 AOP 思想實現了示例,方便咱們本身理解。讀完這篇文章,相信你們能夠基本不懼面試官對這個知識點的考覈了!

本文所涉及的代碼已上傳至 github :

https://github.com/841809077/...

本文參考連接:


相關文章
相關標籤/搜索