怎樣用 Spring Boot AOP實現 Web 日誌處理+分佈式鎖

怎樣用 Spring Boot AOP實現 Web 日誌處理+分佈式鎖

AOP概念html

AOP 的全稱爲 Aspect Oriented Programming,譯爲面向切面編程。實際上 AOP 就是經過預編譯和運行期動態代理實現程序功能的統一維護的一種技術。在不一樣的技術棧中 AOP 有着不一樣的實現,可是其做用都相差不遠,咱們經過 AOP 爲既有的程序定義一個切入點,而後在切入點先後插入不一樣的執行內容,以達到在不修改原有代碼業務邏輯的前提下統一處理一些內容(好比日誌處理、分佈式鎖)的目的。前端

爲何要使用 AOPjava

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

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

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

AOP 的核心概念spring

  • 切面(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 組件。
  2. 清單 4. WebLogAspect
@Aspect@Component@Order(100)public class WebLogAspect {}複製代碼
  1. 接下來咱們須要定義一個切點。
  2. 清單 5. Web 日誌 AOP 切點
@Pointcut("execution(* cn.itweknow.sbaop.controller..*.*(..))")public void webLog() {}複製代碼
  1. 對於 execution 表達式,
    官網
    的介紹爲(翻譯後):
  2. 清單 6. 官網對 execution 表達式的介紹
execution(<修飾符模式>?<返回類型模式><方法名模式>(<參數模式>)<異常模式>?)複製代碼
  1. 其中除了返回類型模式、方法名模式和參數模式外,其它項都是可選的。這個解釋可能有點難理解,下面咱們經過一個具體的例子來了解一下。在 WebLogAspect 中咱們定義了一個切點,其 execution 表達式爲 * cn.itweknow.sbaop.controller..*.*(..) ,下表爲該表達式比較通俗的解析:
  2. 表 1. execution() 表達式解析
  3. 標識符含義execution()表達式的主體第一個 * 符號表示返回值的類型, * 表明全部返回類型cn.itweknow.sbaop.controllerAOP 所切的服務的包名,即須要進行橫切的業務類包名後面的 ..表示當前包及子包第二個 *表示類名, * 表示全部類最後的 .*(..)第一個 . 表示任何方法名,括號內爲參數類型, .. 表明任何類型參數
  4. @Before 修飾的方法中的內容會在進入切點以前執行,在這個部分咱們須要打印一個開始執行的日誌,並將請求參數和開始調用的時間存儲在 ThreadLocal 中,方便在後面結束調用時打印參數和計算接口耗時。
  5. 清單 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)); }複製代碼
  1. @AfterReturning ,當程序正常執行有正確的返回時執行,咱們在這裏打印結束日誌,最後不能忘的是清除 ThreadLocal 裏的內容。
  2. 清單 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);}複製代碼
  1. 當程序發生異常時,咱們也須要將異常日誌打印出來:
  2. 清單 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);}複製代碼
  1. 至此,咱們的切面已經編寫完成了。下面咱們須要將 ControllerWebLog 註解使用在咱們的測試接口上,接口內部的代碼已省略,若有須要的話,請參照
    本文源碼
  2. 清單 10. 測試接口代碼
@PostMapping("/post-test")@ApiOperation("接口日誌 POST 請求測試")@ControllerWebLog(name = "接口日誌 POST 請求測試", intoDb = true)public BaseResponse postTest(@RequestBody BaseRequest baseRequest) {}複製代碼
  1. 最後,啓動項目,而後打開 Swagger 文檔進行測試,調用接口後在控制檯就會看到相似圖 1 這樣的日誌。
  2. 圖 1. 基於 Redis 的分佈式鎖測試效果
怎樣用 Spring Boot AOP實現 Web 日誌處理+分佈式鎖


怎樣用 Spring Boot AOP實現 Web 日誌處理+分佈式鎖


利用 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 的分佈式鎖測試效果

怎樣用 Spring Boot AOP實現 Web 日誌處理+分佈式鎖

怎樣用 Spring Boot AOP實現 Web 日誌處理+分佈式鎖

這就說明咱們的分佈式鎖已經生效。

結束語

在本教程中,咱們主要了解了 AOP 編程以及爲何要使用 AOP。也介紹瞭如何在 Spring Boot 項目中利用 AOP 實現 Web 日誌統一處理和基於 Redis 的分佈式鎖。你能夠在 Github 上找到本教程的

完整實現
,若是你想對本教程作補充的話歡迎私信評論給我。固然若是你以爲本篇文章還不錯的話,順手給個 start,這是對我最好的鼓勵。

感謝您耐心看完的文章

順便給你們推薦一個Java技術交流羣:710373545裏面會分享一些資深架構師錄製的視頻資料:有Spring,MyBatis,Netty源碼分析,高併發、高性能、分佈式、微服務架構的原理,JVM性能優化、分佈式架構等這些成爲架構師必備的知識體系。還能領取免費的學習資源,目前受益良多!

原文連接:https://www.ibm.com/developerworks/cn/java/j-spring-boot-aop-web-log-processing-and-distributed-locking/index.html?ca=drs-&utm_source=tuicool&utm_medium=referral
相關文章
相關標籤/搜索