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>
说明:
aspectjrt和aspectjweaver是 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. 序列化报错?
- 检查参数里是否有
MultipartFile、HttpServletRequest等特殊对象(本方案已过滤) - 某些对象包含循环引用时,
JSONUtil可能报错,可针对性地在filterArgs里加处理
4. 日志太敏感?
入参里可能包含密码、手机号等敏感信息。生产环境建议在 filterArgs 或序列化时做脱敏 处理,例如对 password、phone 字段打码。
5. getRequestAttributes() 返回 null?
只有在 Web 请求线程内 才会拿到 RequestAttributes。如果是定时任务、异步线程里触发切面,RequestContextHolder.getRequestAttributes() 会是 null,需要判空处理。
九、总结
本文通过「自定义注解 + AOP 切面」实现了接口日志的零侵入记录:
- 两个注解 :
@BeforeLog(只记入参)、@AroundLog(记入参 + 出参 + 耗时) - 一个切面 :
LogAspect,负责取请求、序列化参数、过滤特殊类型、统一输出 - 核心价值:业务代码保持干净,日志逻辑集中维护,一处修改全局生效
这套代码可以直接复制到项目里使用,也可以根据业务需求扩展------比如日志入库、异步落库、敏感信息脱敏、按方法名定制日志级别等。
完整项目代码已开源思路,欢迎交流讨论。如果你觉得有帮助,点个赞支持一下~
作者: 洁心未眠
技术栈: Spring Boot 3 + MyBatis-Plus + Hutool + Lombok + AspectJ