annotation-logging-guide
基于注解的日志记录实现指南(Java / Spring Boot + AOP)
本文档提供一套“注解 + AOP”的实现方案,包含设计要点、依赖、核心代码、配置、使用方式、扩展与常见问题。复制本页即可在你的项目中落地;文末附完整示例结构建议。
完整代码参考gitee地址: https://gitee.com/xizhyu66/log-annotation
1. 目标与设计原则
目标
- 通过
@Loggable注解,精确控制哪些方法/类需要记录日志。 - 统一记录:入参(可脱敏)、出参、耗时、异常、traceId。
- 低侵入:对业务代码改动最小;可逐步接入。
- 易扩展:支持自定义脱敏策略、JSON/控制台输出、接入链路追踪。
设计原则
- AOP
@Around环绕通知收集上下文。 - 使用 SLF4J + Logback 输出;MDC 放置
traceId。 - 对大对象/敏感数据做安全处理,避免日志爆量或泄露。
2. 依赖(Maven)
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId>
</dependency>
建议 Spring Boot 3.x,JDK 17+。
3. 定义注解 @Loggable
package com.example.logging;import java.lang.annotation.*;@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Loggable {/** 是否记录入参 */boolean logArgs() default true;/** 是否记录返回值 */boolean logResult() default true;/** 业务标签,便于筛选 */String tag() default "";
}
- 可作用于类或方法;方法优先级高于类。
4. 生成 traceId(可选但强烈推荐)
package com.example.logging;import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;import java.io.IOException;
import java.util.UUID;@Component
public class TraceIdFilter extends OncePerRequestFilter {public static final String TRACE_ID = "traceId";@Overrideprotected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)throws ServletException, IOException {String traceId = UUID.randomUUID().toString().replace("-", "");MDC.put(TRACE_ID, traceId);try {response.setHeader("X-Trace-Id", traceId);chain.doFilter(request, response);} finally {MDC.remove(TRACE_ID);}}
}
- 每次 HTTP 请求产生独立
traceId,通过X-Trace-Id返回到客户端。非 Web 场景可在调用入口(如 MQ/定时任务)手动放入/清理MDC。
5. 日志切面 LoggingAspect
package com.example.logging;import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.IntStream;@Aspect
@Component
public class LoggingAspect {private static final Logger log = LoggerFactory.getLogger(LoggingAspect.class);private static final String[] SENSITIVE_KEYS = {"password", "secret", "token", "accessToken", "refreshToken","authorization", "auth", "passwd", "pwd", "creditCard"};@Around("@annotation(com.example.logging.Loggable) || @within(com.example.logging.Loggable)")public Object around(ProceedingJoinPoint pjp) throws Throwable {long start = System.currentTimeMillis();MethodSignature sig = (MethodSignature) pjp.getSignature();Method method = sig.getMethod();Loggable ann = method.getAnnotation(Loggable.class);if (ann == null) ann = pjp.getTarget().getClass().getAnnotation(Loggable.class);String methodName = sig.getDeclaringType().getSimpleName() + "." + method.getName();String tag = ann != null ? ann.tag() : "";boolean logArgs = ann == null || ann.logArgs();boolean logResult = ann == null || ann.logResult();Map<String, Object> argMap = buildArgsMap(sig.getParameterNames(), pjp.getArgs(), logArgs);String traceId = MDC.get("traceId");log.info("➡️ Enter {} tag={} traceId={} args={}", methodName, tag, traceId, argMap);Object result = null;Throwable error = null;try {result = pjp.proceed();return result;} catch (Throwable t) {error = t;throw t;} finally {long cost = System.currentTimeMillis() - start;if (error == null) {if (logResult) {log.info("✅ Exit {} traceId={} cost={}ms result={}", methodName, traceId, cost, safeToString(result));} else {log.info("✅ Exit {} traceId={} cost={}ms", methodName, traceId, cost);}} else {log.error("❌ Error {} traceId={} cost={}ms ex={} msg={}", methodName, traceId, cost,error.getClass().getSimpleName(), error.getMessage(), error);}}}private Map<String, Object> buildArgsMap(String[] names, Object[] values, boolean logArgs) {Map<String, Object> map = new HashMap<>();if (!logArgs) return map;if (names == null || values == null) return map;IntStream.range(0, Math.min(names.length, values.length)).forEach(i -> {String name = names[i];Object value = values[i];if (value == null) { map.put(name, null); return; }// 避免打印大对象/不安全类型if (value instanceof org.springframework.web.multipart.MultipartFile) { map.put(name, "[MultipartFile]"); return; }if (value instanceof jakarta.servlet.http.HttpServletRequest) { map.put(name, "[HttpServletRequest]"); return; }if (value instanceof jakarta.servlet.http.HttpServletResponse) { map.put(name, "[HttpServletResponse]"); return; }// 按名称启发式脱敏if (isSensitiveKey(name)) { map.put(name, "******"); return; }// 常见 Header 容器脱敏if (value instanceof HttpHeaders headers) {HttpHeaders masked = new HttpHeaders();headers.forEach((k, v) -> masked.put(k, isSensitiveKey(k) ? Arrays.asList("******") : v));map.put(name, masked);return;}map.put(name, safeToString(value));});return map;}private boolean isSensitiveKey(String key) {String k = key == null ? "" : key.toLowerCase();for (String s : SENSITIVE_KEYS) if (k.contains(s.toLowerCase())) return true;return false;}private String safeToString(Object obj) {if (obj == null) return "null";try { return String.valueOf(obj); }catch (Throwable t) { return obj.getClass().getName() + "@(toString-error)"; }}
}
6. 应用与示例
控制器(类级别开启日志)
package com.example.demo.web;import com.example.logging.Loggable;
import com.example.demo.service.DemoService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;@RestController
@Loggable(tag = "controller")
public class DemoController {private final DemoService demoService;public DemoController(DemoService demoService) { this.demoService = demoService; }@GetMapping("/api/echo")public String echo(@RequestParam String text,@RequestParam(required = false, defaultValue = "secret") String password) {return demoService.echo(text, password);}
}
服务(方法级别详细控制)
package com.example.demo.service;import com.example.logging.Loggable;
import org.springframework.stereotype.Service;@Service
public class DemoService {@Loggable(tag = "biz:echo", logArgs = true, logResult = true)public String echo(String text, String password) {return "echo:" + text;}
}
7. 日志输出配置
application.yml
logging:level:root: INFOcom.example: INFOpattern:console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger - traceId=%X{traceId} - %msg%n"
若接入 ELK/可观测平台,建议改用 JSON 编码(见 §9 扩展)。
示例输出
➡️ Enter DemoService.echo tag=biz:echo traceId=7d2... args={text=hello, password=******}
✅ Exit DemoService.echo traceId=7d2... cost=2ms result=echo:hello
8. 最佳实践
- 只打必要的日志:对高频方法进行抽样或关闭出参日志以减少 I/O。
- 限制对象深度:大对象建议输出关键字段或 ID;避免序列化巨型集合。
- 明确敏感键:根据安全要求扩展
SENSITIVE_KEYS;如需字段级更精确控制,可引入@Masked注解。 - 链路一致性:跨线程/异步时手动透传
MDC(使用TaskDecorator或自定义包装器)。 - 错误场景:异常堆栈务必打出(
log.error(..., e)),避免只输出 message。
9. 扩展方向
- JSON 日志:使用
logstash-logback-encoder输出 JSON,便于 ELK / Loki 检索与聚合。 - 动态开关:配合配置中心动态调整某些包或方法的日志级别。
- 注解参数:可追加
sampleRate()、maxArgLength()等属性,灵活控制产出量。 - 统一出参包装:结合响应包装器/拦截器统一补充
traceId。
10. 常见问题(FAQ)
Q: 为什么我的参数名是 arg0/arg1?
A: 需要在编译器开启参数名保留(Maven maven-compiler-plugin 增加 <parameters>true</parameters>),或通过 @RequestParam 显式命名。
Q: 非 HTTP 场景如何拿到 traceId?
A: 在任务入口(定时任务、MQ 监听)生成并放入 MDC,执行完成后记得清理。
Q: 大文件/流如何处理?
A: 切面中直接用占位符 [MultipartFile] 或 [InputStream],避免读流。
11. 参考的 pom.xml 片段(含编译参数)
<properties><java.version>17</java.version>
</properties><build><plugins><plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId><version>3.11.0</version><configuration><source>${java.version}</source><target>${java.version}</target><release>${java.version}</release><parameters>true</parameters></configuration></plugin></plugins>
</build>
12. 目录建议(落地到你的项目)
src/main/java
└─ com/example├─ logging│ ├─ Loggable.java # 注解│ ├─ LoggingAspect.java # 切面│ └─ TraceIdFilter.java # 请求级 traceId(可选)└─ demo├─ web/DemoController.java└─ service/DemoService.java
src/main/resources
└─ application.yml # 日志级别与输出格式
完
