SpringBoot核心框架之AOP详解
SpringBoot核心框架之AOP详解#### 一、AOP基础##### 1.1 AOP概述* AOPAspectOrientedProgramming面向切面编程面向方面编程其实就是面向特定方法编程。 * 场景项目部分功能运行较慢定位执行耗时较长的业务方法此时就需要统计每一个业务的执行耗时。 * 思路给每个方法在开始前写一个开始计时的逻辑在方法结束后写一个计时结束的逻辑然后相减得到运行时间。思路是没问题的但是有个问题一个项目是有很多方法的如果挨个增加逻辑代码会相当繁琐造成代码的臃肿所以可以使用AOP编程将计时提出成一个这样的模板 1. 获取方法运行开始时间 2. 运行原始方法 3. 获取方法运行结束时间计算执行耗时原始方法就是我们需要计算时间的方法并且可以对原始方法进行增强其实这个技术就是用到了我们在Java基础部分学习的动态代理技术。实现动态代理是面向切面编程最主流的实现。而SpringAOP是Spring框架的高级技术旨在管理bean对象的过程中主要是通过底层的动态代理机制对特点的方法进行编程。##### 1.2 AOP快速入门统计各个业务层方法执行耗时1. 导入依赖在pom.xml中导入AOP的依赖。 org.springframework.boot spring-boot-starter-aop 2. 编写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; } } 3. 查看结果这样我们就完成了一个AOP的小例子但是AOP的功能远不能这些他还有更多的实用的功能。比如记录操作日志可以记录谁什么时间操作了什么方法传了什么参数返回值是什么都可以很方便的实现。还有比如权限控制事务管理等等。我们来总结一下AOP的优势1. 代码无侵入2. 减少重复代码3. 提高开发效率4. 维护方便##### 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支持的通知类型通知类型1.环绕通知Around Advice重点* 使用Around注解来定义。* 包围目标方法的执行可以在方法执行前后执行自定义逻辑并且可以控制目标方法的执行。* 通过ProceedingJoinPoint参数的proceed()方法来决定是否执行目标方法。2.前置通知Before Advice* 使用Before注解来定义。* 在目标方法执行之前执行无论方法是否抛出异常都会执行。* 不能阻止目标方法的执行。3.后置通知After Advice 也叫最终通知* 使用After注解来定义。* 在目标方法执行之后执行无论方法是否抛出异常都会执行。* 通常用于资源清理工作*返回通知After Returning Advice了解* 使用AfterReturning注解来定义。* 在目标方法成功执行之后执行即没有抛出异常时执行。* 可以获取方法的返回值。4.异常通知After Advice了解* 使用AfterThrowing注解来定义。* 在目标方法抛出异常后执行。* 可以获取抛出的异常对象。注意事项1. 环绕通知需要自己调用joinPoint.proceed()来让原始方法执行其他通知则不需要。2. 环绕通知的返回值必须是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(id1, name学工部, createTime2023-11-30T13:55:55, updateTime2023-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. 引入依赖 org.springframework.boot spring-boot-starter-aop com.alibaba fastjson 2.0.7 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(idnull, name测试部, createTimenull, updateTimenull)]”,“{”“code”“:1,”“msg”“:”“success”“}”,5 3,1,2024-10-27 20:21:00,com.example.controller.EmpController,sava,“[Emp(idnull, username测试, passwordnull, name测试, gender1, image, job1, entrydate2024-10-20, deptId16, createTimenull, updateTimenull)]”,“{”“code”“:1,”“msg”“:”“success”“}”,6 4,1,2024-10-27 20:23:01,com.example.controller.DeptController,add,“[Dept(idnull, name1, createTimenull, updateTimenull)]”,“{”“code”“:1,”“msg”“:”“success”“}”,8 5,1,2024-10-27 20:23:18,com.example.controller.DeptController,delete,[17],“{”“code”“:1,”“msg”“:”“success”“}”,12完全符合要求