一、背景
在后台管理系统中,对"部门"这类核心数据的新增、删除、修改操作往往需要有据可查------谁、在什么时间、调用了哪个方法、传入了什么参数、执行耗时多久。如果把这些日志逻辑硬编码进每个 Controller 方法,会造成大量重复代码且难以维护。
本项目采用 Spring AOP(面向切面编程) ,通过自定义注解 @Log 作为"切点标记",在方法执行前后自动织入日志记录逻辑,做到业务代码与日志代码完全解耦。
二、整体架构
HTTP 请求
│
▼
┌─────────────┐ 注解 @Log 标记
│ DeptController│──────────────┐
│ (增/删/改) │ │
└─────────────┘ │
│ proceed() ▼
▼ ┌──────────────────┐
┌─────────────┐ │ OperateLogAspect │ @Around 环绕通知
│ DeptService │ │ (AOP 切面) │
└─────────────┘ └──────────────────┘
│ 组装 OperateLog
▼
┌────────────────┐
│ OperateLogMapper│ → operate_log 表
└────────────────┘
三、核心组件逐一拆解
1. 自定义注解 @Log ------ 切点标记
java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Log {
}
这是一个标记型注解 :本身不带任何属性,只用于在目标方法上"打标签"。@Retention(RUNTIME) 保证它在运行时可通过反射被 AOP 识别。
2. 切点声明 ------ 哪里织入?
java
@Around("@annotation(com.mlamp.anno.Log)")
public Object recordLog(ProceedingJoinPoint proceedingJoinPoint) throws Throwable
@Around + @annotation(...Log) 的组合含义是:所有被 @Log 标注的方法,都作为切点的匹配范围。这样我们只需要给想记录的方法贴注解,非常灵活。
3. 切面逻辑 ------ 记录什么?
OperateLogAspect 在方法执行前后各取一次时间戳,拿到"耗时",再收集上下文信息组装成 OperateLog 对象:
java
long begin = System.currentTimeMillis();
Object result = proceedingJoinPoint.proceed(); // 执行原业务方法
long end = System.currentTimeMillis();
OperateLog olog = new OperateLog();
olog.setOperateEmpId(getCurrentuserId()); // 操作人
olog.setOperateTime(LocalDateTime.now()); // 操作时间
olog.setClassName(proceedingJoinPoint.getTarget().getClass().getName()); // 类名
olog.setMethodName(proceedingJoinPoint.getSignature().getName()); // 方法名
olog.setMethodParams(Arrays.toString(proceedingJoinPoint.getArgs())); // 入参
olog.setReturnValue(result != null ? result.toString() : "void"); // 返回值
olog.setCostTime(end - begin); // 耗时
关键点 :proceedingJoinPoint.proceed() 是环绕通知的核心------必须调用它,原业务方法(如真正的删除逻辑)才会执行。若不调用,目标方法将被"拦腰截断"。
4. 操作人身份获取 ------ ThreadLocal 传递
java
private Integer getCurrentuserId() {
return Integer.parseInt(ThreadLocalUtil.get().toString());
}
切面本身无法直接拿到 HTTP 请求头里的 JWT,因此由 DemoInterceptor 拦截器 在请求入口处完成身份解析,并把操作人 ID 存入 ThreadLocalUtil:
java
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String token = request.getHeader("token");
if (token == null || token.isEmpty()) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return false; // token 缺失,拦截
}
try {
Claims claims = JwtUtil.parseToken(token); // 解析 JWT
Integer empId = Integer.valueOf(claims.get("id").toString());
ThreadLocalUtil.set(empId); // ★ 将操作人 ID 存入 ThreadLocal
} catch (Exception e) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return false; // token 非法,拦截
}
return HandlerInterceptor.super.preHandle(request, response, handler);
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);
ThreadLocalUtil.remove(); // ★ 请求结束,清理 ThreadLocal,避免内存泄漏
}
流程要点:
preHandle:从请求头取token,用JwtUtil.parseToken解析出Claims,取出其中的id作为empId,调用ThreadLocalUtil.set(empId)存入当前线程。postHandle:目标方法执行完毕后调用ThreadLocalUtil.remove()清理,防止线程池复用导致数据串用或内存泄漏。
由于一次 HTTP 请求在同一个线程内执行,AOP 切面随后便能从 ThreadLocal 读取到刚存入的 empId,从而拿到操作人身份,避免参数层层透传。ThreadLocalUtil 提供了 set / get / remove 三个核心方法,与上面拦截器的写入、清理相呼应。
5. 持久化 ------ 写库
OperateLogMapper 用 MyBatis 注解方式把日志记录落库:
java
@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})")
void insert(OperateLog log);
OperateLog 实体字段与表结构一一对应:operate_emp_id、operate_time、class_name、method_name、method_params、return_value、cost_time。
四、DeptController 中的实际应用
在 DeptController 中,只需在目标方法上添加 @Log 即可接入日志:
java
// 删除部门 ------ 标记 @Log
@Log
@DeleteMapping
public Result delete(Integer id){
deptService.deleteById(id);
return Result.success();
}
// 新增部门 ------ 标记 @Log
@Log
@PostMapping
public Result add(@RequestBody Dept dept){
deptService.add(dept);
return Result.success();
}
注意 list()(查询)、getDeptById()(详情)等方法未加 @Log ,因此不会被记录。这正是注解驱动切面的优势:按需记录,零侵入、可粒度控制。
五、一次"删除部门"请求的完整执行流程
- 前端
DELETE /depts?id=5进入DemoInterceptor.preHandle,解析请求头中的 JWT,取出登录用户empId并ThreadLocalUtil.set(empId);token 缺失或非法则直接返回 401 拦截。 - 请求放行到达
DeptController.delete(5)。 - Spring 发现该方法被
@Log标记,将控制权交给OperateLogAspect.recordLog。 - 切面记录
begin时间戳。 - 调用
proceed()→ 真正执行deptService.deleteById(5)落库删除。 - 切面记录
end时间戳,算出costTime。 - 从
ThreadLocalUtil取当前用户 ID(即拦截器存入的empId),组装OperateLog(类名DeptController、方法名delete、参数[5]、返回值Result、耗时毫秒)。 operateLogMapper.insert(olog)写入operate_log表,同时log.info打印到日志。- 切面返回原方法结果,响应前端。
- 请求处理完毕,
DemoInterceptor.postHandle调用ThreadLocalUtil.remove()清理当前线程的操作人信息。
整个过程业务方法对日志逻辑完全无感知,新增/修改日志记录也无需改动业务代码。
六、小结与可优化点
优点
- 解耦:日志逻辑与业务逻辑分离。
- 灵活:注解驱动,按需标记,粒度可控。
- 低侵入:业务方法零改动。
可优化点(供思考)
- 当前
returnValue直接toString(),若返回值较大可能拖慢且占库;可限制长度或按需记录。 getReturnValue在proceed()之后获取,若业务方法抛异常则不会记录返回值和入库------可考虑补充@AfterThrowing记录异常日志。getCurrentuserId()强转ThreadLocal,若拦截器未设置会 NPE;建议增加空值兜底。