封装 API 请求日志切面——从注解定义到 Starter 自动装配

封装 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 标注的方法会在文档里显示这个注解。不是核心功能,删了也不影响运行,但保留着有助于看文档的人知道这个接口会被记录日志。

注解属性的规则

注解属性只支持这些类型:基本类型、StringClass、枚举、注解,以及这些类型的数组。

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 原生不认识 LocalDateTimeLocalDateLocalTime 这些 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

这样整个链路就通了:

  1. A 在 pom.xml 引入 B
  2. Spring Boot 启动时,扫描 B 的 jar 包,发现 AutoConfiguration.imports 文件
  3. 加载 ApiOperationLogAutoConfiguration 配置类
  4. 配置类中的 @Bean 方法把 ApiOperationLogAspect 注册到容器
  5. 切面生效,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 负责自动加载。使用者引入依赖即可用,不需要任何额外配置。

相关推荐
掉鱼的猫1 小时前
把 OpenAPI 接入 Agent Harness:零代码让 Agent 听懂你的 REST API
java·llm·agent
lzhcoder2 小时前
Spring Boot 集成 Kafka:先跑通 Demo,再避开那 80% 的人踩过的坑
java·kafka
plainGeekDev2 小时前
ProGuard → R8
android·java·kotlin
带刺的坐椅2 小时前
把 OpenAPI 接入 Agent Harness:零代码让 Agent 听懂你的 REST API
java·ai·llm·agent·solon·restapi·openapi
༄沐࿆风࿆࿆2 小时前
JavaScript函数完全指南:从基础语法到闭包原理与应用实战
开发语言·javascript
Geek-Chow3 小时前
微服务认证与授权:01 — 概念
java·前端·数据库
KobeSacre3 小时前
DelayQueue 源码
java·开发语言
Wang's Blog3 小时前
JavaWeb快速入门: Filter与Listener核心详解
java·filter·listener
han_hanker3 小时前
重新认识枚举enum
开发语言