基于 Spring AOP 的操作日志记录:以 DeptController 增删接口为例

一、背景

在后台管理系统中,对"部门"这类核心数据的新增、删除、修改操作往往需要有据可查------谁、在什么时间、调用了哪个方法、传入了什么参数、执行耗时多久。如果把这些日志逻辑硬编码进每个 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 ,因此不会被记录。这正是注解驱动切面的优势:按需记录,零侵入、可粒度控制。

五、一次"删除部门"请求的完整执行流程

  1. 前端 DELETE /depts?id=5 进入 DemoInterceptor.preHandle,解析请求头中的 JWT,取出登录用户 empId 并 ThreadLocalUtil.set(empId);token 缺失或非法则直接返回 401 拦截。
  2. 请求放行到达 DeptController.delete(5)。
  3. Spring 发现该方法被 @Log 标记,将控制权交给 OperateLogAspect.recordLog。
  4. 切面记录 begin 时间戳。
  5. 调用 proceed() → 真正执行 deptService.deleteById(5) 落库删除。
  6. 切面记录 end 时间戳,算出 costTime。
  7. 从 ThreadLocalUtil 取当前用户 ID(即拦截器存入的 empId),组装 OperateLog(类名 DeptController、方法名 delete、参数 [5]、返回值 Result、耗时毫秒)。
  8. operateLogMapper.insert(olog) 写入 operate_log 表,同时 log.info 打印到日志。
  9. 切面返回原方法结果,响应前端。
  10. 请求处理完毕,DemoInterceptor.postHandle 调用 ThreadLocalUtil.remove() 清理当前线程的操作人信息。

整个过程业务方法对日志逻辑完全无感知,新增/修改日志记录也无需改动业务代码。

六、小结与可优化点

优点

  • 解耦:日志逻辑与业务逻辑分离。
  • 灵活:注解驱动,按需标记,粒度可控。
  • 低侵入:业务方法零改动。

可优化点(供思考)

  • 当前 returnValue 直接 toString(),若返回值较大可能拖慢且占库;可限制长度或按需记录。
  • getReturnValue 在 proceed() 之后获取,若业务方法抛异常则不会记录返回值和入库------可考虑补充 @AfterThrowing 记录异常日志。
  • getCurrentuserId() 强转 ThreadLocal,若拦截器未设置会 NPE;建议增加空值兜底。
相关推荐
懂点Tech5 小时前
ctfshow misc28:GIF 高度修改解题与脚本实现
开发语言·windows·python
comedate6 小时前
SMLib 的 Examples 导读:PyAPI / SM_API / SMLib 三组示例怎么读
python·cad·smlib·examples·sm_api
云和数据.ChenGuang6 小时前
Python 重写(Override)深度解析:从 MRO 到 super() 的完整机制
java·开发语言·人工智能·python·langchain·1024程序员节
摇滚侠6 小时前
《On Java 中文版 基础卷》阅读笔记 实现隐藏 12
java·数据库·笔记
川石课堂软件测试6 小时前
学习软件测试需掌握的一些知识
数据库·python·sql·功能测试·网络协议·mysql·http
万化冥合7 小时前
PyBox屏幕键盘如何辅助演示键盘操作?
python·计算机外设
车软学堂7 小时前
从测试转开发,到底要补什么
网络·人工智能·python·功能测试·单片机·车载系统·自动化
天空依然蔚蓝7 小时前
分享一个自动检测网站在线状态的python程序
python·运维开发·1024程序员节
岁月宁静7 小时前
一个 AI 项目从本地到上线,要经过哪些步骤?全流程讲清楚
python·docker·agent
codists8 小时前
《crafting interpreters》阅读笔记:p45-p45
python