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)

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                 # 日志级别与输出格式


相关推荐
毕设源码-邱学长2 小时前
【开题答辩全过程】以 二手车交易系统的设计与实现为例,包含答辩的问题和答案
java·eclipse
梵得儿SHI3 小时前
Java IO 流深度解析:对象流与序列化机制(ObjectInputStream/ObjectOutputStream)
java·开发语言·rpc·序列化·对象流·对象与字节流的转换·java对象流
百炼成神 LV@菜哥3 小时前
记类成员变量 vs 方法中的变量
java·开发语言
せいしゅん青春之我3 小时前
【JavaEE初阶】网络经典面试题小小结
java·网络·笔记·网络协议·tcp/ip·java-ee
Aevget3 小时前
「Java EE开发指南」如何用MyEclipse设置Java项目依赖项属性?
java·ide·java-ee·eclipse·myeclipse
南♡黎(・ิϖ・ิ)っ3 小时前
JavaEE初阶,文件IO(2)
java·笔记·java-ee
学习编程的Kitty3 小时前
JavaEE初阶——多线程(4)线程安全
java·开发语言·jvm
sheji34163 小时前
【开题答辩全过程】以 多媒体素材管理系统为例,包含答辩的问题和答案
java·eclipse
成钰3 小时前
设计模式之抽象工厂模式:最复杂的工厂模式变种
java·设计模式·抽象工厂模式