SpringBoot核心框架之AOP详解
一、AOP基础
1.1 AOP概述
- AOP:Aspect Oriented Programming(面向切面编程,面向方面编程),其实就是面向特定方法编程。
- 场景:项目部分功能运行较慢,定位执行耗时较长的业务方法,此时就需要统计每一个业务的执行耗时。
- 思路:给每个方法在开始前写一个开始计时的逻辑,在方法结束后写一个计时结束的逻辑,然后相减得到运行时间。
思路是没问题的,但是有个问题,一个项目是有很多方法的,如果挨个增加逻辑代码,会相当繁琐,造成代码的臃肿,所以可以使用AOP编程,将计时提出成一个这样的模板:
- 获取方法运行开始时间
- 运行原始方法
- 获取方法运行结束时间,计算执行耗时
原始方法就是我们需要计算时间的方法,并且可以对原始方法进行增强,其实这个技术就是用到了我们在Java基础部分学习的动态代理技术。
实现:动态代理是面向切面编程最主流的实现。而SpringAOP是Spring框架的高级技术,旨在管理bean对象的过程中,主要是通过底层的动态代理机制,对特点的方法进行编程。
1.2 AOP快速入门
统计各个业务层方法执行耗时
- 导入依赖:在pom.xml中导入AOP的依赖。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
- 编写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; //ID
private Integer operateUser; //操作人ID
private 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 {
@Autowired
private HttpServletRequest request;
@Autowired
private 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
@PostMapping
public 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
完全符合要求!!!!!!