找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

5354

积分

0

好友

746

主题
发表于 2 小时前 | 查看: 2| 回复: 0

网上大部分的AOP日志教程,代码几乎都是一个模子刻出来的:定义一个注解,写一个切面,在方法前后各打一行 log.info。跑通Demo,教程就结束了。

但这类代码到了生产环境几乎毫无用处。线上排查问题时,你需要的远不是“某个方法被调用了”这种模糊信息。你需要的是这次请求的链路ID、调用方IP、传入的具体业务参数、方法执行耗时、异常类型和错误码。教程里那种只记录方法名的日志,在日志平台里搜出来跟没搜一样,根本无法定位问题。

本文从三个真实的线上项目中提炼了AOP日志的实现代码,整理出生产环境真正在用的写法。这三个项目分别是一个通用框架层的日志starter、一个带业务模块分类的中型项目、以及一个包含几十个微服务的大型项目。它们的AOP日志实现各有侧重,合在一起恰好覆盖了生产环境需要考虑的所有关键问题。

生产级AOP日志要解决什么?

先看全局。生产环境的AOP日志需要系统性地解决三个层面的问题:

生产级AOP日志三层架构图

记什么,这由注解设计决定,它定义了日志需要携带哪些业务信息。是只记录方法名,还是必须带上模块编码、事件类型、甚至耗时阈值?

怎么记,这由切面实现决定,它负责日志的生命周期管理和异常处理策略。正常返回和抛出异常时,日志记录有何不同?业务异常和系统异常应该使用什么日志级别?

怎么串,这关乎链路追踪,决定了在跨服务调用时,日志能否被有效关联。一个请求从网关进入,流经3个微服务,最终写入数据库,你是否能用一个唯一的ID把整条链路上的所有日志串联起来?

大多数教程通常只触及第二层的皮毛,而在实际生产中,这三个层面都必须做到位。下面我们就按照这三个层面逐一深入。

注解设计:从空壳到业务语义

教程中的日志注解通常只是一个空壳:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Log {
}

这种注解只能告诉切面“这个方法需要记日志”,至于记什么、如何分类,切面得自己去猜测和硬编码。

在生产环境中,注解设计演化出了几种不同的、更精细的思路。

带业务分类的注解 (@ModuleLog)

其中一个项目使用了 ModuleLog 注解,强制要求标注业务模块和具体事件:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ModuleLog {
    // 模块编码,从枚举取值
    String moduleCode();
    // 模块名称
    String moduleName();
    // 事件编码
    String eventCode();
    // 事件名称
    String eventName();
}

使用时如下所示:

@GetMapping("/getOrderDetail")
@ModuleLog(
    moduleCode = "order",
    moduleName = "订单",
    eventCode = "getOrderDetail",
    eventName = "查询订单详情"
)
public Result<OrderDetailVO> getOrderDetail(...) {
}

moduleCodemoduleName 必须从一个统一的枚举中取值,不允许随意填写字符串。这样做的好处是日志带上了明确的业务语义。在日志平台中,你可以直接按模块进行筛选,也可以为特定模块配置告警规则。例如,当订单模块的异常率突然升高时,运维能第一时间收到通知,而无需从混杂的日志中人工筛查。

带屏蔽控制的注解 (@LogIgnore)

在框架层,还提供了一个 LogIgnore 注解,用于处理参数或返回值过大的情况,避免不必要的性能开销。

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LogIgnore {
    // 忽略入参
    boolean ignoreArgs() default false;
    // 忽略返回结果
    boolean ignoreReturn() default false;
}

有些接口的入参可能是一个庞大的JSON对象,或者返回值包含数千条记录。将这些内容全部序列化成字符串写入日志,会产生巨大的I/O开销,甚至可能拖慢接口响应速度。@LogIgnore 注解就是用来解决这个问题的,在方法上标注后,切面在采集参数时会跳过对应部分的序列化。

带性能阈值的注解 (@TimeLogPrint)

另一个项目采用了不同的思路。它的 TimeLogPrint 注解可以设置一个耗时阈值,只有执行时间超过此阈值的调用才会被记录。

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface TimeLogPrint {
    // 打印名称,默认用方法名
    String value() default "";
    // 耗时阈值(毫秒),0表示总是记录
    long costTime() default 0;
    // 是否打印入参
    boolean args() default true;
    // 是否打印出参
    boolean result() default true;
}

这种设计特别适合高频调用的接口。比如一个正常情况下50毫秒返回的接口,你并不想每次都记录日志,但当其耗时超过200毫秒时,则需要记录下来以便后续排查。costTime 参数正是用于设置这个阈值。

四种注解设计模式对比

将这四种注解设计模式放在一起对比,能更清晰地看出其适用场景:

模式 代表注解 适用场景 核心特点
纯标记型 @Log 框架层自动记录所有Controller入口 零配置,切面自动采集所有可用信息
业务分类型 @ModuleLog 需要按模块、事件进行日志检索和告警 日志自带业务语义,便于分类管理
性能监控型 @TimeLogPrint 高频接口,只关注慢调用和性能瓶颈 支持耗时阈值,超标才记录,减少日志量
选择屏蔽型 @LogIgnore 参数或返回值数据量特别大 可按需关闭入参/出参的序列化,避免I/O瓶颈

在实际项目中,这几种模式并非互斥,经常组合使用。框架层使用 @Log 做兜底,确保每个接口都有基础日志;业务层叠加 @ModuleLog,为日志加上业务分类;对于个别参数特别大的接口,可以再加一个 @LogIgnore 来屏蔽入参序列化。

切面实现:从割裂到统一管控

注解定义好后,切面才是真正干活的“执行者”。

为什么生产环境都选择 @Around?

@Before@After 是两个独立的方法,它们之间无法直接共享状态。如果你想在 @Before 中记录开始时间,在 @After 中计算耗时,就必须将开始时间存储到 ThreadLocal 或成员变量中,引入了额外的复杂度。异常场景的处理则更加麻烦:@AfterThrowing 虽然能拿到异常对象,但它与 @AfterReturning 是互斥的,你无法在一个统一的地方根据“正常返回还是异常抛出”来执行不同的日志处理逻辑。

@Around 将整个方法执行的前、中、后都包裹在一个方法体内,开始时间、返回值、异常对象都是局部变量,天然共享。你可以根据异常类型灵活使用不同的日志级别,可以在 finally 块中进行资源清理,还可以决定是将原始异常重新抛出,还是转换后再抛出。

模板方法模式:职责分离的精髓

框架层的切面代码极其精简,核心逻辑往往只有一行:

@Aspect
public class RestControllerAspect {

    @Pointcut("@within(org.springframework.web.bind.annotation.RestController)")
    public void pointcut() {
    }

    @Around("pointcut()")
    public Object around(ProceedingJoinPoint proceedingJoinPoint)
            throws Throwable {
        return new HttpLogBuilder(proceedingJoinPoint).save().thenReturn();
    }
}

切面本身不包含任何具体的日志逻辑,所有工作都委托给了 HttpLogBuilderHttpLogBuilder 继承自一个抽象的 LogBuilder 类,这里应用了经典的模板方法模式。

LogBuilder 定义了日志记录的完整生命周期骨架:

public abstract class LogBuilder {

    private final ProceedingJoinPoint proceedingJoinPoint;
    private Object result;
    private long beginTime;
    private long finishTime;
    private Throwable throwable;

    // 三个抽象方法,由子类实现具体逻辑
    protected abstract void saveBeforeLog();
    protected abstract void saveAfterLog();
    protected abstract void saveInterruptLog();

    public LogBuilder save() throws Throwable {
        beginTime = System.currentTimeMillis();
        saveBeforeLog();

        try {
            result = proceedingJoinPoint.proceed();
            finishTime = System.currentTimeMillis();
            saveAfterLog();
        } catch (Throwable e) {
            throwable = e;
            finishTime = System.currentTimeMillis();
            saveInterruptLog();
        }

        return this;
    }

    public Object thenReturn() throws Throwable {
        if (null != throwable) {
            throw throwable;
        }
        return result;
    }
}

save() 方法是整个模板的骨架,其执行顺序固定为:记录开始时间 → 调用 saveBeforeLog() → 执行目标方法 → 正常则调用 saveAfterLog(),异常则调用 saveInterruptLog() → 记录结束时间。

这里有一个关键的设计细节值得注意:save()thenReturn() 被拆分成两个独立的方法。save() 负责执行目标方法和记录日志,thenReturn() 则负责处理异常的重新抛出。在 save()catch 块中,并没有直接抛出异常,而是将其存储到成员变量中,等待 thenReturn() 时再抛出。这样做是为了确保,无论目标方法是正常返回还是抛出异常,日志记录的逻辑都一定能执行完毕。如果在 catch 里直接 throw,而 saveInterruptLog() 本身又发生了异常(例如JSON序列化出错),那么原始的业务异常就会被覆盖,导致排查问题时看到的是日志框架的异常,而非真正的业务异常。

框架层根据不同的使用场景,提供了多个 LogBuilder 子类:HttpLogBuilder 处理HTTP请求、GenericLogBuilder 处理通用方法调用,还有针对 Dubbo、RocketMQ、XxlJob 的专用子类。切面只负责选择合适的 Builder,具体的日志格式和处理逻辑全在 Builder 内部。这种职责分离的设计,使得新增一种日志场景变得非常简单:只需编写一个新的 Builder 子类,再注册一个对应的切面即可。

HTTP日志的具体实现 (HttpLogBuilder)

HttpLogBuilder 是最常用的子类,专门处理HTTP请求的日志。它的三个方法实现了完整的请求日志采集:

public class HttpLogBuilder extends LogBuilder {

    private static final Logger LOGGER =
            LoggerFactory.getLogger(HttpLogBuilder.class);

    @Override
    protected void saveBeforeLog() {
        LOGGER.info(
            "url[{}] ip[{}] action[{}:{}] status[invoke] args[{}]",
            HttpLogUtil.getUri(),
            WebUtils.getIP(),
            GenericLogUtil.getClassName(getProceedingJoinPoint()),
            GenericLogUtil.getMethodName(getProceedingJoinPoint()),
            GenericLogUtil.toJsonString(
                GenericLogUtil.getArgs(getProceedingJoinPoint()))
        );
    }

    @Override
    protected void saveAfterLog() {
        LOGGER.info(
            "url[{}] code[{}] action[{}:{}] status[success] cost[{}] result[{}]",
            HttpLogUtil.getUri(),
            HttpLogUtil.getHttpStatus(),
            GenericLogUtil.getClassName(getProceedingJoinPoint()),
            GenericLogUtil.getMethodName(getProceedingJoinPoint()),
            Math.max(getFinishTime() - getBeginTime(), 0),
            GenericLogUtil.toJsonString(
                GenericLogUtil.getReturn(
                    getProceedingJoinPoint(), getLogData()))
        );
    }

    @Override
    protected void saveInterruptLog() {
        if (getThrowable() instanceof BusinessException) {
            BusinessException exception =
                    (BusinessException) getThrowable();
            LOGGER.warn(
                "url[{}] action[{}:{}] status[fail] cost[{}] error[{}] message[{}]",
                HttpLogUtil.getUri(),
                GenericLogUtil.getClassName(getProceedingJoinPoint()),
                GenericLogUtil.getMethodName(getProceedingJoinPoint()),
                Math.max(getFinishTime() - getBeginTime(), 0),
                exception.getCode(),
                exception.getMessage()
            );
            return;
        }

        // 未知异常
        LOGGER.warn(
            "url[{}] action[{}:{}] status[fail] cost[{}] error[internal-error]",
            HttpLogUtil.getUri(),
            GenericLogUtil.getClassName(getProceedingJoinPoint()),
            GenericLogUtil.getMethodName(getProceedingJoinPoint()),
            Math.max(getFinishTime() - getBeginTime(), 0),
            getThrowable()
        );
    }
}

这段代码体现了几个关键的生产级设计决策:

  1. 结构化日志格式:采用了 key[value] 的固定格式。实际输出类似:

    url[/api/order/detail] ip[192.168.1.100] action[OrderController:getOrderDetail] status[invoke] args[{"id":123}]
    url[/api/order/detail] code[200] action[OrderController:getOrderDetail] status[success] cost[45] result[{...}]

    这种格式在 ELK Stack(如Kibana)或 Loki 等日志平台上,可以直接使用正则表达式提取字段并建立索引。你可以轻松写出这样的查询:status:fail AND url:/api/order/*,瞬间筛选出订单模块所有失败的请求。纯文本日志则无法实现这种精确定位。

  2. 异常分级处理BusinessException 代表业务异常(如库存不足、权限不够),代码逻辑正常执行完毕,只是业务规则不满足,这类异常使用 WARN 级别记录即可。参数校验异常 InvalidParamException 同理。只有捕获到未预期的 Exception 才意味着代码存在真正的Bug,需要使用更高的 ERROR 级别进行告警。如果不做区分,告警系统会被大量的业务异常淹没,真正需要关注的系统异常反而被埋没。

  3. 生产环境返回值优化saveAfterLog() 中调用的是 getLogData() 而非直接 getResult()。在 LogBuilder 中,这个方法包含一段逻辑:在生产环境下,它可能返回一个占位符,而不是完整的返回值对象。因为接口返回值经常是庞大的JSON对象,全量序列化到日志中I/O开销过高。通常只在非生产环境(如测试、开发)打印完整返回值,生产环境则只记录请求参数和耗时,以保证性能。

业务层的补充切面 (ModuleLogAspect)

框架层的 RestControllerAspect 会自动拦截所有 @RestController 方法,无需额外注解。业务层可以在此基础上,叠加自己的切面,实现更精细的日志记录。

ModuleLogAspect 就是一个典型的补充切面。它只关注异常场景(正常情况框架层已记录),并且会带上业务模块信息:

@Aspect
@Component
@Slf4j
public class ModuleLogAspect {

    @Around("@annotation(moduleLog)")
    public Object around(ProceedingJoinPoint pjp, ModuleLog moduleLog)
            throws Throwable {
        try {
            return pjp.proceed();
        } catch (InvalidParamException e) {
            // 参数异常不额外记录,直接抛出
            throw e;
        } catch (BusinessException e) {
            logException(pjp, moduleLog, e);
            throw e;
        } catch (Exception e) {
            logException(pjp, moduleLog, e);
            throw e;
        }
    }

    private void logException(
            ProceedingJoinPoint pjp,
            ModuleLog moduleLog,
            Throwable e) {
        Object[] args = pjp.getArgs();
        Object firstArg =
                args != null && args.length > 0 ? args[0] : null;

        StructuredLog.error(log)
                .message(moduleLog.eventName() + "失败")
                .exception(e)
                .moduleCode(moduleLog.moduleCode())
                .moduleName(moduleLog.moduleName())
                .eventCode(moduleLog.eventCode())
                .eventName(moduleLog.eventName())
                .put("param", JSON.toJSONString(firstArg))
                .log();
    }
}

这个切面的设计思路与框架层不同:它不是全量记录的,而是聚焦于异常场景。对于 InvalidParamException 直接跳过(框架层已记录),仅对 BusinessException 和其他 Exception 进行额外记录,并附带模块编码和事件名称。这样,在日志平台中,你可以快速按模块筛选异常,例如:“找出订单模块最近一小时所有的异常日志”。

HTTP请求AOP日志处理流程图

结构化日志:从纯文本到可查询字段

前面的 ModuleLogAspect 中使用了一个 StructuredLog 工具来输出日志。结构化日志是生产环境代码与教程示例之间差异最大的部分之一。

教程中的日志输出通常是这样的:

log.info("方法{}执行完成,参数:{},返回值:{}", methodName, args, result);

这种纯文本日志在控制台查看尚可,但一旦接入 ELK、Loki 等日志平台,你无法直接对“方法名”、“参数”、“返回值”这些信息进行字段级查询。只能进行低效的全文搜索,效率天差地别。

StructuredLog 采用 Builder 模式,构建了一个能够输出带业务字段的 JSON 日志工具:

@Slf4j
public class StructuredLog {
    private final Map<String, Object> fields = new LinkedHashMap<>();
    private final LogLevel level;
    private final Logger logger;
    private String message;
    private Throwable throwable;

    // 静态工厂方法,传入调用方的logger
    public static StructuredLog info(Logger logger) {
        return new StructuredLog(LogLevel.INFO, logger);
    }

    public static StructuredLog error(Logger logger) {
        return new StructuredLog(LogLevel.ERROR, logger);
    }

    // 通用键值对
    public StructuredLog put(String key, Object value) {
        if (key != null && value != null) {
            fields.put(key, value);
        }
        return this;
    }

    // 预设的业务字段快捷方法
    public StructuredLog userId(String userId) {
        return put("userId", userId);
    }

    public StructuredLog shopId(Integer shopId) {
        return put("shopId", shopId);
    }

    public StructuredLog moduleCode(String moduleCode) {
        return put("moduleCode", moduleCode);
    }

    public StructuredLog costTime(Long costTime) {
        return put("costTime", costTime);
    }

    // 输出日志
    public void log() {
        String logContent = buildLogContent();
        switch (level) {
            case ERROR:
                if (throwable != null) {
                    logger.error(logContent, throwable);
                } else {
                    logger.error(logContent);
                }
                break;
            // INFO、WARN、DEBUG同理
        }
    }

    private String buildLogContent() {
        StringBuilder sb = new StringBuilder();
        if (message != null && !message.isEmpty()) {
            sb.append(message).append(" || ");
        }
        // JSON格式输出,便于日志平台解析
        sb.append(JSON.toJSONString(fields));
        return sb.toString();
    }
}

调用示例如下:

StructuredLog.error(log)
    .message("查询订单详情失败")
    .exception(e)
    .moduleCode("order")
    .moduleName("订单")
    .eventCode("getOrderDetail")
    .put("param", JSON.toJSONString(request))
    .log();

输出的日志形如:

查询订单详情失败 || {"moduleCode":"order","moduleName":"订单","eventCode":"getOrderDetail","param":"{\"id\":123}"}

前半部分是可读的文本消息,后半部分是 JSON 格式的结构化字段,两者以 || 分隔。日志平台可以轻松地按 || 进行拆分,并将右侧的 JSON 直接解析成字段建立索引。随后,你就能在 Kibana 或 Grafana 中使用 moduleCode:order AND eventCode:getOrderDetail 这样的条件进行精确查询。

StructuredLog 的另一个设计亮点是:它为常见的业务字段预设了快捷方法(如 userIdshopIdmoduleCodecostTime),同时保留了通用的 put() 方法。预设字段确保了团队内部日志字段命名的一致性,避免了“一人写 userId,另一人写 user_id”的混乱情况。这种一致性在进行日志聚合分析时至关重要。

日志不是写给自己看的,而是写给三个月后凌晨三点被叫起来排查问题的那个同事看的。 结构化的日志格式,加上统一的字段命名规范,能将问题排查效率提升一个数量级。

链路追踪:串联分布式调用的生命线

一个请求进入系统,可能调用3个微服务,执行2次数据库操作,发送1条消息。这些操作分散在不同的服务实例上,日志也记录在不同的日志文件中。如何将它们串联起来?答案就是链路ID (Trace ID)

链路ID的生成与传递

框架层通过一个 HttpLogFilter 在请求入口处统一处理链路ID:

public class HttpLogFilter extends TraceIdHandler implements Filter {

    @Override
    public void doFilter(
            ServletRequest request,
            ServletResponse response,
            FilterChain filterChain)
            throws IOException, ServletException {
        try {
            if (request instanceof HttpServletRequest
                    && response instanceof HttpServletResponse) {
                // 从请求头获取链路ID
                String traceId = ((HttpServletRequest) request)
                        .getHeader("X-Trace-Id");
                // 没有则生成一个新的UUID
                traceId = settingTraceId(traceId);
                // 写入响应头
                ((HttpServletResponse) response)
                        .addHeader("X-Trace-Id", traceId);
            }
            filterChain.doFilter(request, response);
        } finally {
            // 请求结束,清理线程上下文变量
            clearContext();
        }
    }
}

如果上游(例如网关或其他微服务)已经在请求头中携带了 X-Trace-Id,Filter 会直接沿用这个ID;如果没有(说明这是调用链路的起点),则生成一个新的UUID。生成之后,ID会被写入响应头,这样前端在收到响应后也能获取到该链路ID。当用户反馈问题时,只需提供这个ID,后端运维人员即可在日志平台中直接搜索该ID,查看完整的调用链路详情。

三层存储:兼容多日志框架

生成链路ID后,需要将其存入当前线程的上下文中,以便后续所有的日志输出都能自动附带此ID。这项工作由 TraceIdHandler 完成:

public class TraceIdHandler {

    public String settingTraceId(String traceId) {
        if (StringUtils.isBlank(traceId)) {
            traceId = GenericLogUtil.generateTraceId();
        }
        // 存到自定义的上下文里(支持线程池传递)
        LogContext.putTraceId(traceId);
        // 存到SLF4J的MDC里
        MDC.put("traceId", traceId);
        // 存到Log4j2的ThreadContext里
        ThreadContext.put("traceId", traceId);
        return traceId;
    }

    public void clearContext() {
        LogContext.clear();
        ThreadContext.remove("traceId");
        MDC.remove("traceId");
    }
}

链路ID被存储了三份:LogContext、SLF4J MDC、Log4j2 ThreadContext。为什么要存三份?因为不同的日志框架会从不同的地方读取上下文变量。使用 Logback 的项目从 MDC 读取,使用 Log4j2 的项目从 ThreadContext 读取,而业务代码中需要主动获取链路ID时则从 LogContext 读取。三处都设置好,就能确保无论项目使用哪种日志框架,都能正确获取到链路ID。

在日志配置文件(如 logback-spring.xml)中,使用 %X{traceId} 引用这个变量,这样每一行日志输出都会自动附带链路ID,无需在业务代码中手动传递。

TransmittableThreadLocal:解决异步场景链路断裂

LogContext 的内部实现使用了 TransmittableThreadLocal

public class LogContext {
    private static final TransmittableThreadLocal<String> traceIdTL =
            new TransmittableThreadLocal<>();

    public static void putTraceId(String traceId) {
        traceIdTL.set(traceId);
    }

    public static String getTraceId() {
        return traceIdTL.get();
    }

    public static void clear() {
        traceIdTL.remove();
    }
}

这里选择 TransmittableThreadLocal 而非普通的 ThreadLocal,这个选择直接决定了链路ID在异步场景下能否正确传递。

普通的 ThreadLocal 只在当前线程内有效。一旦业务代码中使用了 @Async 或自定义线程池,新开启的子线程中的 ThreadLocal 将是空的,导致链路ID在此处“断裂”。这是生产环境中链路追踪失效最常见的原因之一。排查请求日志时,会发现前半段有 traceId,到了某个异步操作后突然消失,后面的日志全都无法关联。

TransmittableThreadLocal 是阿里开源的一个库(Maven坐标:com.alibaba:transmittable-thread-local),它能够在向线程池提交任务时,自动将父线程的 ThreadLocal 值拷贝到子线程中。配合 TtlExecutors.getTtlExecutorService() 对线程池进行包装,链路ID在 @AsyncCompletableFuture、自定义线程池等异步场景下都能实现无缝传递。

MDCThreadContext 本身不具备这种跨线程传递的能力,因此才需要额外的 LogContext 使用 TransmittableThreadLocal 存储一份。即使在异步线程中 MDC 的值丢失了,也可以从 LogContext 中重新获取链路ID并设置回 MDC

分布式系统链路追踪架构图

自动装配:开箱即用的日志能力

框架层的日志能力通过 Spring Boot 的自动装配机制提供给业务服务。业务服务只需在 pom.xml 中引入对应的 starter 依赖,无需编写任何配置代码,切面、过滤器、拦截器等组件便会全部自动生效。

LogAutoConfiguration 是自动装配的入口:

@Configuration
@EnableConfigurationProperties(LogProperties.class)
@ConditionalOnProperty(
    prefix = "app.log",
    name = "enable",
    havingValue = "true",
    matchIfMissing = true)
public class LogAutoConfiguration {

    // Web应用才注册HTTP相关组件
    @Bean
    @ConditionalOnWebApplication
    public RestControllerAspect restControllerAspect() {
        return new RestControllerAspect();
    }

    // 过滤器注册,优先级设为最高
    @Bean
    @ConditionalOnWebApplication
    public FilterRegistrationBean<HttpLogFilter> httpLogFilterRegistration() {
        FilterRegistrationBean<HttpLogFilter> registration =
                new FilterRegistrationBean<>();
        registration.setFilter(new HttpLogFilter());
        registration.addUrlPatterns("/*");
        registration.setOrder(Ordered.HIGHEST_PRECEDENCE);
        return registration;
    }

    // 引了XxlJob依赖才注册定时任务切面
    @Bean
    @ConditionalOnMissingBean
    @ConditionalOnClass(
        name = "com.xxl.job.core.log.XxlJobFileAppender")
    public XxlJobLogAspect xxlJobLogAspect() {
        return new XxlJobLogAspect();
    }

    // 引了Dubbo依赖才注册RPC切面
    @Bean
    @ConditionalOnMissingBean
    @ConditionalOnClass(
        name = "org.apache.dubbo.config.ServiceConfig")
    public RpcServiceAspect rpcServiceAspect() {
        return new RpcServiceAspect();
    }

    // 引了RocketMQ依赖才注册消息队列切面
    @Configuration
    @ConditionalOnClass(
        name = "com.example.framework.mq.RocketMQProducer")
    protected static class RocketMqConfig {
        @Bean
        @ConditionalOnMissingBean
        public RocketMqProducerAspect rocketMQTemplateAspect() {
            return new RocketMqProducerAspect();
        }

        @Bean
        @ConditionalOnMissingBean
        public RocketMqListenerAspect rocketMqListenerAspect() {
            return new RocketMqListenerAspect();
        }
    }
}

整个配置类的设计思想是按需装配@ConditionalOnWebApplication 确保只有Web应用才会注册HTTP相关组件。@ConditionalOnClass 根据类路径进行判断:项目引入了Dubbo依赖就自动装配Dubbo日志切面,引入了RocketMQ依赖就装配消息队列日志切面,引入了XxlJob依赖就装配定时任务日志切面。没有引入相应依赖的服务,相关的切面根本不会被创建,不会产生任何额外开销。

HttpLogFilter 的注册优先级被设置为 Ordered.HIGHEST_PRECEDENCE,确保它是第一个执行的过滤器。这样,链路ID在所有后续的过滤器、拦截器和切面执行之前就已经设置完毕,后续所有的日志输出都能自动带上这个ID。

配合 META-INF/spring.factories 文件实现自动发现,业务服务引入依赖后,每个Controller方法的调用会自动产生日志,每个请求会自动生成并传递链路ID,异常也会自动按类型分级记录。如果需要添加业务分类,只需在方法上标注 @ModuleLog;如果需要屏蔽大参数,标注 @LogIgnore 即可。

生产级AOP日志落地检查清单

将生产级AOP日志需要考虑的要点整理成下表,可作为项目落地时的检查清单:

检查项 说明
注解是否携带业务语义 应包含模块编码、事件类型等,便于按业务维度检索和告警
切面是否采用@Around @Before+@After 无法共享状态,且难以统一处理异常分级
异常是否分级处理 业务异常用 WARN,系统异常用 ERROR,参数校验异常可考虑跳过
日志格式是否结构化 采用JSON或 key[value] 固定格式,便于日志平台解析成字段
字段命名是否统一 团队内 userIdshopIdtraceId 等字段名应保持一致
链路ID是否用TransmittableThreadLocal 普通 ThreadLocal 在线程池场景下会导致ID丢失
链路ID是否写入响应头 前端获取后可直接提供给后端,用于问题快速定位
大参数是否有屏蔽机制 通过 @LogIgnore 等注解防止大对象序列化拖慢接口响应
生产环境是否屏蔽返回值 返回值数据量通常很大,生产环境可只记录占位符或摘要
是否通过starter自动装配 业务方引入依赖即可,无需手动配置切面和过滤器,提升开发效率

总结

AOP日志在很多人印象中是一个入门级话题,面试时可能只需回答 @AroundProceedingJoinPoint 就过关了。然而在实际生产环境中,它所承载的职责远比面试答案中描述的多得多。注解不再是一个空标记,而是日志的数据模型定义;切面不只是打印两行日志,它需要处理异常分级、性能控制、上下文传递;日志的输出格式直接决定了线上问题排查的效率。

从更宏观的视角看,AOP日志切面是整个系统可观测性 (Observability) 体系的基础设施。日志、指标、链路追踪是可观测性的三大支柱,一个设计良好的AOP切面能同时为这三者提供高质量的数据:切面记录的耗时可以转化为接口响应时间的监控指标;链路ID可以在分布式追踪系统中关联完整的调用链;结构化的异常日志可以直接触发告警。写好这一层,相当于为每个服务入口自动安装了观测探针,业务开发者无需额外操心,服务上线即自带完善的可观测能力。

希望这篇从真实项目中提炼的实践方案,能帮助你构建出真正适用于生产环境的AOP日志体系。更多关于 Java 后端架构、分布式系统DevOps 的深度讨论,欢迎在 云栈社区 交流探讨。




上一篇:信息科主任三级跃迁:从技术骨干到业务伙伴,再到战略核心的路径拆解
下一篇:Spring Bean作用域核心原理剖析:从Singleton到Web作用域及实战陷阱(Spring 6.0)
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-4-20 13:57 , Processed in 0.700614 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表