java 切面


https://developer.ibm.com/zh/technologies/spring/articles/j-spring-boot-aop-web-log-processing-and-distributed-locking/

AOP

AOP 的全稱為 Aspect Oriented Programming,譯為面向切面編程。實際上 AOP 就是通過預編譯和運行期動態代理實現程序功能的統一維護的一種技術。在不同的技術棧中 AOP 有着不同的實現,但是其作用都相差不遠,我們通過 AOP 為既有的程序定義一個切入點,然后在切入點前后插入不同的執行內容,以達到在不修改原有代碼業務邏輯的前提下統一處理一些內容(比如日志處理、分布式鎖)的目的。

為什么要使用 AOP

在實際的開發過程中,我們的應用程序會被分為很多層。通常來講一個 Java 的 Web 程序會擁有以下幾個層次:

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

雖然看起來每一層都做着全然不同的事情,但是實際上總會有一些類似的代碼,比如日志打印和安全驗證等等相關的代碼。如果我們選擇在每一層都獨立編寫這部分代碼,那么久而久之代碼將變的很難維護。所以我們提供了另外的一種解決方案: AOP。這樣可以保證這些通用的代碼被聚合在一起維護,而且我們可以靈活的選擇何處需要使用這些代碼。

AOP 的核心概念

  • 切面(Aspect) :通常是一個類,在里面可以定義切入點和通知。
  • 連接點(Joint Point) :被攔截到的點,因為 Spring 只支持方法類型的連接點,所以在 Spring 中連接點指的就是被攔截的到的方法,實際上連接點還可以是字段或者構造器。
  • 切入點(Pointcut) :對連接點進行攔截的定義。
  • 通知(Advice) :攔截到連接點之后所要執行的代碼,通知分為前置、后置、異常、最終、環繞通知五類。
  • AOP 代理 :AOP 框架創建的對象,代理就是目標對象的加強。Spring 中的 AOP 代理可以使 JDK 動態代理,也可以是 CGLIB 代理,前者基於接口,后者基於子類。

Spring AOP

Spring 中的 AOP 代理還是離不開 Spring 的 IOC 容器,代理的生成,管理及其依賴關系都是由 IOC 容器負責,Spring 默認使用 JDK 動態代理,在需要代理類而不是代理接口的時候,Spring 會自動切換為使用 CGLIB 代理,不過現在的項目都是面向接口編程,所以 JDK 動態代理相對來說用的還是多一些。在本文中,我們將以注解結合 AOP 的方式來分別實現 Web 日志處理和分布式鎖。

Spring AOP 相關注解

  • @Aspect : 將一個 java 類定義為切面類。
  • @Pointcut :定義一個切入點,可以是一個規則表達式,比如下例中某個 package 下的所有函數,也可以是一個注解等。
  • @Before :在切入點開始處切入內容。
  • @After :在切入點結尾處切入內容。
  • @AfterReturning :在切入點 return 內容之后切入內容(可以用來對處理返回值做一些加工處理)。
  • @Around :在切入點前后切入內容,並自己控制何時執行切入點自身的內容。
  • @AfterThrowing :用來處理當切入內容部分拋出異常之后的處理邏輯。

其中 @Before 、 @After 、 @AfterReturning 、 @Around 、 @AfterThrowing 都屬於通知。

AOP 順序問題

在實際情況下,我們對同一個接口做多個切面,比如日志打印、分布式鎖、權限校驗等等。這時候我們就會面臨一個優先級的問題,這么多的切面該如何告知 Spring 執行順序呢?這就需要我們定義每個切面的優先級,我們可以使用 @Order(i) 注解來標識切面的優先級, i 的值越小,優先級越高。假設現在我們一共有兩個切面,一個 WebLogAspect ,我們為其設置 @Order(100) ;而另外一個切面 DistributeLockAspect 設置為 @Order(99) ,所以 DistributeLockAspect 有更高的優先級,這個時候執行順序是這樣的:在 @Before 中優先執行 @Order(99) 的內容,再執行 @Order(100) 的內容。而在 @After 和 @AfterReturning 中則優先執行 @Order(100) 的內容,再執行 @Order(99) 的內容,可以理解為先進后出的原則。

基於注解的 AOP 配置

使用注解一方面可以減少我們的配置,另一方面注解在編譯期間就可以驗證正確性,查錯相對比較容易,而且配置起來也相當方便。相信大家也都有所了解,我們現在的 Spring 項目里面使用了非常多的注解替代了之前的 xml 配置。而將注解與 AOP 配合使用也是我們最常用的方式,在本文中我們將以這種模式實現 Web 日志統一處理和分布式鎖兩個注解。下面就讓我們從准備工作開始吧。

准備工作

准備一個 Spring Boot 的 Web 項目

你可以通過 Spring Initializr 頁面 生成一個空的 Spring Boot 項目,當然也可以下載 springboot-pom.xml 文件 ,然后使用 maven 構建一個 Spring Boot 項目。項目創建完成后,為了方便后面代碼的編寫你可以將其導入到你喜歡的 IDE 中,我這里選擇了 Intelli IDEA 打開。

添加依賴

我們需要添加 Web 依賴和 AOP 相關依賴,只需要在 pom.xml 中添加如下內容即可:

清單 1. 添加 web 依賴
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
清單 2. 添加 AOP 相關依賴
<dependency>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

其他准備工作

為了方便測試我還在項目中集成了 Swagger 文檔,具體的集成方法可以參照 在 Spring Boot 項目中使用 Swagger 文檔 。另外編寫了兩個接口以供測試使用,具體可以參考 本文源碼 。由於本教程所實現的分布式鎖是基於 Redis 緩存的,所以需要安裝 Redis 或者准備一台 Redis 服務器。

利用 AOP 實現 Web 日志處理

為什么要實現 Web 日志統一處理

在實際的開發過程中,我們會需要將接口的出請求參數、返回數據甚至接口的消耗時間都以日志的形式打印出來以便排查問題,有些比較重要的接口甚至還需要將這些信息寫入到數據庫。而這部分代碼相對來講比較相似,為了提高代碼的復用率,我們可以以 AOP 的方式將這種類似的代碼封裝起來。

Web 日志注解

清單 3. Web 日志注解代碼
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ControllerWebLog {
     String name();
     boolean intoDb() default false;

}

其中 name 為所調用接口的名稱, intoDb 則標識該條操作日志是否需要持久化存儲,Spring Boot 連接數據庫的配置,可以參考 SpringBoot 項目配置多數據源 這篇文章,具體的數據庫結構可以 點擊這里獲取 。現在注解有了,我們接下來需要編寫與該注解配套的 AOP 切面。

實現 WebLogAspect 切面

第 1 步,我們定義了一個切面類 WebLogAspect 如清單 4 所示。其中@Aspect 注解是告訴 Spring 將該類作為一個切面管理,@Component 注解是說明該類作為一個 Spring 組件。

清單 4. WebLogAspect
   @Aspect
   @Component
   @Order(100)
   public class WebLogAspect {
   }

第 2 步,接下來我們需要定義一個切點。

清單 5. Web 日志 AOP 切點
   @Pointcut("execution(* cn.itweknow.sbaop.controller..*.*(..))")
   public void webLog() {}

對於 execution 表達式, 官網 的介紹為(翻譯后):

清單 6. 官網對 execution 表達式的介紹
   execution(<修飾符模式>?<返回類型模式><方法名模式>(<參數模式>)<異常模式>?)

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

表 1. execution() 表達式解析
標識符 含義
execution() 表達式的主體
第一個 * 符號 表示返回值的類型, * 代表所有返回類型
cn.itweknow.sbaop.controller AOP 所切的服務的包名,即需要進行橫切的業務類
包名后面的 .. 表示當前包及子包
第二個 * 表示類名, * 表示所有類
最后的 .*(..) 第一個 . 表示任何方法名,括號內為參數類型, .. 代表任何類型參數

第 3 步,@Before 修飾的方法中的內容會在進入切點之前執行,在這個部分我們需要打印一個開始執行的日志,並將請求參數和開始調用的時間存儲在 ThreadLocal 中,方便在后面結束調用時打印參數和計算接口耗時。

清單 7. @Before 代碼
   @Before(value = "webLog()& &  @annotation(controllerWebLog)")
       public void doBefore(JoinPoint joinPoint, ControllerWebLog controllerWebLog) {
           // 開始時間。
           long startTime = System.currentTimeMillis();
           Map<String, Object> threadInfo = new HashMap<>();
           threadInfo.put(START_TIME, startTime);
           // 請求參數。
           StringBuilder requestStr = new StringBuilder();
           Object[] args = joinPoint.getArgs();
           if (args != null && args.length > 0) {
               for (Object arg : args) {
                   requestStr.append(arg.toString());
               }
           }
           threadInfo.put(REQUEST_PARAMS, requestStr.toString());
           threadLocal.set(threadInfo);
           logger.info("{}接口開始調用:requestData={}", controllerWebLog.name(), threadInfo.get(REQUEST_PARAMS));
    }

第 4 步,@AfterReturning ,當程序正常執行有正確的返回時執行,我們在這里打印結束日志,最后不能忘的是清除 ThreadLocal 里的內容。

清單 8. @AfterReturning 代碼
   @AfterReturning(value = "webLog()&& @annotation(controllerWebLog)", returning = "res")
   public void doAfterReturning(ControllerWebLog controllerWebLog, Object res) {
           Map<String, Object> threadInfo = threadLocal.get();
           long takeTime = System.currentTimeMillis() - (long) threadInfo.getOrDefault(START_TIME, System.currentTimeMillis());
           if (controllerWebLog.intoDb()) {
               insertResult(controllerWebLog.name(), (String) threadInfo.getOrDefault(REQUEST_PARAMS, ""),
                           JSON.toJSONString(res), takeTime);
           }
           threadLocal.remove();
           logger.info("{}接口結束調用:耗時={}ms,result={}", controllerWebLog.name(),
                   takeTime, res);
   }

第 5 步,當程序發生異常時,我們也需要將異常日志打印出來:

清單 9. @AfterThrowing 代碼
   @AfterThrowing(value = "webLog()& &  @annotation(controllerWebLog)", throwing = "throwable")
       public void doAfterThrowing(ControllerWebLog controllerWebLog, Throwable throwable) {
           Map< String, Object> threadInfo = threadLocal.get();
           if (controllerWebLog.intoDb()) {
               insertError(controllerWebLog.name(), (String)threadInfo.getOrDefault(REQUEST_PARAMS, ""),
                       throwable);
           }
           threadLocal.remove();
           logger.error("{}接口調用異常,異常信息{}",controllerWebLog.name(), throwable);
   }

第 6 步,至此,我們的切面已經編寫完成了。下面我們需要將 ControllerWebLog 注解使用在我們的測試接口上,接口內部的代碼已省略,如有需要的話,請參照 本文源碼 。

清單 10. 測試接口代碼
   @PostMapping("/post-test")
   @ApiOperation("接口日志 POST 請求測試")
   @ControllerWebLog(name = "接口日志 POST 請求測試", intoDb = true)
   public BaseResponse postTest(@RequestBody BaseRequest baseRequest) {
   }

第 7 步,最后,啟動項目,然后打開 Swagger 文檔進行測試,調用接口后在控制台就會看到類似圖 1 這樣的日志。

圖 1. 基於 Redis 的分布式鎖測試效果

基於 Redis 的分布式鎖測試效果

利用 AOP 實現分布式鎖

為什么要使用分布式鎖

我們程序中多多少少會有一些共享的資源或者數據,在某些時候我們需要保證同一時間只能有一個線程訪問或者操作它們。在傳統的單機部署的情況下,我們簡單的使用 Java 提供的並發相關的 API 處理即可。但是現在大多數服務都采用分布式的部署方式,我們就需要提供一個跨進程的互斥機制來控制共享資源的訪問,這種互斥機制就是我們所說的分布式鎖。

注意

  1. 互斥性。在任時刻,只有一個客戶端能持有鎖。
  2. 不會發生死鎖。即使有一個客戶端在持有鎖的期間崩潰而沒有主動解鎖,也能保證后續其他客戶端能加鎖。這個其實只要我們給鎖加上超時時間即可。
  3. 具有容錯性。只要大部分的 Redis 節點正常運行,客戶端就可以加鎖和解鎖。
  4. 解鈴還須系鈴人。加鎖和解鎖必須是同一個客戶端,客戶端自己不能把別人加的鎖給解了。

分布式鎖注解

清單 11. 分布式鎖注解
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface DistributeLock {
    String key();
    long timeout() default 5;
    TimeUnit timeUnit() default TimeUnit.SECONDS;
}

其中, key 為分布式所的 key 值, timeout 為鎖的超時時間,默認為 5, timeUnit 為超時時間的單位,默認為秒。

注解參數解析器

由於注解屬性在指定的時候只能為常量,我們無法直接使用方法的參數。而在絕大多數的情況下分布式鎖的 key 值是需要包含方法的一個或者多個參數的,這就需要我們將這些參數的位置以某種特殊的字符串表示出來,然后通過參數解析器去動態的解析出來這些參數具體的值,然后拼接到 key 上。在本教程中我也編寫了一個參數解析器 AnnotationResolver 。篇幅原因,其源碼就不直接粘在文中,需要的讀者可以 查看源碼 。

獲取鎖方法

清單 12. 獲取鎖
private String getLock(String key, long timeout, TimeUnit timeUnit) {
        try {
            String value = UUID.randomUUID().toString();
            Boolean lockStat = stringRedisTemplate.execute((RedisCallback< Boolean>)connection ->
                    connection.set(key.getBytes(Charset.forName("UTF-8")), value.getBytes(Charset.forName("UTF-8")),
                            Expiration.from(timeout, timeUnit), RedisStringCommands.SetOption.SET_IF_ABSENT));
            if (!lockStat) {
                // 獲取鎖失敗。
                return null;
            }
            return value;
        } catch (Exception e) {
            logger.error("獲取分布式鎖失敗,key={}", key, e);
            return null;
        }
}

RedisStringCommands.SetOption.SET_IF_ABSENT 實際上是使用了 setNX 命令,如果 key 已經存在的話則不進行任何操作返回失敗,如果 key 不存在的話則保存 key 並返回成功,該命令在成功的時候返回 1,結束的時候返回 0。我們隨機產生了一個 value 並且在獲取鎖成功的時候返回出去,是為了在釋放鎖的時候對該值進行比較,這樣可以做到解鈴還須系鈴人,由誰創建的鎖就由誰釋放。同時還指定了超時時間,這樣可以保證鎖釋放失敗的情況下不會造成接口永遠不能訪問。

釋放鎖方法

清單 13. 釋放鎖
private void unLock(String key, String value) {
        try {
            String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
            boolean unLockStat = stringRedisTemplate.execute((RedisCallback< Boolean>)connection ->
                    connection.eval(script.getBytes(), ReturnType.BOOLEAN, 1,
                            key.getBytes(Charset.forName("UTF-8")), value.getBytes(Charset.forName("UTF-8"))));
            if (!unLockStat) {
                logger.error("釋放分布式鎖失敗,key={},已自動超時,其他線程可能已經重新獲取鎖", key);
            }
        } catch (Exception e) {
            logger.error("釋放分布式鎖失敗,key={}", key, e);
        }
}

切面

切點和 Web 日志處理的切點一樣,這里不再贅述。我們在切面中使用的通知類型為 @Around ,在切點之前我們先嘗試獲取鎖,若獲取鎖失敗則直接返回錯誤信息,若獲取鎖成功則執行方法體,當方法結束后(無論是正常結束還是異常終止)釋放鎖。

清單 14. 環繞通知
@Around(value = "distribute()&& @annotation(distributeLock)")
public Object doAround(ProceedingJoinPoint joinPoint, DistributeLock distributeLock) throws Exception {
        String key = annotationResolver.resolver(joinPoint, distributeLock.key());
        String keyValue = getLock(key, distributeLock.timeout(), distributeLock.timeUnit());
        if (StringUtil.isNullOrEmpty(keyValue)) {
            // 獲取鎖失敗。
            return BaseResponse.addError(ErrorCodeEnum.OPERATE_FAILED, "請勿頻繁操作");
        }
        // 獲取鎖成功
        try {
            return joinPoint.proceed();
        } catch (Throwable throwable) {
            return BaseResponse.addError(ErrorCodeEnum.SYSTEM_ERROR, "系統異常");
        } finally {
            // 釋放鎖。
            unLock(key, keyValue);
        }
}

測試

清單 15. 分布式鎖測試代碼
@PostMapping("/post-test")
@ApiOperation("接口日志 POST 請求測試")
@ControllerWebLog(name = "接口日志 POST 請求測試", intoDb = true)
@DistributeLock(key = "post_test_#{baseRequest.channel}", timeout = 10)
public BaseResponse postTest(@RequestBody BaseRequest baseRequest) {
        try {
            Thread.sleep(10000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return BaseResponse.addResult();
}

在本次測試中我們將鎖的超時時間設置為 10 秒鍾,在接口中讓當前線程睡眠 10 秒,這樣可以保證 10 秒鍾之內鎖不會被釋放掉,測試起來更加容易些。啟動項目后,我們快速訪問兩次該接口,注意兩次請求的 channel 傳值需要一致(因為鎖的 key 中包含該值),會發現第二次訪問時返回如下結果:

圖 2. 基於 Redis 的分布式鎖測試效果

基於 Redis 的分布式鎖測試效果

這就說明我們的分布式鎖已經生效。

結束語

在本教程中,我們主要了解了 AOP 編程以及為什么要使用 AOP。也介紹了如何在 Spring Boot 項目中利用 AOP 實現 Web 日志統一處理和基於 Redis 的分布式鎖。你可以在 Github 上找到本教程的 完整實現 ,如果你想對本教程做補充的話歡迎發郵件(gancy.programmer@gmail.com)給我或者直接在 Github 上提交 Pull Reqeust。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM