AOP概念
AOP 的全稱為 Aspect Oriented Programming,譯為面向切面編程。實(shí)際上 AOP 就是通過(guò)預(yù)編譯和運(yùn)行期動(dòng)態(tài)代理實(shí)現(xiàn)程序功能的統(tǒng)一維護(hù)的一種技術(shù)。在不同的技術(shù)棧中 AOP 有著不同的實(shí)現(xiàn),但是其作用都相差不遠(yuǎn),我們通過(guò) AOP 為既有的程序定義一個(gè)切入點(diǎn),然后在切入點(diǎn)前后插入不同的執(zhí)行內(nèi)容,以達(dá)到在不修改原有代碼業(yè)務(wù)邏輯的前提下統(tǒng)一處理一些內(nèi)容(比如日志處理、分布式鎖)的目的。
為什么要使用 AOP
在實(shí)際的開(kāi)發(fā)過(guò)程中,我們的應(yīng)用程序會(huì)被分為很多層。通常來(lái)講一個(gè) Java 的 Web 程序會(huì)擁有以下幾個(gè)層次:
Web 層:主要是暴露一些 Restful API 供前端調(diào)用。
業(yè)務(wù)層:主要是處理具體的業(yè)務(wù)邏輯。
數(shù)據(jù)持久層:主要負(fù)責(zé)數(shù)據(jù)庫(kù)的相關(guān)操作(增刪改查)。
雖然看起來(lái)每一層都做著全然不同的事情,但是實(shí)際上總會(huì)有一些類似的代碼,比如日志打印和安全驗(yàn)證等等相關(guān)的代碼。如果我們選擇在每一層都獨(dú)立編寫這部分代碼,那么久而久之代碼將變的很難維護(hù)。所以我們提供了另外的一種解決方案: AOP。這樣可以保證這些通用的代碼被聚合在一起維護(hù),而且我們可以靈活的選擇何處需要使用這些代碼。
AOP 的核心概念
切面(Aspect)?:通常是一個(gè)類,在里面可以定義切入點(diǎn)和通知。
連接點(diǎn)(Joint Point)?:被攔截到的點(diǎn),因?yàn)?Spring 只支持方法類型的連接點(diǎn),所以在 Spring 中連接點(diǎn)指的就是被攔截的到的方法,實(shí)際上連接點(diǎn)還可以是字段或者構(gòu)造器。
切入點(diǎn)(Pointcut)?:對(duì)連接點(diǎn)進(jìn)行攔截的定義。
通知(Advice)?:攔截到連接點(diǎn)之后所要執(zhí)行的代碼,通知分為前置、后置、異常、最終、環(huán)繞通知五類。
AOP 代理?:AOP 框架創(chuàng)建的對(duì)象,代理就是目標(biāo)對(duì)象的加強(qiáng)。Spring 中的 AOP 代理可以使 JDK 動(dòng)態(tài)代理,也可以是 CGLIB 代理,前者基于接口,后者基于子類。
Spring AOP
Spring 中的 AOP 代理還是離不開(kāi) Spring 的 IOC 容器,代理的生成,管理及其依賴關(guān)系都是由 IOC 容器負(fù)責(zé),Spring 默認(rèn)使用 JDK 動(dòng)態(tài)代理,在需要代理類而不是代理接口的時(shí)候,Spring 會(huì)自動(dòng)切換為使用 CGLIB 代理,不過(guò)現(xiàn)在的項(xiàng)目都是面向接口編程,所以 JDK 動(dòng)態(tài)代理相對(duì)來(lái)說(shuō)用的還是多一些。在本文中,我們將以注解結(jié)合 AOP 的方式來(lái)分別實(shí)現(xiàn) Web 日志處理和分布式鎖。
Spring AOP 相關(guān)注解
@Aspect : 將一個(gè) java 類定義為切面類。
@Pointcut :定義一個(gè)切入點(diǎn),可以是一個(gè)規(guī)則表達(dá)式,比如下例中某個(gè) package 下的所有函數(shù),也可以是一個(gè)注解等。
@Before :在切入點(diǎn)開(kāi)始處切入內(nèi)容。
@After :在切入點(diǎn)結(jié)尾處切入內(nèi)容。
@AfterReturning :在切入點(diǎn) return 內(nèi)容之后切入內(nèi)容(可以用來(lái)對(duì)處理返回值做一些加工處理)。
@Around :在切入點(diǎn)前后切入內(nèi)容,并自己控制何時(shí)執(zhí)行切入點(diǎn)自身的內(nèi)容。
@AfterThrowing :用來(lái)處理當(dāng)切入內(nèi)容部分拋出異常之后的處理邏輯。
其中 @Before 、 @After 、 @AfterReturning 、 @Around 、 @AfterThrowing 都屬于通知。
AOP 順序問(wèn)題
在實(shí)際情況下,我們對(duì)同一個(gè)接口做多個(gè)切面,比如日志打印、分布式鎖、權(quán)限校驗(yàn)等等。這時(shí)候我們就會(huì)面臨一個(gè)優(yōu)先級(jí)的問(wèn)題,這么多的切面該如何告知 Spring 執(zhí)行順序呢?這就需要我們定義每個(gè)切面的優(yōu)先級(jí),我們可以使用 @Order(i) 注解來(lái)標(biāo)識(shí)切面的優(yōu)先級(jí), i 的值越小,優(yōu)先級(jí)越高。假設(shè)現(xiàn)在我們一共有兩個(gè)切面,一個(gè) WebLogAspect ,我們?yōu)槠湓O(shè)置 @Order(100) ;而另外一個(gè)切面 DistributeLockAspect 設(shè)置為 @Order(99) ,所以 DistributeLockAspect 有更高的優(yōu)先級(jí),這個(gè)時(shí)候執(zhí)行順序是這樣的:在 @Before 中優(yōu)先執(zhí)行 @Order(99) 的內(nèi)容,再執(zhí)行 @Order(100) 的內(nèi)容。而在 @After 和 @AfterReturning 中則優(yōu)先執(zhí)行 @Order(100) 的內(nèi)容,再執(zhí)行 @Order(99) 的內(nèi)容,可以理解為先進(jìn)后出的原則。
基于注解的 AOP 配置
使用注解一方面可以減少我們的配置,另一方面注解在編譯期間就可以驗(yàn)證正確性,查錯(cuò)相對(duì)比較容易,而且配置起來(lái)也相當(dāng)方便。相信大家也都有所了解,我們現(xiàn)在的 Spring 項(xiàng)目里面使用了非常多的注解替代了之前的 xml 配置。而將注解與 AOP 配合使用也是我們最常用的方式,在本文中我們將以這種模式實(shí)現(xiàn) Web 日志統(tǒng)一處理和分布式鎖兩個(gè)注解。下面就讓我們從準(zhǔn)備工作開(kāi)始吧。
準(zhǔn)備工作
準(zhǔn)備一個(gè) Spring Boot 的 Web 項(xiàng)目
你可以通過(guò)?Spring Initializr 頁(yè)面?生成一個(gè)空的 Spring Boot 項(xiàng)目,當(dāng)然也可以下載?springboot-pom.xml 文件?,然后使用 maven 構(gòu)建一個(gè) Spring Boot 項(xiàng)目。項(xiàng)目創(chuàng)建完成后,為了方便后面代碼的編寫你可以將其導(dǎo)入到你喜歡的 IDE 中,我這里選擇了 Intelli IDEA 打開(kāi)。
添加依賴
我們需要添加 Web 依賴和 AOP 相關(guān)依賴,只需要在 pom.xml 中添加如下內(nèi)容即可:
清單 1. 添加 web 依賴
org.springframework.boot
spring-boot-starter-web
清單 2. 添加 AOP 相關(guān)依賴
org.springframework.boot
spring-boot-starter-aop
其他準(zhǔn)備工作
為了方便測(cè)試我還在項(xiàng)目中集成了 Swagger 文檔,具體的集成方法可以參照?在 Spring Boot 項(xiàng)目中使用 Swagger 文檔?。另外編寫了兩個(gè)接口以供測(cè)試使用,具體可以參考?本文源碼?。由于本教程所實(shí)現(xiàn)的分布式鎖是基于 Redis 緩存的,所以需要安裝 Redis 或者準(zhǔn)備一臺(tái) Redis 服務(wù)器。
利用 AOP 實(shí)現(xiàn) Web 日志處理
為什么要實(shí)現(xiàn) Web 日志統(tǒng)一處理
在實(shí)際的開(kāi)發(fā)過(guò)程中,我們會(huì)需要將接口的出請(qǐng)求參數(shù)、返回?cái)?shù)據(jù)甚至接口的消耗時(shí)間都以日志的形式打印出來(lái)以便排查問(wèn)題,有些比較重要的接口甚至還需要將這些信息寫入到數(shù)據(jù)庫(kù)。而這部分代碼相對(duì)來(lái)講比較相似,為了提高代碼的復(fù)用率,我們可以以 AOP 的方式將這種類似的代碼封裝起來(lái)。
Web 日志注解
清單 3. Web 日志注解代碼
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ControllerWebLog {
String name();
boolean intoDb() default false;
}
其中 name 為所調(diào)用接口的名稱, intoDb 則標(biāo)識(shí)該條操作日志是否需要持久化存儲(chǔ),Spring Boot 連接數(shù)據(jù)庫(kù)的配置,可以參考?SpringBoot 項(xiàng)目配置多數(shù)據(jù)源?這篇文章,具體的數(shù)據(jù)庫(kù)結(jié)構(gòu)可以?點(diǎn)擊這里獲取?。現(xiàn)在注解有了,我們接下來(lái)需要編寫與該注解配套的 AOP 切面。
實(shí)現(xiàn) WebLogAspect 切面
首先我們定義了一個(gè)切面類 WebLogAspect 如清單 4 所示。其中@Aspect 注解是告訴 Spring 將該類作為一個(gè)切面管理,@Component 注解是說(shuō)明該類作為一個(gè) Spring 組件。
清單 4. WebLogAspect
@Aspect
@Component
@Order(100)
public class WebLogAspect {
}
接下來(lái)我們需要定義一個(gè)切點(diǎn)。
清單 5. Web 日志 AOP 切點(diǎn)
@Pointcut("execution(* cn.itweknow.sbaop.controller..*.*(..))")
public void webLog() {}
對(duì)于 execution 表達(dá)式,?官網(wǎng)?的介紹為(翻譯后):
清單 6. 官網(wǎng)對(duì) execution 表達(dá)式的介紹
execution(<修飾符模式>?<返回類型模式><方法名模式>(<參數(shù)模式>)<異常模式>?)
其中除了返回類型模式、方法名模式和參數(shù)模式外,其它項(xiàng)都是可選的。這個(gè)解釋可能有點(diǎn)難理解,下面我們通過(guò)一個(gè)具體的例子來(lái)了解一下。在 WebLogAspect 中我們定義了一個(gè)切點(diǎn),其 execution 表達(dá)式為 * cn.itweknow.sbaop.controller..*.*(..) ,下表為該表達(dá)式比較通俗的解析:
表 1. execution() 表達(dá)式解析
標(biāo)識(shí)符含義execution()表達(dá)式的主體第一個(gè) * 符號(hào)表示返回值的類型, * 代表所有返回類型cn.itweknow.sbaop.controllerAOP 所切的服務(wù)的包名,即需要進(jìn)行橫切的業(yè)務(wù)類包名后面的 ..表示當(dāng)前包及子包第二個(gè) *表示類名, * 表示所有類最后的 .*(..)第一個(gè) . 表示任何方法名,括號(hào)內(nèi)為參數(shù)類型, .. 代表任何類型參數(shù)
@Before 修飾的方法中的內(nèi)容會(huì)在進(jìn)入切點(diǎn)之前執(zhí)行,在這個(gè)部分我們需要打印一個(gè)開(kāi)始執(zhí)行的日志,并將請(qǐng)求參數(shù)和開(kāi)始調(diào)用的時(shí)間存儲(chǔ)在 ThreadLocal 中,方便在后面結(jié)束調(diào)用時(shí)打印參數(shù)和計(jì)算接口耗時(shí)。
清單 7. @Before 代碼
@Before(value = "webLog()& & @annotation(controllerWebLog)")
public void doBefore(JoinPoint joinPoint, ControllerWebLog controllerWebLog) {
// 開(kāi)始時(shí)間。
long startTime = System.currentTimeMillis();
Map threadInfo = new HashMap<>();
threadInfo.put(START_TIME, startTime);
// 請(qǐng)求參數(shù)。
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("{}接口開(kāi)始調(diào)用:requestData={}", controllerWebLog.name(), threadInfo.get(REQUEST_PARAMS));
}
@AfterReturning ,當(dāng)程序正常執(zhí)行有正確的返回時(shí)執(zhí)行,我們?cè)谶@里打印結(jié)束日志,最后不能忘的是清除 ThreadLocal 里的內(nèi)容。
清單 8. @AfterReturning 代碼
@AfterReturning(value = "webLog()&& @annotation(controllerWebLog)", returning = "res")
public void doAfterReturning(ControllerWebLog controllerWebLog, Object res) {
Map 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("{}接口結(jié)束調(diào)用:耗時(shí)={}ms,result={}", controllerWebLog.name(),
takeTime, res);
}
當(dāng)程序發(fā)生異常時(shí),我們也需要將異常日志打印出來(lái):
清單 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("{}接口調(diào)用異常,異常信息{}",controllerWebLog.name(), throwable);
}
至此,我們的切面已經(jīng)編寫完成了。下面我們需要將 ControllerWebLog 注解使用在我們的測(cè)試接口上,接口內(nèi)部的代碼已省略,如有需要的話,請(qǐng)參照?本文源碼?。
清單 10. 測(cè)試接口代碼
@PostMapping("/post-test")
@ApiOperation("接口日志 POST 請(qǐng)求測(cè)試")
@ControllerWebLog(name = "接口日志 POST 請(qǐng)求測(cè)試", intoDb = true)
public BaseResponse postTest(@RequestBody BaseRequest baseRequest) {
}
最后,啟動(dòng)項(xiàng)目,然后打開(kāi) Swagger 文檔進(jìn)行測(cè)試,調(diào)用接口后在控制臺(tái)就會(huì)看到類似圖 1 這樣的日志。
圖 1. 基于 Redis 的分布式鎖測(cè)試效果
利用 AOP 實(shí)現(xiàn)分布式鎖
為什么要使用分布式鎖
我們程序中多多少少會(huì)有一些共享的資源或者數(shù)據(jù),在某些時(shí)候我們需要保證同一時(shí)間只能有一個(gè)線程訪問(wèn)或者操作它們。在傳統(tǒng)的單機(jī)部署的情況下,我們簡(jiǎn)單的使用 Java 提供的并發(fā)相關(guān)的 API 處理即可。但是現(xiàn)在大多數(shù)服務(wù)都采用分布式的部署方式,我們就需要提供一個(gè)跨進(jìn)程的互斥機(jī)制來(lái)控制共享資源的訪問(wèn),這種互斥機(jī)制就是我們所說(shuō)的分布式鎖。
注意
互斥性。在任時(shí)刻,只有一個(gè)客戶端能持有鎖。
不會(huì)發(fā)生死鎖。即使有一個(gè)客戶端在持有鎖的期間崩潰而沒(méi)有主動(dòng)解鎖,也能保證后續(xù)其他客戶端能加鎖。這個(gè)其實(shí)只要我們給鎖加上超時(shí)時(shí)間即可。
具有容錯(cuò)性。只要大部分的 Redis 節(jié)點(diǎn)正常運(yùn)行,客戶端就可以加鎖和解鎖。
解鈴還須系鈴人。加鎖和解鎖必須是同一個(gè)客戶端,客戶端自己不能把別人加的鎖給解了。
分布式鎖注解
清單 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 為鎖的超時(shí)時(shí)間,默認(rèn)為 5, timeUnit 為超時(shí)時(shí)間的單位,默認(rèn)為秒。
注解參數(shù)解析器
由于注解屬性在指定的時(shí)候只能為常量,我們無(wú)法直接使用方法的參數(shù)。而在絕大多數(shù)的情況下分布式鎖的 key 值是需要包含方法的一個(gè)或者多個(gè)參數(shù)的,這就需要我們將這些參數(shù)的位置以某種特殊的字符串表示出來(lái),然后通過(guò)參數(shù)解析器去動(dòng)態(tài)的解析出來(lái)這些參數(shù)具體的值,然后拼接到 key 上。在本教程中我也編寫了一個(gè)參數(shù)解析器 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 實(shí)際上是使用了 setNX 命令,如果 key 已經(jīng)存在的話則不進(jìn)行任何操作返回失敗,如果 key 不存在的話則保存 key 并返回成功,該命令在成功的時(shí)候返回 1,結(jié)束的時(shí)候返回 0。我們隨機(jī)產(chǎn)生了一個(gè) value 并且在獲取鎖成功的時(shí)候返回出去,是為了在釋放鎖的時(shí)候?qū)υ撝颠M(jìn)行比較,這樣可以做到解鈴還須系鈴人,由誰(shuí)創(chuàng)建的鎖就由誰(shuí)釋放。同時(shí)還指定了超時(shí)時(shí)間,這樣可以保證鎖釋放失敗的情況下不會(huì)造成接口永遠(yuǎn)不能訪問(wèn)。
釋放鎖方法
清單 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={},已自動(dòng)超時(shí),其他線程可能已經(jīng)重新獲取鎖", key);
}
} catch (Exception e) {
logger.error("釋放分布式鎖失敗,key={}", key, e);
}
}
切面
切點(diǎn)和 Web 日志處理的切點(diǎn)一樣,這里不再贅述。我們?cè)谇忻嬷惺褂玫耐ㄖ愋蜑?@Around,在切點(diǎn)之前我們先嘗試獲取鎖,若獲取鎖失敗則直接返回錯(cuò)誤信息,若獲取鎖成功則執(zhí)行方法體,當(dāng)方法結(jié)束后(無(wú)論是正常結(jié)束還是異常終止)釋放鎖。
清單 14. 環(huán)繞通知
@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, "請(qǐng)勿頻繁操作");
}
// 獲取鎖成功
try {
return joinPoint.proceed();
} catch (Throwable throwable) {
return BaseResponse.addError(ErrorCodeEnum.SYSTEM_ERROR, "系統(tǒng)異常");
} finally {
// 釋放鎖。
unLock(key, keyValue);
}
}
測(cè)試
清單 15. 分布式鎖測(cè)試代碼
@PostMapping("/post-test")
@ApiOperation("接口日志 POST 請(qǐng)求測(cè)試")
@ControllerWebLog(name = "接口日志 POST 請(qǐng)求測(cè)試", 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();
}
在本次測(cè)試中我們將鎖的超時(shí)時(shí)間設(shè)置為 10 秒鐘,在接口中讓當(dāng)前線程睡眠 10 秒,這樣可以保證 10 秒鐘之內(nèi)鎖不會(huì)被釋放掉,測(cè)試起來(lái)更加容易些。啟動(dòng)項(xiàng)目后,我們快速訪問(wèn)兩次該接口,注意兩次請(qǐng)求的 channel 傳值需要一致(因?yàn)殒i的 key 中包含該值),會(huì)發(fā)現(xiàn)第二次訪問(wèn)時(shí)返回如下結(jié)果:
圖 2. 基于 Redis 的分布式鎖測(cè)試效果
這就說(shuō)明我們的分布式鎖已經(jīng)生效。
結(jié)束語(yǔ)
在本教程中,我們主要了解了 AOP 編程以及為什么要使用 AOP。也介紹了如何在 Spring Boot 項(xiàng)目中利用 AOP 實(shí)現(xiàn) Web 日志統(tǒng)一處理和基于 Redis 的分布式鎖。你可以在 Github 上找到本教程的?完整實(shí)現(xiàn)?,如果你想對(duì)本教程做補(bǔ)充的話歡迎私信評(píng)論給我。當(dāng)然如果你覺(jué)得本篇文章還不錯(cuò)的話,順手給個(gè) start,這是對(duì)我最好的鼓勵(lì)。
原文鏈接: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