SpringAOP
什么是AOP?
AOP:Aspect Oriented Programming(面向切面编程,面向方面编程),可以简单理解为就是面向特定方法编程
场景:案例中部分业务方法运行较慢,定位执行耗时较长的接口,此时需要统计每一个业务方法的执行耗时
优势:
1:减少重复代码
2:代码无侵入
3:提高开发效率
4:维护方便
提示:AOP 是一种思想,而在 Spring 框架中对这种思想进行的实现,那我们要学习的就是 Spring AOP。
AOP 基础
AOP 快速入门
需求:统计所有业务层方法的执行耗时。
1:导入依赖:在 pom.xml 中引入 AOP 的依赖
org.springframework.boot spring-boot-starter-aop
2:编写 AOP 程序:针对于特定的方法根据业务需要进行编程
package com.itheima.aop;import lombok.extern.slf4j.Slf4j;import org.aspectj.lang.ProceedingJoinPoint;import org.aspectj.lang.annotation.Around;import org.aspectj.lang.annotation.Aspect;import org.springframework.stereotype.Component;import java.util.Objects;@Slf4j@Aspect // 标识当前是一个 AOP 类@Componentpublic class RecordTimeAspect { @Around(\"execution(* com.itheima.service.impl.*.*(..))\") public Object recordTime(ProceedingJoinPoint pjp) throws Throwable { //1:记录方法运行的开始时间 long begin = System.currentTimeMillis(); //2:执行原始的方法 Object result = pjp.proceed(); //3:记录方法运行的结束时间 long end = System.currentTimeMillis(); log.info(\"方法:{} 执行耗时: 方法运行时间:{}\", pjp.getSignature(), end - begin); return result; }}
AOP 核心概念
连接点:JoinPoint,可以被 AOP 控制的方法(暗含方法执行时的相关信息)
切入点:PointCut,实际被 AOP 控制的方法(切入点一定是连接点,连接点不一定是切入点)
通知:Advice,指那些重复的逻辑,也就是共性功能(最终体现为一个方法)也就是上面的那个 recordTime 方法
切面:Aspect,描述通知与切入点的对应关系(通知 + 切入点)
目标对象:Target,通知所应用的对象
AOP 执行流程:动态代理
这里解释一下:SpingAOP 的底层是通过动态代理技术实现的,它会基于动态代理技术为这个目标对象生成一个代理对象 ,这个代理对象会和目标对象实现同一个接口,然后再去实现这个接口的方法,比如这个 list 方法,那么这个代理对象中的 list 方法逻辑是什么样的呢?
其实就是通知方法中对应的逻辑。
最后会将这个代理对象交给 IOC 容器管理,然后我们在 Controller 层注入的其实就是代理对象,调用的 list 方法也就是代理对象中的 list 方法,而代理对象中的 list 方法已经基于 AOP 程序中的这个逻辑对目标对象的原始方法进行了功能增强,已经可以统计出这些业务方法的执行耗时了。
AOP 进阶
通知类型
1:@Around:环绕通知,此注解标注的通知方法在目标方法前,后都被执行
2:@Before:前置通知,此注解标注的通知方法在目标方法前被执行
3:@After:后置通知,此注解标注的通知方法在目标方法后被执行,无论是否有异常都会执行
4:@AfterReturning:返回后通知,此注解标注的通知方法在目标方法后被执行,有异常不会执行
5:@AfterThrowing:异常后通知,此注解标注的通知方法发生异常后执行
注意1:@Around 环绕通知需要自己调用 ProceedingJoinPoint.proceed() 来让原始方法执行,其他通知不需要考虑目标方法执行.
注意2:@Around 环绕通知方法的返回值,必须指定为 Object,来接收原始方法的返回值。
当然这里有一个技巧,为了避免我们重复写这个切入点表达式造成出错,我们可以用以下方法
@Pointcut(\"execution(* com.itheima.service.impl.*.*(..))\") public void pointcut() { } @Around(\"pointcut()\")
通知顺序
当有多个切面的切入点都匹配导了目标方法,目标方法运行时,多个通知方法都会被执行。
执行顺序:
-> 不同切面类中,默认按照切面类的类名字母排序:
目标方法前的通知方法:字母排名靠前的先执行
目标方法后的通知方法:字母排名靠前的后执行
-> 用 @Order(数字)加在切面类上来控制顺序
目标方法前的通知方法:数字小的先执行
目标方法后的通知方法:数字小的后执行
切入点表达式
切入点表达式 -- execution
execution 主要根据方法的返回值,包名,类名,方法名,方法参数等信息来匹配,语法为:
其中带 ? 的表示可以省略的部分
1:访问修饰符:可省略(比如:public, protected)
2:包名.类名:可省略(强烈不建议)
3:throws 异常:可省略(注意是方法上声明抛出的异常,不是实际抛出的异常)
可以使用通配符描述切入点
1:* : 单个独立的任意符号,可以通配任意返回值,包名,类名,方法名,任意类型的一个参数,也可以通配包,类,方法名的一部分
2:.. :多个连续的任意符号,可以通配任意层级的包,或任意类型,任意个数的参数
补充:如果我们想同时匹配两个或两个以上的方法,我们也可以用逻辑运算符
比如:我们要同时匹配 list 与 delete 方法
@Around(\"execution(* com.itheima.service.impl.DeptServiceImpl.delete(..)) || execution(* com.itheima.service.impl.DeptServiceImpl.list(..))\")
切入点表达式 -- @annotation
@annotation 切入点表达式,用于匹配标识有特定注解的方法。
我们首先要新建一个注解类
package com.itheima.anno;import java.lang.annotation.ElementType;import java.lang.annotation.Retention;import java.lang.annotation.RetentionPolicy;import java.lang.annotation.Target;@Target(ElementType.METHOD) // 这个注解在哪生效; -- (在方法上生效)@Retention(RetentionPolicy.RUNTIME) // 这个注解什么时候生效; -- (运行时生效)public @interface LogOperation {}
然后改一下我们的切入点表达式
@Around(\"@annotation(com.itheima.anno.LogOperation)\") public Object recordTime(ProceedingJoinPoint pjp) throws Throwable { //1:记录方法运行的开始时间 long begin = System.currentTimeMillis(); //2:执行原始的方法 Object result = pjp.proceed(); //3:记录方法运行的结束时间 long end = System.currentTimeMillis(); log.info(\"方法:{} 执行耗时: 方法运行时间:{}\", pjp.getSignature(), end - begin); return result; }
最后在我们要匹配的方法前面加上我们刚才创捷的注解的名字
@LogOperation @Override public void delete(Integer id) { deptMapper.delete(id); }
连接点
在 Spring 中用 JointPoint 抽象了连接点,用它可以获得方法执行时的相关信息,如目标类名,方法名,方法参数等
-> 对于 @Around 通知,获取连接点信息只能使用 ProceedingJoinPoint
-> 对于其它四种通知,获取连接点信息只能使用 JoinPoint,它是 ProceedingJoinPoint 的父类型
有几个我们常用的关于连接点的方法
@Before(\"execution(* com.itheima.service.impl.*.*(..))\") public void before(JoinPoint joinPoint) { log.info(\"方法执行之前\"); //1:获取目标对象 Object target = joinPoint.getTarget(); log.info(\"目标对象:{}\", target); //2:获取目标类 String className = joinPoint.getTarget().getClass().getName(); log.info(\"目标类:{}\", className); //3:获取目标方法名 String methodName = joinPoint.getSignature().getName(); log.info(\"目标方法名:{}\", methodName); //4:获取目标方法参数 Object[] args = joinPoint.getArgs(); log.info(\"目标方法参数:{}\", Arrays.toString(args)); }
AOP 案例
将案例中增,删,改相关接口的操作日志记录到数据库表中
日志信息包含:操作人,操作时间,执行方法的全类名,执行方法名,方法运行时参数,返回值,方法执行时长
采用哪种通知类型? @Around 环绕通知
切入点表达式该怎么写?
由于增,删,改,方法名不一样,我们用 @annotation 写
我们就以 Controller 层的 Dept 的方法为例
package com.itheima.aop;import com.itheima.mapper.OperateLogMapper;import com.itheima.pojo.OperateLog;import lombok.extern.slf4j.Slf4j;import org.aspectj.lang.ProceedingJoinPoint;import org.aspectj.lang.annotation.Around;import org.aspectj.lang.annotation.Aspect;import org.aspectj.lang.annotation.Pointcut;import org.aspectj.lang.reflect.MethodSignature;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.stereotype.Component;import org.springframework.util.StopWatch;import java.time.LocalDateTime;import java.util.Arrays;import java.util.Objects;import java.util.stream.Collectors;@Slf4j@Aspect@Componentpublic class OperateLogAspect { @Autowired private OperateLogMapper operateLogMapper; // 定义切点 @Pointcut(\"@annotation(com.itheima.anno.Log)\") public void operateLogPointcut() {} // 环绕通知:记录操作日志 @Around(\"operateLogPointcut()\") public Object recordOperateLog(ProceedingJoinPoint joinPoint) throws Throwable { long beginTime = System.currentTimeMillis(); Object result = joinPoint.proceed(); // 计算耗时 long endTime = System.currentTimeMillis(); long costTime = endTime - beginTime; // 构建日志实体 OperateLog alog = new OperateLog(); alog.setOperateEmpId(1); alog.setOperateTime(LocalDateTime.now()); alog.setClassName(joinPoint.getTarget().getClass().getName()); alog.setMethodName(joinPoint.getSignature().getName()); alog.setMethodParams(Arrays.toString(joinPoint.getArgs())); alog.setReturnValue(result != null ? result.toString() : \"void\"); alog.setCostTime(costTime); log.info(\"操作日志:{}\", alog); //保存日志 operateLogMapper.insert(alog); return result; }}
不要忘了在对应的方法上添加 Log 注解
这里还有个问题,就是我们的 EmpId 是写死的,接下来我们要解决这个问题
获取当前登录员工(ThreadLocal)
首先思考:
1:员工登录成功后,哪里存储的当前登录员工的信息? 给客户端浏览器下发的 jwt 令牌中
2:如何从 jwt 令牌中获取到当前登录的员工信息? 获取请求头中传递的 jwt 令牌,并解析
3:TokenFilter 中已经解析了令牌的信息,如何
将其传递给 AOP 程序,Controller,Service 呢? ThreadLocal
ThreadLocal 并不是一个 Thread,而是 Thread 的局部变量。
ThreadLocal 为每个线程提供一份单独的存储空间,具有线程隔离的效果,不同的线程之间不会相互干扰。
ThreadLocal 常用方法:
public void set(T value) 设置当前线程的线程局部变量的值
public T get() 返回当前线程所对应的线程局部变量的值
public void remove() 移除当前线程的线程局部变量
这个玩意就是每次请求的线程;
执行流程:
具体操作步骤:
1:定义在 ThreadLocal 操作的工具类,用于操作当前登录员工 ID;
2:在 TokenFilter 中,解析完当前登录员工 ID,将其存入 ThreadLocal(用完之后需将其删除)。
3:在 AOP 程序中,从 ThreadLocal 中获取当前登录员工的 ID。
我们首先需要一个工具类
package com.itheima.utils;public class CurrentHolder { private static final ThreadLocal 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 放入当前线程中
//5:如果token存在,校验令牌,如果令牌校验失败,则返回错误信息(响应 401 状态码) try { Claims claims = JwtUtils.parseJwt(token); Integer empId = Integer.valueOf(claims.get(\"id\").toString()); CurrentHolder.setCurrentId(empId); log.info(\"当前登录员工ID:{},将其存入ThreadLocal\", empId); } catch (Exception e) { log.info(\"令牌校验失败,返回错误信息\"); response.setStatus(401); return; }
最后返回给我们的 AOP 类
alog.setOperateEmpId(getCurrentUserId()); private Integer getCurrentUserId(){ return CurrentHolder.getCurrentId();}
所以最后总结:这个 ThreadLocal 的应用场景:在同一个线程/同一个请求中,进行数据共享。