封装 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 切面通过扫描这个标记来决定对哪些方法织入日志代码。Retention(RetentionPolicy.RUNTIME)Target({ElementType.METHOD})DocumentedpublicinterfaceApiOperationLog{/** * API 功能描述 */Stringdescription()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是注解里比较特殊的属性名// 注解里只有 value 一个属性publicinterfaceApiOperationLog{Stringvalue()default;}// 使用时可以省略属性名直接传值ApiOperationLog(用户登录)// 等价于 ApiOperationLog(value 用户登录)但是value这个单词作为属性名语义不够明确——值是什么值不如description直观。可是我们又想保留简写的便利性不想每次都写description xxx。怎么办呢Java 原生注解没有别名机制但是 Spring 提供了AliasFor可以让两个属性互为别名Retention(RetentionPolicy.RUNTIME)Target(ElementType.METHOD)DocumentedpublicinterfaceApiOperationLog{AliasFor(description)Stringvalue()default;AliasFor(value)Stringdescription()default;}这样下面三种写法完全等价ApiOperationLog(用户登录)ApiOperationLog(value用户登录)ApiOperationLog(description用户登录)注意互为别名的两个属性它们的类型、默认值、语义必须完全一致不然 Spring 启动时就会报错。第二步引入依赖我们需要两样东西AOP 和 JSON 序列化。AOP 依赖放在 B 模块biz-operationlog因为切面逻辑在这里dependencygroupIdorg.springframework.boot/groupIdartifactIdspring-boot-starter-aop/artifactId/dependencyJSON 序列化依赖放在 A 模块common。虽然spring-boot-web自带 Jackson但 A 模块并没有引入spring-boot-web所以需要自己手动引入dependencygroupIdcom.fasterxml.jackson.core/groupIdartifactIdjackson-core/artifactId/dependencydependencygroupIdcom.fasterxml.jackson.core/groupIdartifactIdjackson-databind/artifactId/dependency!-- 解决 Jackson 对 Java 8 日期 APILocalDateTime 等的序列化支持 --dependencygroupIdcom.fasterxml.jackson.datatype/groupIdartifactIdjackson-datatype-jsr310/artifactId/dependency注意以上坐标适用于 Spring Boot 2.x / 3.x。如果未来升级到 Spring Boot 4.xJackson 的groupId会变为tools.jackson.core。第三步封装 JSON 工具类放在 A 模块common中这样所有依赖了 A 的模块都能直接用。日期格式常量publicinterfaceDateConstants{/** * 年-月-日 时:分:秒 */StringY_M_D_H_M_S_FORMATyyyy-MM-dd HH:mm:ss;}JsonUtilspublicclassJsonUtils{privatestaticfinalObjectMapperOBJECT_MAPPERnewObjectMapper();static{// 反序列化时JSON 中有未知字段不报错直接忽略OBJECT_MAPPER.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,false);// 序列化时空 Bean 不报错返回 {}OBJECT_MAPPER.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS,false);// JavaTimeModuleJackson 专门处理 Java 8 日期 API 的模块JavaTimeModulejavaTimeModulenewJavaTimeModule();// 指定 LocalDateTime 的序列化格式javaTimeModule.addSerializer(LocalDateTime.class,newLocalDateTimeSerializer(DateTimeFormatter.ofPattern(DateConstants.Y_M_D_H_M_S_FORMAT)));// 指定 LocalDateTime 的反序列化格式javaTimeModule.addDeserializer(LocalDateTime.class,newLocalDateTimeDeserializer(DateTimeFormatter.ofPattern(DateConstants.Y_M_D_H_M_S_FORMAT)));// 注册配置好的模块OBJECT_MAPPER.registerModules(javaTimeModule);}/** * 将对象序列化为 JSON 字符串 */SneakyThrowspublicstaticStringtoJsonString(Objectobj){returnOBJECT_MAPPER.writeValueAsString(obj);}}配置逐项说明FAIL_ON_UNKNOWN_PROPERTIES false反序列化的时候比如前端传了一个字段{ nickName: 二狗, age: 18 }但是你的 Java 类里只有nickName没有age。默认情况下 Jackson 会直接抛异常我不认识age这个字段设成false之后它就睁一只眼闭一只眼忽略掉多余字段这在前后端迭代节奏不一致的时候非常实用——前端多加了个字段不会导致后端接口报错。FAIL_ON_EMPTY_BEANS false假设你有这样一个空类publicclassEmptyObj{}默认情况下Jackson 序列化它的时候会报错因为这玩意儿没有任何可序列化的属性啊。设成false之后直接输出{}不报错。JavaTimeModuleJackson 原生不认识LocalDateTime、LocalDate、LocalTime这些 Java 8 新增的时间类型。不识别的后果是啥呢它会把LocalDateTime序列化成这样[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标注的方法在方法执行前后自动打印日志。AspectSlf4jpublicclassApiOperationLogAspect{/** * 切点所有标注了 ApiOperationLog 的方法 */Pointcut(annotation(com.lh.framework.biz.operationlog.aspect.ApiOperationLog))publicvoidapiOperationLog(){}/** * 环绕通知 */Around(apiOperationLog())publicObjectdoAround(ProceedingJoinPointjoinPoint)throwsThrowable{longstartTimeSystem.currentTimeMillis();// 被调用的类名和方法名StringclassNamejoinPoint.getTarget().getClass().getSimpleName();StringmethodNamejoinPoint.getSignature().getName();// 入参转 JSON 字符串Object[]argsjoinPoint.getArgs();StringargsJsonStrArrays.stream(args).map(JsonUtils::toJsonString).collect(Collectors.joining(, ));// 从注解中取出描述信息StringdescriptiongetApiOperationLogDescription(joinPoint);log.info( 请求开始: [{}], 入参: {}, 请求类: {}, 请求方法: {} ,description,argsJsonStr,className,methodName);// 执行真正的业务方法ObjectresultjoinPoint.proceed();longexecutionTimeSystem.currentTimeMillis()-startTime;log.info( 请求结束: [{}], 耗时: {}ms, 出参: {} ,description,executionTime,JsonUtils.toJsonString(result));returnresult;}/** * 从 ApiOperationLog 注解中提取 description */privateStringgetApiOperationLogDescription(ProceedingJoinPointjoinPoint){MethodSignaturesignature(MethodSignature)joinPoint.getSignature();Methodmethodsignature.getMethod();ApiOperationLogapiOperationLogmethod.getAnnotation(ApiOperationLog.class);returnapiOperationLog.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.User7a81197d除了一串内存地址什么也看不出来没法排查问题。转成 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 在自己的配置类里手动声明BeanpublicApiOperationLogAspectapiOperationLogAspect(){returnnewApiOperationLogAspect();}这样确实能行。但问题来了——如果还有 C 模块、D 模块也要用 B每个都得写一遍这个Bean烦不烦我们是写轮子的人不能把这个麻烦抛给用轮子的人。所以正确的做法是B 模块自己配置好自动装配让 Spring Boot 在启动时自动把 B 的 Bean 加载进去。配置类在 B 模块里创建ApiOperationLogAutoConfigurationAutoConfigurationpublicclassApiOperationLogAutoConfiguration{BeanpublicApiOperationLogAspectapiOperationLogAspect(){returnnewApiOperationLogAspect();}}这样 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文件内容只有一行——就是我们的配置类的全限定名com.lh.framework.biz.operationlog.config.ApiOperationLogAutoConfiguration这样整个链路就通了A 在pom.xml引入 BSpring Boot 启动时扫描 B 的 jar 包发现AutoConfiguration.imports文件加载ApiOperationLogAutoConfiguration配置类配置类中的Bean方法把ApiOperationLogAspect注册到容器切面生效A 中所有带ApiOperationLog的方法都会被拦截一句话总结B 通过META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports告诉 Spring Boot“你扫不到我没关系我把我需要注册的类名单放在这儿了你照单全收就行。”这样任何人想用这个功能只需要一行依赖dependencygroupIdcom.lh/groupIdartifactIdlh-spring-boot-starter-biz-operationlog/artifactId/dependency不用写任何ComponentScan、Import、Bean引入即用。使用示例在 Controller 方法上加注解即可RestControllerpublicclassUserController{PostMapping(/login)ApiOperationLog(用户登录)publicResponseUserlogin(RequestBodyLoginRequestrequest){// 业务逻辑}}日志输出效果INFO 请求开始: [用户登录], 入参: {username:zhangsan}, 请求类: UserController, 请求方法: login INFO 请求结束: [用户登录], 耗时: 28ms, 出参: {success:true,data:{nickName:张三}} 整个过程最核心的思想是约定优于配置注解只做标记切面负责逻辑starter 负责自动加载。使用者引入依赖即可用不需要任何额外配置。