基于Spring AOP优雅记录请求响应日志

Spring Boot 优雅实现接口日志:注解 + AOP 记录请求入参与出参

在日常开发中,排查线上问题最痛苦的事情之一,就是不知道某个接口到底收到了什么参数、返回了什么结果、耗时多久 。靠人工在每个 Controller 方法里一行一行地写 log.info(...),既啰嗦又容易漏。

本文介绍一种优雅、可复用 的做法:自定义两个注解(@BeforeLog@AroundLog)+ 一个 AOP 切面(@Aspect),零侵入地记录接口的请求入参、返回出参和处理耗时。

看完你会收获:

  • 理解 Spring AOP 的 @Before@Around 的区别
  • 学会自定义注解 + 切面组合拳
  • 掌握参数过滤、JSON 序列化、耗时统计等实用细节
  • 拿到一套可直接复制使用的完整代码

一、效果预览

在任意 Controller 方法上打一个注解,例如:

java 复制代码
@AroundLog
@GetMapping("/list")
public Result<List<User>> list() {
    return Result.ok(userService.list());
}

控制台就会自动输出这样一段规整的日志(无需手动写一行打印):

text 复制代码
=====================================
请求地址:http://localhost:8080/api/user/list
请求方式:GET
请求类方法:Result com.sxy.trande.controller.UserController.list()
请求方法参数:[]
返回报文:{"code":200,"data":[...],"msg":"success"}
处理耗时:32ms
=====================================

下面我们一步步把它搭起来。


二、整体设计思路

整个方案由三部分组成,职责清晰:

组成 说明
@BeforeLog 注解 标记在方法上,表示「只在方法执行记录日志」
@AroundLog 注解 标记在方法上,表示「记录方法执行前后日志 + 耗时」
LogAspect 切面 真正的日志逻辑:拦截带注解的方法,读取请求、序列化参数、输出日志

核心思想:把「打日志」这个横切关注点,从业务代码里抽离出来,用注解声明、用切面统一处理。


三、引入依赖

需要在 pom.xml 中引入 AOP 相关依赖。如果是多模块项目,切面类所在的模块需要这些依赖:

xml 复制代码
<!-- Spring Web(提供 HttpServletRequest 等) -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!-- AOP 支持(自定义切面必需) -->
<dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjrt</artifactId>
</dependency>
<dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjweaver</artifactId>
</dependency>

<!-- Lombok:用 @Slf4j 简化日志声明 -->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
</dependency>

<!-- Hutool:用 JSONUtil 做 JSON 序列化 -->
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
</dependency>

说明:aspectjrtaspectjweaver 是 Spring AOP 的核心,缺了它们 @Aspect 会不生效。如果不想手动加,也可以引入 spring-boot-starter-aop


四、第一步:定义两个注解

注解本身不包含任何逻辑,只是一个「标记」,供切面识别。

1. @BeforeLog ------ 方法执行前记录

java 复制代码
package com.sxy.trande.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * 日志注解,用于在方法执行之前记录 log
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface BeforeLog {
}

2. @AroundLog ------ 记录请求和响应(含耗时)

java 复制代码
package com.sxy.trande.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * 记录请求和响应的日志
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface AroundLog {
}

关键注解解释

元注解 作用
@Retention(RetentionPolicy.RUNTIME) 注解保留到运行期,切面在运行期才能通过反射拿到它
@Target(ElementType.METHOD) 注解只能用在方法上

这两个元注解缺一不可:没有 RUNTIME,运行期就「看不到」这个注解;没有 METHOD,就可能被误用在类或字段上。


五、第二步:编写切面类 LogAspect

这是核心,负责真正的日志逻辑。

java 复制代码
package com.sxy.trande.aspect;

import cn.hutool.json.JSONUtil;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.multipart.MultipartFile;

import java.util.Arrays;
import java.util.Collection;
import java.util.Objects;

/**
 * 日志切入类
 */
@Slf4j
@Component
@Aspect
public class LogAspect {

    /**
     * Before 切入点:标注了 @BeforeLog 注解的方法
     */
    @Pointcut("@annotation(com.sxy.trande.annotation.BeforeLog)")
    public void beforePointcut() {
    }

    /**
     * Around 切入点:标注了 @AroundLog 注解的方法
     */
    @Pointcut("@annotation(com.sxy.trande.annotation.AroundLog)")
    public void aroundPointcut() {
    }

    /**
     * 方法执行前记录请求日志
     */
    @Before("beforePointcut()")
    public void doBefore(JoinPoint joinPoint) {
        try {
            addLog(joinPoint, "", 0);
        } catch (Exception e) {
            log.error("doBefore 日志异常,", e);
        }
    }

    /**
     * 环绕通知:记录请求、响应和耗时
     */
    @Around("aroundPointcut()")
    public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
        Object[] args = joinPoint.getArgs();
        Object result;
        try {
            long startTime = System.currentTimeMillis();
            result = joinPoint.proceed(args);          // 执行目标方法
            long endTime = System.currentTimeMillis();
            long time = endTime - startTime;

            // 序列化返回结果
            String outParams = JSONUtil.toJsonStr(result);
            if (result == null) {
                outParams = "null";
            } else if (result instanceof ResponseEntity) {
                outParams = "ResponseEntity status=" + ((ResponseEntity<?>) result).getStatusCode();
            }
            addLog(joinPoint, outParams, time);
        } catch (Exception e) {
            log.error("doAround日志记录异常,信息为:", e);
            throw e;                                   // 异常必须继续抛出,不能吞掉
        }
        return result;
    }

    /**
     * 统一的日志输出逻辑
     */
    public void addLog(JoinPoint joinPoint, String outParams, long time) {
        HttpServletRequest request =
                ((ServletRequestAttributes) Objects.requireNonNull(
                        RequestContextHolder.getRequestAttributes())).getRequest();

        log.info("""
                        =====================================
                        请求地址:{}
                        请求方式:{}
                        请求类方法:{}
                        请求方法参数:{}
                        返回报文:{}
                        处理耗时:{}ms
                        =====================================""",
                request.getRequestURL(),
                request.getMethod(),
                joinPoint.getSignature(),
                JSONUtil.toJsonStr(filterArgs(joinPoint.getArgs())),
                outParams,
                String.valueOf(time)
        );
    }

    /**
     * 过滤特殊参数类型:文件、Http 对象、过大的集合等
     */
    private Object[] filterArgs(Object[] args) {
        if (args == null) return new Object[0];
        return Arrays.stream(args)
                .map(arg -> {
                    if (arg == null) return null;
                    if (arg instanceof HttpServletRequest) {
                        return "HttpServletRequest";
                    }
                    if (arg instanceof HttpServletResponse) {
                        return "HttpServletResponse";
                    }
                    if (arg instanceof MultipartFile) {
                        MultipartFile file = (MultipartFile) arg;
                        return String.format("MultipartFile[name=%s, size=%d]",
                                file.getOriginalFilename(), file.getSize());
                    }
                    if (arg instanceof Collection) {
                        Collection<?> coll = (Collection<?>) arg;
                        if (coll.size() > 10) {
                            return "Collection[size=" + coll.size() + "]";
                        }
                    }
                    return arg;
                })
                .toArray();
    }
}

六、核心知识点逐条拆解

1. 三个关键注解

注解 作用
@Aspect 声明这是一个切面类
@Component 把切面交给 Spring 容器管理(否则不生效)
@Pointcut 定义切入点,即「在哪些方法上生效」

2. @Pointcut 的表达式

java 复制代码
@Pointcut("@annotation(com.sxy.trande.annotation.AroundLog)")
  • @annotation(...) 是切入点表达式的一种,表示「标了某个注解的方法」。
  • 括号里要写注解的全限定类名,写错路径就拦截不到。

3. @Before vs @Around

通知类型 时机 能否拿到返回值 能否控制方法执行
@Before 方法执行前
@Around 方法执行前后 ✅(可决定是否放行)
  • @Before 只适合「方法跑之前干点事」,拿不到返回结果。
  • @Around 更强大:通过 joinPoint.proceed() 手动放行,能拿到返回值、统计耗时,所以记录出入参 + 耗时用 @Around

4. 耗时统计的写法

java 复制代码
long startTime = System.currentTimeMillis();
result = joinPoint.proceed(args);          // 关键:执行目标方法
long endTime = System.currentTimeMillis();
long time = endTime - startTime;

joinPoint.proceed(args) 前后各取一次系统时间,差值就是方法执行耗时。

5. 异常不能吞

java 复制代码
catch (Exception e) {
    log.error("doAround日志记录异常,信息为:", e);
    throw e;    // 必须重新抛出
}

日志记录失败不应该影响业务,但业务异常必须原样抛出,否则接口会「假成功」。

6. 参数过滤 filterArgs

直接 JSONUtil.toJsonStr(args) 会踩几个坑:

  • MultipartFile(文件)无法直接序列化,会报错或输出无意义内容
  • HttpServletRequest / HttpServletResponse 序列化会触发连环调用,甚至死循环
  • 超大集合全量打印会刷屏、拖慢性能

所以这里对特殊类型做了「降级处理」,只记录有意义的信息。


七、如何使用

1. 只需在方法上加注解

java 复制代码
@RestController
@RequestMapping("/api/user")
public class UserController {

    // 只记录入参(方法执行前)
    @BeforeLog
    @PostMapping
    public Result<Void> add(@RequestBody User user) {
        userService.save(user);
        return Result.ok();
    }

    // 记录入参 + 出参 + 耗时(推荐)
    @AroundLog
    @GetMapping("/list")
    public Result<List<User>> list() {
        return Result.ok(userService.list());
    }
}

2. 控制台输出效果

调用 /api/user/list 后:

text 复制代码
=====================================
请求地址:http://localhost:8080/api/user/list
请求方式:GET
请求类方法:Result com.sxy.trande.controller.UserController.list()
请求方法参数:[]
返回报文:{"code":200,"data":[...],"msg":"success"}
处理耗时:32ms
=====================================

八、常见问题与避坑

1. 切面不生效?

按顺序排查:

  • 切面类有没有加 @Component(最常漏)
  • 有没有加 @Aspect
  • 是否引入了 aspectjweaver / spring-boot-starter-aop
  • @Pointcut 里的注解全限定名是否写对
  • 目标方法是否被 Spring 管理(比如是自己 new 出来的对象调用,AOP 不生效)

2. 方法内部调用不生效?

AOP 是基于代理 的,只有「通过 Spring 代理对象调用」才会触发切面。如果一个类内部自己调用自己的方法(this.method()),绕过了代理,切面不会生效。

3. 序列化报错?

  • 检查参数里是否有 MultipartFileHttpServletRequest 等特殊对象(本方案已过滤)
  • 某些对象包含循环引用时,JSONUtil 可能报错,可针对性地在 filterArgs 里加处理

4. 日志太敏感?

入参里可能包含密码、手机号等敏感信息。生产环境建议在 filterArgs 或序列化时做脱敏 处理,例如对 passwordphone 字段打码。

5. getRequestAttributes() 返回 null?

只有在 Web 请求线程内 才会拿到 RequestAttributes。如果是定时任务、异步线程里触发切面,RequestContextHolder.getRequestAttributes() 会是 null,需要判空处理。


九、总结

本文通过「自定义注解 + AOP 切面」实现了接口日志的零侵入记录:

  1. 两个注解@BeforeLog(只记入参)、@AroundLog(记入参 + 出参 + 耗时)
  2. 一个切面LogAspect,负责取请求、序列化参数、过滤特殊类型、统一输出
  3. 核心价值:业务代码保持干净,日志逻辑集中维护,一处修改全局生效

这套代码可以直接复制到项目里使用,也可以根据业务需求扩展------比如日志入库、异步落库、敏感信息脱敏、按方法名定制日志级别等。

完整项目代码已开源思路,欢迎交流讨论。如果你觉得有帮助,点个赞支持一下~


作者: 洁心未眠

技术栈: Spring Boot 3 + MyBatis-Plus + Hutool + Lombok + AspectJ

相关推荐
vx_Biye_Design27 分钟前
springboot游泳馆系统93765-计算机课程设计、毕业设计
java·javascript·spring boot·后端·python·spring·课程设计
名字还没想好☜30 分钟前
Java NIO ByteBuffer 实战:flip/clear/compact 三个绕晕人的方法与 position/limit 心智模型
java·开发语言·后端·spring·nio
Wang's Blog1 小时前
Java框架快速入门: Spring Security+OAuth2之短信服务多供应商动态切换(阿里云与LeanCloud)
java·spring·阿里云
Doubbbbbbble云1 小时前
区间合并问题的常见算法模式与优化思路4
java·数据结构·算法
小溪学编程1 小时前
AQS 原理详解:从 CLH 队列到 ReentrantLock 的实现
java·开发语言
专业程序开发源1 小时前
springbootLivehouse票务系统-计算机课程设计、毕业设计
vue.js·spring boot·后端·python·django·课程设计·pygame
蜗牛互联网1 小时前
语音AI开始边听边说,改变的不只是响应速度
java·人工智能·后端·语音识别
卓怡学长1 小时前
w233基于vue和springboot的宠物管理系统的设计与实现
java·vue.js·spring boot·spring·intellij-idea
我命由我123452 小时前
人脸识别 - 去重时间窗口
java·开发语言·python·学习·java-ee·学习方法·python3.11
逆境不可逃2 小时前
Pi Agent 学习笔记:一次提问背后的工具调用循环
java·笔记·学习