基于注解的日志记录实现指南(Java / Spring Boot + AOP)
本文档提供一套"注解 + AOP"的实现方案,包含设计要点、依赖、核心代码、配置、使用方式、扩展与常见问题。复制本页即可在你的项目中落地;文末附完整示例结构建议。
完整代码参考gitee地址: https://gitee.com/xizhyu66/log-annotation
1. 目标与设计原则
目标
- 通过
@Loggable注解,精确控制哪些方法/类需要记录日志。 - 统一记录:入参(可脱敏)、出参、耗时、异常、traceId。
- 低侵入:对业务代码改动最小;可逐步接入。
- 易扩展:支持自定义脱敏策略、JSON/控制台输出、接入链路追踪。
设计原则
- AOP
@Around环绕通知收集上下文。 - 使用 SLF4J + Logback 输出;MDC 放置
traceId。 - 对大对象/敏感数据做安全处理,避免日志爆量或泄露。
2. 依赖(Maven)
xml
<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
java
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(可选但强烈推荐)
java
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";
@Override
protected 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
java
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. 应用与示例
控制器(类级别开启日志)
java
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);
}
}
服务(方法级别详细控制)
java
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
yaml
logging:
level:
root: INFO
com.example: INFO
pattern:
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 片段(含编译参数)
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 # 日志级别与输出格式
完