目錄
前言
一、AOP基礎
1.入門程序
2. AOP核心概念
3. 底層原理
二、AOP進階
1.通知類型
抽取切入點
2. 切入點表達式
2.1 execution
2.2 @annoation
2.3 連接點詳解
三、ThreadLocal
前言
AOP(面向切面編程),面向切面編程實際就是面向特定方法編程。
假如有一個項目,現在想知道這個項目中每個業務功能執行的市場,以便對執行時長比較長的功能進行優化。
首先想到的應該就是在每個方法執行前加一個System.currentTimeMillis()方法來記錄時間,執行完后,再次記錄時間。
但是如果業務功能很多,這樣操作未免太過于冗余。利用AOP,只需要單獨定義一段代碼,就可以計算出所有業務功能所執行的時間。
所以,AOP的優勢主要體現在以下四個方面:
-
減少重復代碼:不需要在業務方法中定義大量的重復性的代碼,只需要將重復性的代碼抽取到AOP程序中即可。
-
代碼無侵入:在基于AOP實現這些業務功能時,對原有的業務代碼是沒有任何侵入的,不需要修改任何的業務代碼。
-
提高開發效率
-
維護方便
一、AOP基礎
1.入門程序
@Component
@Aspect //當前類為切面類
@Slf4j
public class RecordTimeAspect {// Around注解中的屬性表示會攔截com.itheima.service.impl這個包下DeptServiceImpl類中的所有方法@Around("execution(* com.itheima.service.impl.DeptServiceImpl.*(..))")public Object recordTime(ProceedingJoinPoint pjp) throws Throwable {// 記錄方法執行開始時間long begin = System.currentTimeMillis();// 執行原始方法// 由于Around表示方法執行前后都會執行,所以被攔截的方法執行結束后,就會執行下面的代碼。Object result = pjp.proceed();//記錄方法執行結束時間long end = System.currentTimeMillis();//計算方法執行耗時log .info("方法執行耗時: {}毫秒",end-begin);return result;}
}
通過AOP入門程序完成了業務方法執行耗時的統計,那其實AOP的功能遠不止于此,常見的應用場景如下:
-
記錄系統的操作日志
-
權限控制
-
事務管理:我們前面所講解的Spring事務管理,底層其實也是通過AOP來實現的,只要添加@Transactional注解之后,AOP程序自動會在原始方法運行前先來開啟事務,在原始方法運行完畢之后提交或回滾事務。
2. AOP核心概念
連接點:JoinPoint,可以被AOP控制的方法(不僅僅指當前被AOP控制的方法,也包含當前沒有被AOP控制,但是可以被控制的方法)。
通知:Advice,指那些重復邏輯,也就是共性功能(最終體現為一個方法),例如計算每個業務執行的時間。
切入點:PointCut,匹配連接點的條件,通知僅會在切入點方法執行時被應用,可以簡單理解為被AOP控制的方法。
切面:Aspect,描述通知與切入點的對應關系(通知+切入點)。被@Aspect直接所修飾的類被稱為切面類。
目標對象:Target,通知所應用的對象,目標對象指的就是通知所應用的對象,稱之為目標對象。
3. 底層原理
Spring的AOP底層是基于動態代理技術來實現的,也就是說在程序運行的時候,會自動的基于動態代理技術為目標對象生成一個對應的代理對象。在代理對象當中就會對目標對象當中的原始方法進行功能的增強。
如下圖所示,切面類在執行原方法的時候,并不會直接去調用目標對象中的方法。而是創建一個代理對象DeptServiceProxy繼承DeptService,在代理對象中執行目標對象的方法。在前端發起請求的時候,Spring通過IOC容器注入的也是這個代理對象。
就像把目標方法看作“核心業務”(制作手機),動態代理就是自動套在業務外的“流水線外殼”(代理商),在制作手機前自動貼標簽(前置日志),制作后自動打包(后置日志),而手機工廠無需修改任何代碼。如下圖,在執行方法之前,計算時間;執行結束之后,計算時間,而Controller層執行代理商的代碼即可。
二、AOP進階
1.通知類型
AOP的通知類型有以下幾種:
現有如下代碼,通過代碼來直觀地感受不同通知類型的執行順序:
@Slf4j
@Component
@Aspect
public class MyAspect1 {//前置通知@Before("execution(* com.itheima.service.*.*(..))")public void before(JoinPoint joinPoint){log.info("before ...");}//環繞通知@Around("execution(* com.itheima.service.*.*(..))")public Object around(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {log.info("around before ...");//調用目標對象的原始方法執行Object result = proceedingJoinPoint.proceed();//原始方法如果執行時有異常,環繞通知中的后置代碼不會在執行了log.info("around after ...");return result;}//后置通知@After("execution(* com.itheima.service.*.*(..))")public void after(JoinPoint joinPoint){log.info("after ...");}//返回后通知(程序在正常執行的情況下,會執行的后置通知)@AfterReturning("execution(* com.itheima.service.*.*(..))")public void afterReturning(JoinPoint joinPoint){log.info("afterReturning ...");}//異常通知(程序在出現異常的情況下,執行的后置通知)@AfterThrowing("execution(* com.itheima.service.*.*(..))")public void afterThrowing(JoinPoint joinPoint){log.info("afterThrowing ...");}
}
隨便執行一個業務程序,在程序沒有發生異常的情況下,@AfterThrowing標識的通知方法不會執行。
可以發現Around先執行,然后Before執行,因為這兩個都是在原始方法執行之前執行的。原始方法執行完畢之后,AfterReturning執行,接著是After,最后是Around。因為@Around是環繞通知,在原始方法執行前后都會執行。
如果程序出現異常,結果如下,@Around環繞通知中原始方法調用時有異常,通知中的環繞后的代碼邏輯也不會在執行了 (因為原始方法調用已經出異常了):
抽取切入點
在上面的代碼中,可以發現每一個通知都有相同的切入點表達式。假如此時切入點表達式需要變動,就需要將所有的切入點表達式一個一個的來改動,就變得非常繁瑣了。因此我們需要將相同的切入點表達式抽取出來。
Spring提供了@PointCut
注解,該注解的作用是將公共的切入點表達式抽取出來,需要用到時引用該切入點表達式即可。
將切入點表達式抽取出來,在通知中就直接引入切入點函數名即可。
@Slf4j
@Component
@Aspect
public class MyAspect1 {//切入點方法(公共的切入點表達式)@Pointcut("execution(* com.itheima.service.*.*(..))")private void pt(){}//前置通知(引用切入點)@Before("pt()")public void before(JoinPoint joinPoint){log.info("before ...");}//環繞通知@Around("pt()")public Object around(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {log.info("around before ...");//調用目標對象的原始方法執行Object result = proceedingJoinPoint.proceed();//原始方法在執行時:發生異常//后續代碼不在執行log.info("around after ...");return result;}//后置通知@After("pt()")public void after(JoinPoint joinPoint){log.info("after ...");}//返回后通知(程序在正常執行的情況下,會執行的后置通知)@AfterReturning("pt()")public void afterReturning(JoinPoint joinPoint){log.info("afterReturning ...");}//異常通知(程序在出現異常的情況下,執行的后置通知)@AfterThrowing("pt()")public void afterThrowing(JoinPoint joinPoint){log.info("afterThrowing ...");}
}
如果外部其他切面類也想使用這個切入點,就需要把private
改為public
,而在引用的時候,具體的語法為:
@Slf4j
@Component
@Aspect
public class MyAspect2 {//引用MyAspect1切面類中的切入點表達式//必須是切入點的全類名@Before("com.itheima.aspect.MyAspect1.pt()")public void before(){log.info("MyAspect2 -> before ...");}
}
2. 切入點表達式
切入點表達式分為兩種:execution(……):根據方法的簽名來匹配;@annotation(……) :根據注解匹配。
2.1 execution
execution主要根據方法的返回值、包名、類名、方法名、方法參數等信息來匹配,語法為:
execution(訪問修飾符? 返回值 包名.類名.?方法名(方法參數) throws 異常?)
其中帶?號的部分是可以省略的。
主要理解下面這個表達式即可:
第一個*號指的是任意的訪問修飾符,com.itheima.service.impl指的是包名,DeptServiceImpl指的是這個包中的一個類,類名是DeptServiceImpl,delete是這個類中的一個方法,后面的*號指的是delete方法中的所有參數。
execution(* com.itheima.service.impl.DeptServiceImpl.delete(*))
如果想要com.itheima.service.impl包下的所有類,可以改為如下代碼:
execution(* com.itheima.service.impl.DeptServiceImpl.*(..))
注意事項:
-
根據業務需要,可以使用 且(&&)、或(||)、非(!) 來組合比較復雜的切入點表達式。
execution(* com.itheima.service.DeptService.list(..)) || execution(* com.itheima.service.DeptService.delete(..))
2.2 @annoation
使用execution,當方法過多的時候,代碼就會很繁雜。此時就可以借助另一種切入點表達式 @annotation
來描述這一類的切入點,從而來簡化切入點表達式的書寫。
實現步驟:
-
編寫自定義注解
-
在業務類要做為連接點的方法上添加自定義注解
自定義注解:LogOperation
// Target注解指定注解的作用目標
// Retention指定注解的生命周期(在運行時有效)
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LogOperation{
}
業務類:DeptServiceImpl
@Slf4j
@Service
public class DeptServiceImpl implements DeptService {@Autowiredprivate DeptMapper deptMapper;@Override@LogOperation //自定義注解(表示:當前方法屬于目標方法)public List<Dept> list() {List<Dept> deptList = deptMapper.list();//模擬異常//int num = 10/0;return deptList;}@Override@LogOperation //自定義注解(表示:當前方法屬于目標方法)public void delete(Integer id) {//1. 刪除部門deptMapper.delete(id);}@Override // 沒有自定義注解,表示當前方法不屬于目標方法public void save(Dept dept) {dept.setCreateTime(LocalDateTime.now());dept.setUpdateTime(LocalDateTime.now());deptMapper.save(dept);}}
切面類
@Slf4j
@Component
@Aspect
public class MyAspect6 {//針對list方法、delete方法進行前置通知和后置通知//前置通知//括號里是自定義注解的全類名@Before("@annotation(com.itheima.anno.LogOperation)")public void before(){log.info("MyAspect6 -> before ...");}//后置通知@After("@annotation(com.itheima.anno.LogOperation)")public void after(){log.info("MyAspect6 -> after ...");}
}
上述就是@annoation的基本用法。
總結:
-
execution切入點表達式
-
根據我們所指定的方法的描述信息來匹配切入點方法,這種方式也是最為常用的一種方式
-
如果我們要匹配的切入點方法的方法名不規則,或者有一些比較特殊的需求,通過execution切入點表達式描述比較繁瑣
-
-
annotation 切入點表達式
-
基于注解的方式來匹配切入點方法。這種方式雖然多一步操作,我們需要自定義一個注解,但是相對來比較靈活。我們需要匹配哪個方法,就在方法上加上對應的注解就可以了
-
2.3 連接點詳解
在Spring中用JoinPoint抽象了連接點,用它可以獲得方法執行時的相關信息,如目標類名、方法名、方法參數等。
-
對于
@Around
通知,獲取連接點信息只能使用ProceedingJoinPoint
類型
-
對于其他四種通知,獲取連接點信息只能使用
JoinPoint
,它是ProceedingJoinPoint
的父類型
三、ThreadLocal
當在做業務代碼的時候,存在以下問題:
-
員工登錄成功后,哪里存儲的有當前登錄員工的信息? 給客戶端瀏覽器下發的jwt令牌中
-
如何從JWT令牌中獲取當前登錄用戶的信息呢? 獲取請求頭中傳遞的jwt令牌,并解析
-
TokenFilter 中已經解析了令牌的信息,如何傳遞給AOP程序、Controller、Service呢?ThreadLocal
ThreadLocal 是 Java 中用于實現線程局部變量的核心類,它通過為每個線程創建獨立的變量副本,解決多線程環境下共享變量的并發問題。ThreadLocal 并不是一個Thread,而是Thread的局部變量,為每個線程提供一份單獨的存儲空間,具有線程隔離的效果,不同的線程之間不會相互干擾。
那么如何操作ThreadLocal呢?
首先定義一個ThreadLocal操作的工具類,用于操作當前登錄員工ID。
package com.itheima.utils;public class CurrentHolder {private static final ThreadLocal<Integer> CURRENT_LOCAL = new ThreadLocal<>();public static void setCurrentId(Integer employeeId) {CURRENT_LOCAL.set(employeeId);}public static Integer getCurrentId() {return CURRENT_LOCAL.get();}public static void remove() {CURRENT_LOCAL.remove();}
}
然后我們在解析JWT令牌的時候,將登入員工的id存入ThreadLocal中,
//5. 如果token不為空, 調用JWtUtils工具類的方法解析token, 如果解析失敗, 響應401狀態碼try {Claims claims = JwtUtils.parseJWT(token);Integer empId = Integer.valueOf(claims.get("id").toString());// 在這里,將用戶ID存入線程空間中CurrentHolder.setCurrentId(empId);log.info("token解析成功, 放行");} catch (Exception e) {log.info("token解析失敗, 響應401狀態碼");response.setStatus(401);return;}
然后我們就可以在任何地方取出這次線程執行的員工ID
// 示例方法,獲取當前用戶IDprivate int getCurrentUserId() {return CurrentHolder.getCurrentId();}
在同一個線程/同一個請求中,進行數據共享就可以使用 ThreadLocal。