Web后端进阶:AOP记录操作日志
1.AOP基础
1.1 AOP入门
- 需求:统计部门管理各个业务层方法执行耗时。
- 原始方式:
在原始的实现方式中,我们需要在业务层的也一个方法执行执行,获取方法运行的开始时间; 然后运行原始的方法逻辑; 最后在每一个方法运行结束时,获取方法运行结束时间,计算执行耗时。
- SpringAOP实现步骤:
- 1). 导入依赖:在 pom.xml 文件中导入 AOP 的依赖
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId></dependency>
2). 编写AOP程序:针对于特定方法根据业务需要进行编程
@Component@Aspect //当前类为切面类@Slf4jpublic class RecordTimeAspect { @Around(\"execution(* com.itheima.service.impl.DeptServiceImpl.*(..))\") public Object recordTime(ProceedingJoinPoint pjp) throws Throwable { //记录方法执行开始时间 long begin = System.currentTimeMillis(); //执行原始方法 Object result = pjp.proceed(); //记录方法执行结束时间 long end = System.currentTimeMillis(); //计算方法执行耗时 log.info(\"方法执行耗时: {}毫秒\",end-begin); return result; }}
1.2AOP核心概念
- 连接点:JoinPoint,可以被AOP控制的方法(暗含方法执行时的相关信息)
- 通知:Advice,指哪些重复的逻辑,也就是共性功能(最终体现为一个方法)
- 切入点:PointCut,匹配连接点的条件,通知仅会在切入点方法执行时被应用。
- 切面:Aspect,描述通知与切入点的对应关系(通知+切入点)
而切面所在的类,称之为切面类(被@Aspect
注解标识的类)。
- 目标对象:Target,通知所应用的对象
AOP的核心概念我们介绍完毕之后,接下来我们再来分析一下我们所定义的通知是如何与目标对象结合在一起,对目标对象当中的方法进行功能增强的。
Spring的AOP底层是基于动态代理技术来实现的,也就是说在程序运行的时候,会自动的基于动态代理技术为目标对象生成一个对应的代理对象。在代理对象当中就会对目标对象当中的原始方法进行功能的增强。
SpringAOP 旨在管理bean对象的过程中,主要通过底层的动态代理机制,对特定的方法进行编程 。
2.AOP进阶
2.1通知类型
在使用通知时的注意事项:
- @Around环绕通知需要自己调用 ProceedingJoinPoint.proceed() 来让原始方法执行,其他通知不需要考虑目标方法执行
- @Around环绕通知方法的返回值,必须指定为Object,来接收原始方法的返回值,否则原始方法执行完毕,是获取不到返回值的。
如何来解决这个切入点表达式重复的问题? 答案就是:抽取
Spring提供了@PointCut
注解,该注解的作用是将公共的切入点表达式抽取出来,需要用到时引用该切入点表达式即可。
@Slf4j@Component@Aspectpublic 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修饰时,仅能在当前切面类中引用该表达式, 当外部其他切面类中也要引用当前类中的切入点表达式,就需要把private改为public
2.2通知顺序
- 在不同切面类中,默认按照切面类的类名字母排序:
- 目标方法前的通知方法:字母排名靠前的先执行
- 目标方法后的通知方法:字母排名靠前的后执行
如果我们想控制通知的执行顺序有两种方式:
- 修改切面类的类名(这种方式非常繁琐、而且不便管理)
- 使用Spring提供的
@Order
注解
2.3切入点表达式
- 作用:主要用来决定项目中的哪些方法需要加入通知
- 常见形式:
-
execution(……):根据方法的签名来匹配
-
@annotation(……) :根据注解匹配
-
2.3.1 execution
execution(访问修饰符? 返回值 包名.类名.?方法名(方法参数) throws 异常?)
其中带?的表示可以省略的部分
- 访问修饰符:可省略(比如: public、protected)
- 包名.类名: 可省略
- throws 异常:可省略(注意是方法上声明抛出的异常,不是实际抛出的异常)
示例:
@Before(\"execution(void com.itheima.service.impl.DeptServiceImpl.delete(java.lang.Integer))\")
可以使用通配符描述切入点
*
:单个独立的任意符号,可以通配任意返回值、包名、类名、方法名、任意类型的一个参数,也可以通配包、类、方法名的一部分..
:多个连续的任意符号,可以通配任意层级的包,或任意类型、任意个数的参数
切入点表达式的语法规则:
- 方法的访问修饰符可以省略
- 返回值可以使用*号代替(任意返回值类型)
- 包名可以使用号代替,代表任意包(一层包使用一个)
- 使用…配置包名,标识此包以及此包下的所有子包
- 类名可以使用*号代替,标识任意类
- 方法名可以使用*号代替,表示任意方法
- 可以使用 * 配置参数,一个任意类型的参数
- 可以使用… 配置参数,任意个任意类型的参数
注意事项:
- 根据业务需要,可以使用 且(&&)、或(||)、非(!) 来组合比较复杂的切入点表达式。
execution(* com.itheima.service.DeptService.list(..)) || execution(* com.itheima.service.DeptService.delete(..))
切入点表达式书写建议:
- 所有业务方法名在命名时尽量规范,方便切入点表达式快速匹配。如:findXxx,updateXxx。
- 描述切入点方法通常基于接口描述,而不是直接描述实现类,增强拓展性。
- 在满足业务需要的前提下,尽量缩小切入点的匹配范围。如:包名尽量不使用…,使用 * 匹配单个包。
2.3.2 @annotation
实现步骤:
- 编写自定义注解
- 在业务类要做为连接点的方法上添加自定义注解
自定义注解:LogOperation
@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME)public @interface LogOperation{}
业务类:DeptServiceImpl
@Slf4j@Servicepublic class DeptServiceImpl implements DeptService { @Autowired private 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); } @Override public Dept getById(Integer id) { return deptMapper.getById(id); } @Override public void update(Dept dept) { dept.setUpdateTime(LocalDateTime.now()); deptMapper.update(dept); }}
切面类
@Slf4j@Component@Aspectpublic 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 ...\"); }}
3.AOP案例
3.1需求
需求:将案例(Tlias智能学习辅助系统)中增、删、改相关接口的操作日志记录到数据库表中
- 就是当访问部门管理和员工管理当中的增、删、改相关功能接口时,需要详细的操作日志,并保存在数据表中,便于后期数据追踪。
操作日志信息包含:
- 操作人、操作时间、执行方法的全类名、执行方法名、方法运行时参数、返回值、方法执行时长
3.4代码实现
1). 准备工作
- 在 pom.xml 中引入AOP的依赖
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId></dependency>
- 创建数据库表结构
-- 操作日志表create table operate_log( id int unsigned primary key auto_increment comment \'ID\', operate_emp_id 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 \'返回值, 存储json格式\', cost_time int comment \'方法执行耗时, 单位:ms\') comment \'操作日志表\';
- 准备的实体类
package com.itheima.pojo;import lombok.AllArgsConstructor;import lombok.Data;import lombok.NoArgsConstructor;import java.time.LocalDateTime;@Data@NoArgsConstructor@AllArgsConstructorpublic class OperateLog { private Integer id; //ID private Integer operateEmpId; //操作人ID private LocalDateTime operateTime; //操作时间 private String className; //操作类名 private String methodName; //操作方法名 private String methodParams; //操作方法参数 private String returnValue; //操作方法返回值 private Long costTime; //操作耗时}
- 日志操作Mapper接口 OperateLogMapper
package com.itheima.mapper;import com.itheima.pojo.OperateLog;import org.apache.ibatis.annotations.Insert;import org.apache.ibatis.annotations.Mapper;@Mapperpublic interface OperateLogMapper { //插入日志数据 @Insert(\"insert into operate_log (operate_emp_id, operate_time, class_name, method_name, method_params, return_value, cost_time) \" + \"values (#{operateEmpId}, #{operateTime}, #{className}, #{methodName}, #{methodParams}, #{returnValue}, #{costTime});\") public void insert(OperateLog log); }
1). 自定义注解 @LogOperation
/** * 自定义注解,用于标识哪些方法需要记录日志 */@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME)public @interface LogOperation {}
2). 定义AOP记录日志的切面类
package com.example.Aop;import com.example.anno.LogOperation;import com.example.mapper.OperateLogMapper;import com.example.pojo.CurrentHolder;import com.example.pojo.OperateLog;import org.aspectj.lang.ProceedingJoinPoint;import org.aspectj.lang.annotation.Around;import org.aspectj.lang.annotation.Aspect;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.stereotype.Component;import java.time.LocalDateTime;import java.util.Arrays;@Aspect@Componentpublic class OperationLogAspect { @Autowired private OperateLogMapper operateLogMapper; //环绕通知 @Around(\"@annotation(com.example.anno.LogOperation)\") public Object around(ProceedingJoinPoint joinPoint) throws Throwable { // 记录开始时间 long startTime = System.currentTimeMillis(); // 执行方法 Object result = joinPoint.proceed(); // 当前时间 long endTime = System.currentTimeMillis(); // 耗时 long costTime = endTime - startTime; // 构建日志对象 OperateLog operateLog = new OperateLog(); operateLog.setOperateEmpId(getCurrentUserId()); // 需要实现 getCurrentUserId 方法 operateLog.setOperateTime(LocalDateTime.now()); operateLog.setClassName(joinPoint.getTarget().getClass().getName()); operateLog.setMethodName(joinPoint.getSignature().getName()); operateLog.setMethodParams(Arrays.toString(joinPoint.getArgs())); operateLog.setReturnValue(result.toString()); operateLog.setCostTime(costTime); // 插入日志 operateLogMapper.insert(operateLog); return result; } // 示例方法,获取当前用户ID private int getCurrentUserId() { // 这里应该根据实际情况从认证信息中获取当前登录用户的ID return CurrentHolder.getCurrentId(); }}
3). 在需要记录的日志的Controller层的方法上,加上注解 @LogOperation
3.6获取当前登录员工
- 员工登录成功后,哪里存储的有当前登录员工的信息? 给客户端浏览器下发的jwt令牌中
- 如何从JWT令牌中获取当前登录用户的信息呢? 获取请求头中传递的jwt令牌,并解析
- TokenFilter 中已经解析了令牌的信息,如何传递给AOP程序、Controller、Service呢?ThreadLocal
3.6.1 ThreadLocal
-
ThreadLocal 并不是一个Thread,而是Thread的局部变量。
-
ThreadLocal为每个线程提供一份单独的存储空间,具有线程隔离的效果,不同的线程之间不会相互干扰。
-
常见方法:
public void set(T value)
设置当前线程的线程局部变量的值public T get()
返回当前线程所对应的线程局部变量的值public void remove()
移除当前线程的线程局部变量
3.6.2记录当前登录员工
- 定义ThreadLocal操作的工具类,用于操作当前登录员工ID。
package com.example.pojo;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(); }}
- 在TokenInterceptor中,解析完当前登录员工ID,将其存入ThreadLocal(用完之后需将其删除)。
package com.example.interceptor;import com.example.controller.EmpController;import com.example.pojo.CurrentHolder;import com.example.utils.JwtUtils;import io.jsonwebtoken.Claims;import jakarta.servlet.http.HttpServletRequest;import jakarta.servlet.http.HttpServletResponse;import lombok.extern.slf4j.Slf4j;import org.apache.http.HttpStatus;import org.slf4j.Logger;import org.slf4j.LoggerFactory;import org.springframework.stereotype.Component;import org.springframework.util.StringUtils;import org.springframework.web.servlet.HandlerInterceptor;@Componentpublic class TokenInterceptor implements HandlerInterceptor { private static final Logger log = LoggerFactory.getLogger(EmpController.class); @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { //1. 获取请求url。// String url = request.getRequestURL().toString(); //2. 判断请求url中是否包含login,如果包含,说明是登录操作,放行。// if(url.contains(\"login\")){ //登录请求// log.info(\"登录请求 , 直接放行\");// return true;// } //3. 获取请求头中的令牌(token)。 String jwt = request.getHeader(\"token\"); //4. 判断令牌是否存在,如果不存在,返回错误结果(未登录)。 if(!StringUtils.hasLength(jwt)){ //jwt为空 log.info(\"获取到jwt令牌为空, 返回错误结果\"); response.setStatus(HttpStatus.SC_UNAUTHORIZED); return false; } //5. 解析token,如果解析失败,返回错误结果(未登录)。 try { Claims claims = JwtUtils.parseJWT(jwt); Integer empId = Integer.valueOf(claims.get(\"id\").toString()); CurrentHolder.setCurrentId(empId); } catch (Exception e) { e.printStackTrace(); log.info(\"解析令牌失败, 返回错误结果\"); response.setStatus(HttpStatus.SC_UNAUTHORIZED); return false; } //6. 放行。 log.info(\"令牌合法, 放行\"); return true; } // 清理线程变量 @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { CurrentHolder.remove(); }}
- 在AOP程序中,从ThreadLocal中获取当前登录员工的ID。
package com.itheima.aop;import com.itheima.anno.LogOperation;import com.itheima.mapper.OperateLogMapper;import com.itheima.pojo.OperateLog;import com.itheima.utils.CurrentHolder;import org.aspectj.lang.ProceedingJoinPoint;import org.aspectj.lang.annotation.Around;import org.aspectj.lang.annotation.Aspect;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.stereotype.Component;import java.time.LocalDateTime;import java.util.Arrays;@Aspect@Componentpublic class OperationLogAspect { @Autowired private OperateLogMapper operateLogMapper; // 环绕通知 @Around(\"@annotation(log)\") public Object around(ProceedingJoinPoint joinPoint, LogOperation log) throws Throwable { // 记录开始时间 long startTime = System.currentTimeMillis(); // 执行方法 Object result = joinPoint.proceed(); // 当前时间 long endTime = System.currentTimeMillis(); // 耗时 long costTime = endTime - startTime; // 构建日志对象 OperateLog operateLog = new OperateLog(); operateLog.setOperateEmpId(getCurrentUserId()); // 需要实现 getCurrentUserId 方法 operateLog.setOperateTime(LocalDateTime.now()); operateLog.setClassName(joinPoint.getTarget().getClass().getName()); operateLog.setMethodName(joinPoint.getSignature().getName()); operateLog.setMethodParams(Arrays.toString(joinPoint.getArgs())); operateLog.setReturnValue(result.toString()); operateLog.setCostTime(costTime); // 插入日志 operateLogMapper.insert(operateLog); return result; } // 示例方法,获取当前用户ID private int getCurrentUserId() { return CurrentHolder.getCurrentId(); }}