封装 API 请求日志切面
概述
在日常开发中,排查接口问题时经常需要知道:谁调了哪个接口、传了什么参数、返回了什么结果、耗时多少。如果每个接口都手写日志代码,既重复又容易遗漏。我们可以把这段逻辑抽成一个切面(AOP),配合自定义注解,做到零侵入、即插即用。
本文从零开始,一步步搭建一个 API 请求日志的 Spring Boot Starter。
最终效果:在 Controller 方法上加一个 @ApiOperationLog("功能描述"),切面自动打印请求入参、出参和耗时。
项目结构
在基础设施模块中新建一个 biz-operationlog 子模块:

我们有两个模块,各司其职:
- A 模块(
idle-store-common) :放通用工具类,比如JsonUtils、日期格式常量。为什么放这儿?因为这个项目里不止一个模块要用到 JSON 序列化,每个都写一遍太蠢了。 - B 模块(
lh-spring-boot-starter-biz-operationlog) :放注解、切面、自动配置类,打成 starter。最终效果是,任何人只要在pom.xml里引入 B,就能直接使用@ApiOperationLog注解。
第一步:定义注解
注解本身只做一个"标记",不包含任何执行逻辑。后面 AOP 切面通过扫描这个标记来决定对哪些方法织入日志代码。
java
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
@Documented
public @interface ApiOperationLog {
/**
* API 功能描述
*/
String description() default "";
}
三个元注解的含义
@Retention --- 决定注解保留到哪个阶段:
| 值 | 说明 |
|---|---|
RetentionPolicy.SOURCE |
只在源码中存在,编译后就没了。典型例子:@Override,它的作用就是编译期提醒你"这个方法真的是重写了吗?",编译完就没用了 |
RetentionPolicy.CLASS |
编译到 .class 文件中,但运行时不可见。这是默认值 |
RetentionPolicy.RUNTIME |
运行时还存在,可以通过反射读取。我们做 AOP 必须用这个,因为切面需要在运行时通过反射拿到注解信息 |
@Target --- 决定注解能加在什么地方:
| 值 | 可以加在 |
|---|---|
ElementType.TYPE |
类、接口、枚举 |
ElementType.METHOD |
方法 |
ElementType.FIELD |
字段 |
ElementType.PARAMETER |
方法参数 |
ElementType.CONSTRUCTOR |
构造方法 |
这里我们选 ElementType.METHOD,因为这个注解只应该加在 Controller 方法上,加在别的地方没意义。
@Documented --- 让注解出现在 JavaDoc 中。生成接口文档的时候,被 @ApiOperationLog 标注的方法会在文档里显示这个注解。不是核心功能,删了也不影响运行,但保留着有助于看文档的人知道这个接口会被记录日志。
注解属性的规则
注解属性只支持这些类型:基本类型、String、Class、枚举、注解,以及这些类型的数组。
default 表示默认值,写了的话使用时可以省略这个属性;不写 default,那你每次用这个注解的时候必须给这个属性赋值,否则编译报错。
value 是注解里比较特殊的属性名:
java
// 注解里只有 value 一个属性:
public @interface ApiOperationLog {
String value() default "";
}
// 使用时可以省略属性名,直接传值:
@ApiOperationLog("用户登录") // 等价于 @ApiOperationLog(value = "用户登录")
但是 value 这个单词作为属性名语义不够明确------"值"是什么值?不如 description 直观。可是我们又想保留简写的便利性,不想每次都写 description = "xxx"。怎么办呢?
Java 原生注解没有别名机制,但是 Spring 提供了 @AliasFor,可以让两个属性互为别名:
java
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Documented
public @interface ApiOperationLog {
@AliasFor("description")
String value() default "";
@AliasFor("value")
String description() default "";
}
这样下面三种写法完全等价:
java
@ApiOperationLog("用户登录")
@ApiOperationLog(value = "用户登录")
@ApiOperationLog(description = "用户登录")
注意 :互为别名的两个属性,它们的类型、默认值、语义必须完全一致,不然 Spring 启动时就会报错。
第二步:引入依赖
我们需要两样东西:AOP 和 JSON 序列化。
AOP 依赖放在 B 模块(biz-operationlog),因为切面逻辑在这里:
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
JSON 序列化依赖放在 A 模块(common)。虽然 spring-boot-web 自带 Jackson,但 A 模块并没有引入 spring-boot-web,所以需要自己手动引入:
xml
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- 解决 Jackson 对 Java 8 日期 API(LocalDateTime 等)的序列化支持 -->
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
注意 :以上坐标适用于 Spring Boot 2.x / 3.x。如果未来升级到 Spring Boot 4.x,Jackson 的
groupId会变为tools.jackson.core。
第三步:封装 JSON 工具类
放在 A 模块(common)中,这样所有依赖了 A 的模块都能直接用。
日期格式常量
java
public interface DateConstants {
/**
* 年-月-日 时:分:秒
*/
String Y_M_D_H_M_S_FORMAT = "yyyy-MM-dd HH:mm:ss";
}
JsonUtils
java
public class JsonUtils {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
static {
// 反序列化时,JSON 中有未知字段不报错,直接忽略
OBJECT_MAPPER.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
// 序列化时,空 Bean 不报错,返回 {}
OBJECT_MAPPER.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false);
// JavaTimeModule:Jackson 专门处理 Java 8 日期 API 的模块
JavaTimeModule javaTimeModule = new JavaTimeModule();
// 指定 LocalDateTime 的序列化格式
javaTimeModule.addSerializer(LocalDateTime.class,
new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(DateConstants.Y_M_D_H_M_S_FORMAT)));
// 指定 LocalDateTime 的反序列化格式
javaTimeModule.addDeserializer(LocalDateTime.class,
new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(DateConstants.Y_M_D_H_M_S_FORMAT)));
// 注册配置好的模块
OBJECT_MAPPER.registerModules(javaTimeModule);
}
/**
* 将对象序列化为 JSON 字符串
*/
@SneakyThrows
public static String toJsonString(Object obj) {
return OBJECT_MAPPER.writeValueAsString(obj);
}
}
配置逐项说明
FAIL_ON_UNKNOWN_PROPERTIES = false
反序列化的时候,比如前端传了一个字段 { "nickName": "二狗", "age": 18 },但是你的 Java 类里只有 nickName,没有 age。默认情况下 Jackson 会直接抛异常:"我不认识 age 这个字段!"设成 false 之后,它就睁一只眼闭一只眼,忽略掉多余字段,这在前后端迭代节奏不一致的时候非常实用------前端多加了个字段不会导致后端接口报错。
FAIL_ON_EMPTY_BEANS = false
假设你有这样一个空类:
java
public class EmptyObj {}
默认情况下,Jackson 序列化它的时候会报错,因为"这玩意儿没有任何可序列化的属性啊"。设成 false 之后,直接输出 {},不报错。
JavaTimeModule
Jackson 原生不认识 LocalDateTime、LocalDate、LocalTime 这些 Java 8 新增的时间类型。不识别的后果是啥呢?它会把 LocalDateTime 序列化成这样:
json
[2026, 7, 6, 14, 30, 0, 123456789]
完全看不懂。JavaTimeModule 就是专门来解决这个问题的:
addSerializer--- 控制 Java 对象 → JSON 字符串时,LocalDateTime用什么格式输出addDeserializer--- 控制 JSON 字符串 → Java 对象时,字符串按什么格式解析回LocalDateTime
这里统一用 yyyy-MM-dd HH:mm:ss,国内最常用的日期格式。
常见踩坑 :注意第 30 行,
registerModules里一定要传前面配置好的javaTimeModule变量 ,而不是new JavaTimeModule()。如果你写成后者,等于注册了一个空白的、啥也没配置的新模块,前面辛辛苦苦配的addSerializer/addDeserializer全白费了。
第四步:编写 AOP 切面
切面是核心,它拦截被 @ApiOperationLog 标注的方法,在方法执行前后自动打印日志。
java
@Aspect
@Slf4j
public class ApiOperationLogAspect {
/**
* 切点:所有标注了 @ApiOperationLog 的方法
*/
@Pointcut("@annotation(com.lh.framework.biz.operationlog.aspect.ApiOperationLog)")
public void apiOperationLog() {}
/**
* 环绕通知
*/
@Around("apiOperationLog()")
public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
long startTime = System.currentTimeMillis();
// 被调用的类名和方法名
String className = joinPoint.getTarget().getClass().getSimpleName();
String methodName = joinPoint.getSignature().getName();
// 入参转 JSON 字符串
Object[] args = joinPoint.getArgs();
String argsJsonStr = Arrays.stream(args)
.map(JsonUtils::toJsonString)
.collect(Collectors.joining(", "));
// 从注解中取出描述信息
String description = getApiOperationLogDescription(joinPoint);
log.info("====== 请求开始: [{}], 入参: {}, 请求类: {}, 请求方法: {} ================================== ",
description, argsJsonStr, className, methodName);
// 执行真正的业务方法
Object result = joinPoint.proceed();
long executionTime = System.currentTimeMillis() - startTime;
log.info("====== 请求结束: [{}], 耗时: {}ms, 出参: {} ================================== ",
description, executionTime, JsonUtils.toJsonString(result));
return result;
}
/**
* 从 @ApiOperationLog 注解中提取 description
*/
private String getApiOperationLogDescription(ProceedingJoinPoint joinPoint) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
ApiOperationLog apiOperationLog = method.getAnnotation(ApiOperationLog.class);
return apiOperationLog.description();
}
}
关键点说明
| 代码 | 说明 |
|---|---|
@Pointcut("@annotation(...)") |
声明切点:所有加了 @ApiOperationLog 的方法都会被这个切面拦截 |
@Around |
环绕通知,可以在方法执行前后插入自己的逻辑,还能拿到返回值 |
joinPoint.getArgs() |
拿到请求入参,返回的是 Object[],因为方法可能有多个参数 |
joinPoint.proceed() |
执行真正的 Controller 方法,返回的就是接口的响应结果 |
joinPoint.getTarget().getClass().getSimpleName() |
获取被代理的原始类名。不能用 joinPoint.getSignature().getDeclaringType(),因为那个拿到的是接口名 |
为什么入参、出参要转成 JSON 字符串?
假设你的接口入参是一个 User 对象,如果你直接 log.info("{}", user),而 User 类又没重写 toString(),那打出来的日志长这样:
com.lh.idlestore.auth.controller.User@7a81197d
除了一串内存地址什么也看不出来,没法排查问题。转成 JSON 之后:
json
{"nickName": "二狗", "createTime": "2026-07-06 14:30:00"}
字段名、字段值、嵌套结构一目了然。
⚠️ 实际生产环境中需要做参数过滤和敏感字段脱敏。日志中不应直接输出密码、Token、身份证号、手机号等敏感信息,也不适合输出文件流或超大对象。本文为了聚焦核心流程省略了这层处理。
第五步:封装成 Starter(自动配置)
前面切面类写好了,但问题是:别的模块怎么用?
假设现在有两个模块:
- A 模块(
idle-store-auth):你的业务模块,Controller 都在这里 - B 模块(
lh-spring-boot-starter-biz-operationlog):切面所在的模块
A 的 Spring Boot 启动类在 com.lh.idlestore.auth 包下,默认只会扫描自己的包及其子目录 。B 模块的包是 com.lh.framework.biz.operationlog,两个包的路径完全不同,A 根本扫描不到 B 里面定义的 ApiOperationLogAspect。
可是 A 在 pom.xml 里引入了 B,所以 A 能访问到 B 的类定义(编译没问题),只是 B 的类不会自动注册到 A 的 Spring 容器里。那怎么办?一个土办法是 A 在自己的配置类里手动声明:
java
@Bean
public ApiOperationLogAspect apiOperationLogAspect() {
return new ApiOperationLogAspect();
}
这样确实能行。但问题来了------如果还有 C 模块、D 模块也要用 B,每个都得写一遍这个 @Bean,烦不烦?我们是写轮子的人,不能把这个麻烦抛给用轮子的人。
所以正确的做法是:B 模块自己配置好自动装配,让 Spring Boot 在启动时自动把 B 的 Bean 加载进去。
配置类
在 B 模块里创建 ApiOperationLogAutoConfiguration:
java
@AutoConfiguration
public class ApiOperationLogAutoConfiguration {
@Bean
public ApiOperationLogAspect apiOperationLogAspect() {
return new ApiOperationLogAspect();
}
}
这样 B 自己就把 @Bean 定义好了。但是问题又来了:A 的包扫描还是扫不到这个配置类啊?
注册自动装配
这就轮到 Spring Boot 的约定上场了。Spring Boot 定义了一个默认规则:应用启动时,会去 classpath 下每一个 jar 包的 META-INF/spring/ 目录里,找 org.springframework.boot.autoconfigure.AutoConfiguration.imports 文件,把里面声明的类全部自动加载进容器。
所以我们在 B 模块的 resources 目录下创建这个路径和文件:
resources/
└── META-INF/
└── spring/
└── org.springframework.boot.autoconfigure.AutoConfiguration.imports
文件内容只有一行------就是我们的配置类的全限定名:
text
com.lh.framework.biz.operationlog.config.ApiOperationLogAutoConfiguration
这样整个链路就通了:
- A 在
pom.xml引入 B - Spring Boot 启动时,扫描 B 的 jar 包,发现
AutoConfiguration.imports文件 - 加载
ApiOperationLogAutoConfiguration配置类 - 配置类中的
@Bean方法把ApiOperationLogAspect注册到容器 - 切面生效,A 中所有带
@ApiOperationLog的方法都会被拦截
一句话总结
B 通过 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 告诉 Spring Boot:"你扫不到我没关系,我把我需要注册的类名单放在这儿了,你照单全收就行。"
这样任何人想用这个功能,只需要一行依赖:
xml
<dependency>
<groupId>com.lh</groupId>
<artifactId>lh-spring-boot-starter-biz-operationlog</artifactId>
</dependency>
不用写任何 @ComponentScan、@Import、@Bean,引入即用。
使用示例
在 Controller 方法上加注解即可:
java
@RestController
public class UserController {
@PostMapping("/login")
@ApiOperationLog("用户登录")
public Response<User> login(@RequestBody LoginRequest request) {
// 业务逻辑
}
}
日志输出效果:
INFO ====== 请求开始: [用户登录], 入参: {"username":"zhangsan"}, 请求类: UserController, 请求方法: login ==================================
INFO ====== 请求结束: [用户登录], 耗时: 28ms, 出参: {"success":true,"data":{"nickName":"张三"}} ==================================
整个过程最核心的思想是约定优于配置:注解只做标记,切面负责逻辑,starter 负责自动加载。使用者引入依赖即可用,不需要任何额外配置。