1. 程式人生 > 其它 >使用 Spring Boot AOP 實現 Web 日誌處理和分散式鎖

使用 Spring Boot AOP 實現 Web 日誌處理和分散式鎖

技術標籤:springboot

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.controllerAOP 所切的服務的包名,即需要進行橫切的業務類
包名後面的..表示當前包及子包
第二個*表示類名,*表示所有類
最後的.*(..)第一個.表示任何方法名,括號內為引數型別,..代表任何型別引數

第 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 的分散式鎖測試效果

利用 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 上找到本教程的完整實現,如果你想對本教程做補充的話歡迎發郵件([email protected])給我或者直接在 Github 上提交 Pull Reqeust。