SpringBoot(六)--- AOP、ThreadLocal
目录
前言
一、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。