SpringBoot核心框架之AOP詳解
一、AOP基礎
1.1 AOP概述
- AOP:Aspect Oriented Programming(面向切面編程,面向方面編程),其實就是面向特定方法編程。
- 場景:項目部分功能運行較慢,定位執行耗時較長的業務方法,此時就需要統計每一個業務的執行耗時。
- 思路:給每個方法在開始前寫一個開始計時的邏輯,在方法結束后寫一個計時結束的邏輯,然后相減得到運行時間。
思路是沒問題的,但是有個問題,一個項目是有很多方法的,如果挨個增加邏輯代碼,會相當繁瑣,造成代碼的臃腫,所以可以使用AOP編程,將計時提出成一個這樣的模板:
- 獲取方法運行開始時間
- 運行原始方法
- 獲取方法運行結束時間,計算執行耗時
原始方法就是我們需要計算時間的方法,并且可以對原始方法進行增強,其實這個技術就是用到了我們在Java基礎部分學習的動態代理技術。
實現:動態代理是面向切面編程最主流的實現。而SpringAOP是Spring框架的高級技術,旨在管理bean對象的過程中,主要是通過底層的動態代理機制,對特點的方法進行編程。
1.2 AOP快速入門
統計各個業務層方法執行耗時
-
導入依賴:在pom.xml中導入AOP的依賴。
org.springframework.boot spring-boot-starter-aop -
編寫AOP程序:針對于特定方法根據業務需要進行編程。
@Slf4j // 日志
@Component // 將當前類交給spring管理
@Aspect // 聲明這是一個AOP類
public class TimeAspect {@Around(“execution(* com.example.service..(…))”)
// @Around:表示這是一個環繞通知。
// “execution(* com.example.service..(…))”:切入點表達式,它定義了哪些方法會被這個環繞通知所攔截。這個后面會詳細講解。
// execution(* …):表示攔截執行的方法。
// * com.example.service..(…):表示攔截 com.example.service 包下所有類的所有方法(* 表示任意字符的通配符)。
// …:表示方法可以有任意數量和類型的參數。
public Object recordTime(ProceedingJoinPoint joinPoint) throws Throwable {
// ProceedingJoinPoint是 Spring AOP 中的一個接口,在使用環繞通知時需要
// 它繼承自 JoinPoint 接口,并添加了 proceed() 方法。
// 這個方法是 AOP 代理鏈執行的關鍵部分,它允許你在切面中執行自定義邏輯后繼續執行原始方法。// 1. 記錄開始時間long start = System.currentTimeMillis();// 2. 調用原始方法Object result = joinPoint.proceed(); // 執行被通知的方法。如果不調用 proceed(),被通知的方法將不會執行。// 3. 記錄結束時間,計算耗時long end = System.currentTimeMillis();// getSignature():返回當前連接點的簽名。log.info(joinPoint.getSignature()+"方法執行耗時:{}ms",end - start);return result;
}
} -
查看結果
這樣我們就完成了,一個AOP的小例子,但是AOP的功能遠不能這些,他還有更多的實用的功能。比如:記錄操作日志:可以記錄誰什么時間操作了什么方法,傳了什么參數,返回值是什么都可以很方便的實現。還有比如權限控制,事務管理等等。
我們來總結一下AOP的優勢
- 代碼無侵入
- 減少重復代碼
- 提高開發效率
- 維護方便
1.3. AOP核心概念
連接點:JoinPoint,可以被連接點控制的方法(暗含方法執行時的信息)。 在此例中就是需要被計算耗時的業務方法。
通知:Advice,指那些重復的邏輯,也就是共性功能(最終體現為一個方法)。在此例中就是計算耗時的邏輯代碼。
切入點:PointCut,匹配連接點的條件,通知僅會在切入點方法執行時被應用。在此例中就是com.example.service 包下所有類的所有方法。
切面:Aspect,描述通知與切入點的對應關系(通知+切入點)。在此例中就是TimeAspect方法。
目標對象:Target,通知所應用的對象。在此例中就是通知com.example.service 包下所有類的所有方法。
1.4. AOP的執行流程
因為SpringAOP
是基于動態代理實現的,所有在方法運行時就會先為目標對象基于動態代理生成一個代理對象,為什么說AOP可以增強方法,就是因為有一個代理方法,然后在AOP執行時,Spring就會將通知添加到代理對象的方法前面,也就是記錄開始時間的那個邏輯代碼,然后調用原始方法,也就是需要計時的那個方法,此時代理對象已經把原始方法添加到代理對象里面了,然后執行調用原始方法下面的代碼,在此例中就是計算耗時的那部分,AOP會把這部分代碼添加到代理對象的執行方法的下面,這樣代理對象就完成了對目標方法的增強,也就是添加了計時功能,最后在程序運行時自動注入的也就不是原來的對象,而是代理對象了,不過這些都是AOP自動完成,我們只需要編寫AOP代碼即可。
二、AOP進階
2.1. AOP支持的通知類型
通知類型:
- 環繞通知(Around Advice)
重點
!!!:
- 使用
@Around
注解來定義。 - 包圍目標方法的執行,可以在方法執行前后執行自定義邏輯,并且可以控制目標方法的執行。
- 通過
ProceedingJoinPoint
參數的proceed()
方法來決定是否執行目標方法。
- 前置通知(Before Advice):
- 使用
@Before
注解來定義。 - 在目標方法執行之前執行,無論方法是否拋出異常,都會執行。
- 不能阻止目標方法的執行。
- 后置通知(After Advice) 也叫最終通知:
- 使用
@After
注解來定義。 - 在目標方法執行之后執行,無論方法是否拋出異常,都會執行。
- 通常用于資源清理工作
- 返回通知(After Returning Advice)
了解
: - 使用
@AfterReturning
注解來定義。 - 在目標方法成功執行之后執行,即沒有拋出異常時執行。
- 可以獲取方法的返回值。
- 異常通知(After Advice)
了解
:
- 使用
@AfterThrowing
注解來定義。 - 在目標方法拋出異常后執行。
- 可以獲取拋出的異常對象。
注意事項:
-
環繞通知需要自己調用
joinPoint.proceed()
來讓原始方法執行,其他通知則不需要。 -
環繞通知的返回值必須是
Object
,來接受原始方法的返回值。@Slf4j
@Component
@Aspect
public class MyAspect {// 因為示例中的切入點都是一樣的,所以不用寫多次切入表達式,創建一個方法即可。 // 此方法也可在其他AOP需要切入點的地方使用。 @Pointcut("execution(* com.example.service.impl.DeptServiceImpl.*(..))") public void pt(){}// 前置通知 @Before("pt()") public void Before(){log.info("before ..."); }// 環繞通知 @Around("pt()") public Object Around(ProceedingJoinPoint joinPoint) throws Throwable {log.info("around after ...");// 調用原始方法Object proceed = joinPoint.proceed();log.info("around after ...");return proceed; }// 后置通知 @After("pt()") public void After(){log.info("after ..."); }// 返回通知 @AfterReturning("pt()") public void Returning(){log.info("returning ..."); }// 異常通知 @AfterThrowing("pt()") public void Throwing(){log.info("throwing ..."); }
}
2.2. 多個通知之間的執行順序
當有多個切面的切入點都匹配到了目標方法,目標方法運行時,多個通知方法都會執行。那么順序是怎么的呢?
我們先創建三個AOP程序,分別給他們創建一個前置通知和后置通知,然后啟動程序觀察他們的輸出情況。
// MyAspect2
@Slf4j
@Component
@Aspect
public class MyAspect2 {@Before("execution(* com.example.service.impl.DeptServiceImpl.*(..))")public void befor(){log.info("befor2 ...");}@After("execution(* com.example.service.impl.DeptServiceImpl.*(..))")public void after(){log.info("after2 ...");}
}
// MyAspect3
@Slf4j
@Component
@Aspect
public class MyAspect3 {@Before("execution(* com.example.service.impl.DeptServiceImpl.*(..))")public void befor(){log.info("befor3 ...");}@After("execution(* com.example.service.impl.DeptServiceImpl.*(..))")public void after(){log.info("after3 ...");}
}
// MyAspect4
@Slf4j
@Component
@Aspect
public class MyAspect4 {@Before("execution(* com.example.service.impl.DeptServiceImpl.*(..))")public void befor(){log.info("befor4 ...");}@After("execution(* com.example.service.impl.DeptServiceImpl.*(..))")public void after(){log.info("after4 ...");}
}// 輸出結果com.example.aop.MyAspect2 : befor2 ...com.example.aop.MyAspect3 : befor3 ...com.example.aop.MyAspect4 : befor4 ...com.example.aop.MyAspect4 : after4 ...com.example.aop.MyAspect3 : after3 ...com.example.aop.MyAspect2 : after2 ...// 然后我們把MyAspect2改成MyAspect5,但輸出內容不變,我們來看一下輸出結果com.example.aop.MyAspect3 : befor3 ...com.example.aop.MyAspect4 : befor4 ...com.example.aop.MyAspect5 : befor2 ...com.example.aop.MyAspect5 : after2 ...com.example.aop.MyAspect4 : after4 ...com.example.aop.MyAspect3 : after3 ...
2.2.1 默認情況:
執行順序是和類名有關系的,對于目標方法前的通知字母越靠前的越先執行,目標方法后的通知則相反,字母越靠前的越晚執行,這和Filter攔截器的規則是一樣的。
2.2.2 也可以使用注解的方式指定順序。使用@Order(數字)
加在切面類上來控制順序。
目標方法前的通知:數字小的先執行。
目標方法后的通知:數字小的后執行。
@Slf4j
@Component
@Aspect@Order(10)public class MyAspect3 {...
}
2.3. 切入點表達式
切入點表達式:描述切入點方法的一種表達式。
作用:主要決定項目中哪些方法需要加入通知。
常見形式:
- execution(…):根據方法的簽名來匹配。
- @annotation:根據注解匹配。
2.3.1 execution(…)
execution主要是通過方法的返回值,類名,包名,方法名,方法參數等信息來匹配,語法為:
execution(訪問修飾符? 返回值 包名.類名.方法名(方法參數) throws 異常)
其中帶 ?
的表示可以省略的部分
-
訪問修飾符:可省略(比如:public private …)
-
包名.類名:可省略 但不推薦
-
throws 異常:可省略 (注意是方法上聲明可拋出的異常,不是實際拋出的異常)
// 完整的寫法:
@Before(“execution(public void com.example.service.impl.DeptServiceImpl.add(java.lang.Integer))”)
public void befor(){
…
}
可以使用通配符描述切入點
-
單個獨立的任意符號,可以通配任意返回值,包括包名,類名,方法名,任意一個參數,也可以通配包,類,方法名的一部分。
@After(“execution(* com..service..add*(*))”)
-
多個連續的任意符號,可以通配任意層級的包,或任意類型,任意個數的參數。
@After(“execution(* com.example…DeptService.*(…))”)
-
根據業務的需要,也可以使用 且(&&),或(||),非(!)來組合切入點表達式。
@After(“execution(* com.example…DeptService.(…)) || execution( com.example.service.DeptService.*(…))”)
2.3.2 @annotation:用于匹配標識有特定注解的方法
語法:@annotation(注解的全類名)
先新建一個注解:
@Retention(RetentionPolicy.RUNTIME) // 用來描述有效時間,RUNTIMW:在運行時有效
@Target(ElementType.METHOD) // 用來說明這個注解可以運行在哪里, METHOD:方法上
public @interface MyLog {
}
在目標方法上添加注解
@MyLog
@Override
public void delete(Integer id) {deptMapper.delect(id); // 根據id刪除部門
}
@MyLog
@Override
public void add(Dept dept) {dept.setCreateTime(LocalDateTime.now());dept.setUpdateTime(LocalDateTime.now());deptMapper.add(dept);
}
在切入點表達式以注解的方式進行
@After("@annotation(com.example.aop.MyLog)")
public void after(){...
}
3.3. 連接點
在Spring中使用JoinPoint抽象了連接點,用它可以獲取方法執行時的相關信息,如目標類目,方法名,方法參數等。
-
對于環繞通知(@around),獲取連接點信息只能使用
ProceedingJoinPoint
-
對于其他四種通知,獲取連接點信息只能使用
JoinPoint
,他是ProceedingJoinPoint的父類型。// 我們只在環繞通知中演示,因為API都是相同的
@Component
@Aspect
@Slf4j
public class MyAspect5 {@Pointcut("@annotation(com.example.aop.MyLog)") public void pt(){}@Before("pt()") public void before(JoinPoint joinPoint){log.info("before ..."); } @Around("pt()") public Object around(ProceedingJoinPoint joinPoint) throws Throwable {log.info("around ... before");// 1. 獲取目標對象的類名log.info("目標對象的類名:"+joinPoint.getTarget().getClass().getName());// 2. 獲取目標方法的方法名log.info("目標方法的方法名"+joinPoint.getSignature().getName());// 3. 目標方法運行時傳入的參數log.info("目標方法運行時傳入的參數"+ Arrays.toString(joinPoint.getArgs())); // 數組不能直接輸出// 4. 放行,目標方法執行Object object = joinPoint.proceed();// 5. 獲取目標方法的返回值log.info("目標方法的返回值"+ object);log.info("around ... after");return object; }
}
// 查看結果
com.example.aop.MyAspect5 : around … before
com.example.aop.MyAspect5 : 目標對象的類名:com.example.service.impl.DeptServiceImpl
com.example.aop.MyAspect5 : 目標方法的方法名select
com.example.aop.MyAspect5 : 目標方法運行時傳入的參數[1]
com.example.aop.MyAspect5 : before …
com.example.aop.MyAspect5 : 目標方法的返回值[Dept(id=1, name=學工部, createTime=2023-11-30T13:55:55, updateTime=2023-11-30T13:55:55)]
com.example.aop.MyAspect5 : around … after
三、AOP案例
3.1. 分析
需求:將項目中的增、刪、改、相關接口的操作日志記錄到數據庫表中
- 操作日志包含:操作人,操作時間,執行方法的全類名,執行方法名,方法運行時的參數,返回值,方法運行時長。
思路分析: - 需要對方法添加統一的功能,使用AOP最方便,并且需要計算運行時長,所以使用 環繞通知
- 因為增刪改的方法名沒有規則,所以使用注解的方式寫切入表達式
步驟:- 準備:
- 案例中引入AOP的起步依賴
- 設計數據表結構,并且引入對應的實體類
- 編碼:
- 自定義注解:@Log
- 定義切面類,完成記錄操作日志的邏輯代碼
- 準備:
3.2. 開始干活
3.2.1. 創建數據庫:
create table operate_log
(id int unsigned primary key auto_increment comment 'ID',operate_user int unsigned comment '操作人ID',operate_time datetime comment '操作時間',class_name varchar(100) comment '操作的類名',method_name varchar(100) comment '操作的方法名',method_params varchar(1000) comment '方法參數',return_value varchar(2000) comment '返回值',cost_time bigint comment '方法執行耗時, 單位:ms'
) comment '操作日志表';
3.2.2. 引入依賴
<!-- AOP-->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-aop</artifactId>
</dependency><!-- fastJSON 阿里巴巴提供的轉JSON的工具-->
<!-- 因為返回值是一個json的,但數據庫表需要的是字符串,所以使用此工具將json轉換成String -->
<dependency><groupId>com.alibaba</groupId><artifactId>fastjson</artifactId><version>2.0.7</version>
</dependency>
3.2.3. 新建實體類
@Data
@NoArgsConstructor
@AllArgsConstructor
public class OperateLog {private Integer id; //IDprivate Integer operateUser; //操作人IDprivate LocalDateTime operateTime; //操作時間private String className; //操作類名private String methodName; //操作方法名private String methodParams; //操作方法參數private String returnValue; //操作方法返回值private Long costTime; //操作耗時
}
3.2.4. 新建Mapper層
@Mapper
public interface OperateLogMapper {//插入日志數據@Insert("insert into operate_log (operate_user, operate_time, class_name, method_name, method_params, return_value, cost_time) " +"values (#{operateUser}, #{operateTime}, #{className}, #{methodName}, #{methodParams}, #{returnValue}, #{costTime});")void insert(OperateLog log);
}
3.2.5. 新建注解
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Log {
}
3.2.6. 定義切面類,完成記錄操作日志的邏輯代碼
@Component
@Aspect
@Slf4j
public class LogAspect {@Autowiredprivate HttpServletRequest request;@Autowiredprivate OperateLogMapper operateLogMapper;@Around("@annotation(com.example.anno.Log)")public Object recordLog(ProceedingJoinPoint joinPoint) throws Throwable {//操作人ID 因為jwt令牌有登錄人信息,所以解析jwt令牌就可以
// String token = request.getHeader("token");
// Claims claims = JwtUtils.parseJWT(token);
// Integer user = (Integer) claims.get("id");// 使用鏈式編程 ↓↓↓Integer user = (Integer) JwtUtils.parseJWT(request.getHeader("token")).get("id");//操作時間LocalDateTime optionTime = LocalDateTime.now();//操作類名String className = joinPoint.getTarget().getClass().getName();//操作方法名String methodName = joinPoint.getSignature().getName();//操作方法參數String args = Arrays.toString(joinPoint.getArgs());long start = System.currentTimeMillis(); // 記錄方法開始運行時間// 調用原始方法Object result = joinPoint.proceed();long end = System.currentTimeMillis(); // 記錄方法結束運行時間//操作方法返回值String returnValue = JSONObject.toJSONString(result);//操作耗時long costTime = end - start;// 記錄操作日志OperateLog operateLog = new OperateLog(null, user, optionTime, className, methodName, args, returnValue, costTime);operateLogMapper.insert(operateLog);log.info("AOP記錄操作日志:{}", operateLog);return result;}
}
3.2.7. 給需要記錄的方法上面添加自定義的注解
// 這里就不一一展示了
/*** 根據id刪除部門*/@Log@DeleteMapping("/{id}")public Result delete(@PathVariable Integer id){log.info("根據id刪除部門:{}",id);deptService.delete(id);return Result.success();}/*** 添加部門*/@Log@PostMappingpublic Result add(@RequestBody Dept dept){log.info("添加部門{}",dept);deptService.add(dept);return Result.success();}
3.3. 查看結果
剛剛進行了部門的增刪改以及員工的增刪改操作,我們查看數據庫,看有沒有被記錄。
1,1,2024-10-27 20:20:23,com.example.controller.DeptController,delete,[15],"{""code"":1,""msg"":""success""}",40
2,1,2024-10-27 20:20:45,com.example.controller.DeptController,add,"[Dept(id=null, name=測試部, createTime=null, updateTime=null)]","{""code"":1,""msg"":""success""}",5
3,1,2024-10-27 20:21:00,com.example.controller.EmpController,sava,"[Emp(id=null, username=測試, password=null, name=測試, gender=1, image=, job=1, entrydate=2024-10-20, deptId=16, createTime=null, updateTime=null)]","{""code"":1,""msg"":""success""}",6
4,1,2024-10-27 20:23:01,com.example.controller.DeptController,add,"[Dept(id=null, name=1, createTime=null, updateTime=null)]","{""code"":1,""msg"":""success""}",8
5,1,2024-10-27 20:23:18,com.example.controller.DeptController,delete,[17],"{""code"":1,""msg"":""success""}",12
完全符合要求!!!!!!