AI摘要

本文通过作者阅读Logback源码的经历,详细阐述了如何从对Logback一无所知到理解其内部机制,并最终实现自定义日志组件的过程。文章首先解释了阅读源码的必要性,然后介绍了Logback的三层架构,接着通过跟踪日志打印的全过程找到了自定义日志输出的切入点。作者动手实现了一个“上下文ID”编码器,并将其集成到Logback配置中。最后,文章讨论了如何让组件可配置,并分享了阅读源码的价值。
这篇博客记录了我如何通过阅读Logback源码,从“只会复制粘贴配置”到真正理解其内部机制,并最终打造属于自己的日志组件的过程。这不是一篇速成指南,而是一个工程师与开源框架的深度对话。

第一步:为什么要读源码?因为文档不会告诉你这些

我最初的需求很简单:在我们的日志中自动添加上下文ID,便于追踪一个请求在整个调用链中的流转。现有的MDC(Mapped Diagnostic Context)需要手动设置,我漏了,同事也漏了。

我想,能不能让Logback自动为每个请求生成一个唯一ID,并自动输出到每条日志里?官方文档只告诉我怎么用,没告诉我怎么改。

于是,我决定读源码。不是因为我有受虐倾向,而是因为当你需要框架做它原本不做的事情时,源码是唯一能告诉你“为什么不能”和“怎样才能”的地方

第二步:从“用”到“看”——理解Logback的三层架构

我克隆了Logback的GitHub仓库,在IDE中打开了这个项目。第一个感觉是:比我想象的清晰。

Logback的核心架构分为清晰的三层,这让我想起了很多经典框架:

  1. Logger:我们最熟悉的层面,log.info()的调用者。它决定这条日志是否要记录(级别过滤)。
  2. Appender:决定日志去哪里。控制台、文件、数据库、网络——每个目的地对应一个Appender。
  3. Layout/Encoder:决定日志长什么样。PatternLayout将日志事件格式化成字符串,Encoder(更现代)负责将日志事件编码成字节流。

我平时写的配置,比如:

<appender name="FILE" class="ch.qos.logback.core.FileAppender">
    <encoder>
        <pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n</pattern>
    </encoder>
    <file>app.log</file>
</appender>

就是在实例化一个FileAppender,并给它配置了一个PatternLayoutEncoder

这个认知让我豁然开朗:如果我需要改变日志的“样子”,我应该改Encoder;如果我需要改变日志的“去处”,我应该改Appender

我的需求是改变输出格式(自动添加ID),所以我的战场是Encoder

第三步:潜入深海——跟踪一次日志打印的全过程

为了知道在哪里动手,我必须知道一次log.info("hello")调用,究竟经历了什么。

我在ch.qos.logback.classic.Logger类的buildLoggingEventAndAppend方法打了断点,然后开始了一次奇妙的调用栈旅行:

  1. Logger.info(String msg)被调用。
  2. 经过级别过滤后,创建ILoggingEvent对象(这是日志事件的核心接口,包含了时间、线程、消息、MDC等所有信息)。
  3. 调用callAppenders(ILoggingEvent event),将事件传递给所有关联的Appender。
  4. AppenderBasedoAppend()方法中,我看到了关键逻辑:先调用encoder.encode(event)将事件转换成字节,再写出。

重点在encoder.encode(event) 这就是日志被格式化的地方。我顺藤摸瓜,找到了PatternLayoutEncoder,它内部又委托给PatternLayout

我打开了PatternLayout的源码,看到了那熟悉的%d%thread%msg——没错,就是配置文件里<pattern>标签的内容。它在doLayout(ILoggingEvent event)方法中,将各种“转换词”替换成具体的值。

至此,我找到了切入点:如果我能自定义一个LayoutEncoder,在输出前把自定义的上下文ID塞进去,问题就解决了。

第四步:动手创造——实现一个“上下文ID”编码器

我决定不直接修改PatternLayout,而是用装饰者模式包装它。这样更干净,也符合开闭原则。

我先创建一个简单的上下文管理器(模拟请求链路):

public class TraceContext {
    private static final ThreadLocal<String> traceIdHolder = new ThreadLocal<>();
    
    public static void setTraceId(String id) {
        traceIdHolder.set(id);
    }
    
    public static String getTraceId() {
        String id = traceIdHolder.get();
        return id != null ? id : "N/A";
    }
    
    public static void clear() {
        traceIdHolder.remove();
    }
}

然后,创建我的自定义编码器。我选择继承EncoderBase<ILoggingEvent>,因为它是Encoder接口的骨架实现:

public class TraceIdEncoder extends EncoderBase<ILoggingEvent> {
    // 保留一个对原始编码器的引用,我们将装饰它
    private PatternLayoutEncoder delegateEncoder;
    
    @Override
    public byte[] encode(ILoggingEvent event) {
        // 在原始消息前添加追踪ID
        String traceId = TraceContext.getTraceId();
        
        // 这里有个关键点:我们不能直接修改event对象,因为它是不可变的
        // 所以我们需要创建一个“包装过”的事件
        ILoggingEvent wrappedEvent = new ILoggingEvent() {
            // 委托所有方法给原始event,除了getFormattedMessage()
            @Override
            public String getFormattedMessage() {
                // 在原始消息前加上追踪ID
                return String.format("[TraceId:%s] %s", traceId, event.getFormattedMessage());
            }
            
            // 其他几十个方法全部委托给原始event...
            @Override
            public String getMessage() { return event.getMessage(); }
            
            @Override
            public Object[] getArgumentArray() { return event.getArgumentArray(); }
            
            @Override 
            public Level getLevel() { return event.getLevel(); }
            
            // ... 为了简洁省略其他委托方法
        };
        
        // 使用原始的编码器来编码我们包装后的事件
        return delegateEncoder.getLayout().doLayout(wrappedEvent).getBytes();
    }
    
    @Override
    public byte[] headerBytes() { return delegateEncoder.getLayout().getHeaderBytes(); }
    
    @Override
    public byte[] footerBytes() { return delegateEncoder.getLayout().getFooterBytes(); }
    
    // 必须的setter
    public void setDelegateEncoder(PatternLayoutEncoder delegateEncoder) {
        this.delegateEncoder = delegateEncoder;
    }
}

写完这段代码,我马上发现了问题:ILoggingEvent接口有二十多个方法!如果每个都手动委托,代码会冗长得可怕。而且,我只想修改getFormattedMessage(),其他方法保持原样。

这逼我思考:有没有更优雅的方式?

第五步:优化与反思——寻找更优雅的解决方案

我重新阅读ILoggingEvent的源码,发现它没有提供现成的装饰类。但我在LoggingEvent类中看到了prepareForDeferredProcessing()方法,这暗示了事件对象可能被复用。

安全性考虑:直接修改事件对象是危险的,因为多个Appender可能共享同一个事件实例。这也是为什么ILoggingEvent是不可变的。

我的解决方案虽然笨拙但有效。不过,我意识到也许有更好的方式:不包装事件,而是修改输出结果。

我重新实现了编码器:

public class TraceIdEncoder extends EncoderBase<ILoggingEvent> {
    private PatternLayoutEncoder delegate;
    
    @Override
    public byte[] encode(ILoggingEvent event) {
        // 先让原始编码器正常工作
        String originalLog = delegate.getLayout().doLayout(event);
        
        // 然后在行首插入我们的追踪ID
        String traceId = TraceContext.getTraceId();
        String decoratedLog = String.format("[%s] %s", traceId, originalLog);
        
        return decoratedLog.getBytes(delegate.getCharset());
    }
    
    // 必须实现的其他方法...
    @Override
    public byte[] headerBytes() {
        return delegate.getLayout().getHeaderBytes();
    }
    
    @Override
    public byte[] footerBytes() {
        return delegate.getLayout().getFooterBytes();
    }
    
    @Override
    public void start() {
        if (delegate == null) {
            throw new IllegalStateException("Delegate encoder must be set");
        }
        delegate.start();
        super.started = true;
    }
    
    // setter...
}

这个版本更清晰:先让原始编码器把事件转换成字符串,然后我再加工。虽然效率稍低(多了一次字符串操作),但代码的可读性和可维护性好太多了。

第六步:让它工作——集成到Logback配置中

要让自定义组件被Logback识别,我需要在logback.xml中配置它。但Logback怎么知道我的TraceIdEncoder类呢?

我创建了src/main/resources/META-INF/services/ch.qos.logback.core.spi.Configurator文件吗?不,不需要这么复杂。对于自定义组件,最简单的方式是直接使用全限定类名。

我的logback.xml配置:

<configuration>
    <!-- 定义我们的编码器 -->
    <encoder class="com.mycompany.logging.TraceIdEncoder" name="TRACE_ENCODER">
        <!-- 注入原始的PatternLayoutEncoder作为委托 -->
        <delegateEncoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
        </delegateEncoder>
    </encoder>
    
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <!-- 使用我们自定义的编码器 -->
        <encoder class="com.mycompany.logging.TraceIdEncoder">
            <delegateEncoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
                <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level - %msg%n</pattern>
            </delegateEncoder>
        </encoder>
    </appender>
    
    <root level="INFO">
        <appender-ref ref="STDOUT" />
    </root>
</configuration>

当我启动应用,看到日志输出变成:

[req-12345] 14:30:25.123 [http-nio-8080-exec-1] INFO  com.mycompany.Controller - 用户登录成功

那种成就感,比解决任何业务Bug都要强烈。这不是因为功能复杂,而是因为我真正理解了框架,并让它按我的意愿工作

第七步:更进一步——让组件可配置

但我的组件还比较死板。如果我想让追踪ID的前缀可配置呢?如果我想在特定情况下不输出追踪ID呢?

这就需要让我的组件支持Logback的配置系统。我研究了PatternLayoutEncoder的源码,发现它继承了LayoutWrappingEncoder,而后者又实现了ContextAwareLifeCycle接口。

要让我的组件可配置,我需要:

  1. 添加setter方法,让Logback能注入配置
  2. start()方法中验证配置

我改进了编码器:

public class TraceIdEncoder extends EncoderBase<ILoggingEvent> {
    private PatternLayoutEncoder delegate;
    private String prefix = "TraceId";  // 默认前缀
    
    @Override
    public void start() {
        // 验证委托编码器已设置
        if (delegate == null) {
            addError("No delegate encoder set for TraceIdEncoder.");
            return;
        }
        
        // 启动委托编码器
        delegate.start();
        
        // 验证委托编码器是否成功启动
        if (!delegate.isStarted()) {
            addError("Failed to start delegate encoder in TraceIdEncoder.");
            return;
        }
        
        // 只有所有条件满足时才标记自己为已启动
        super.started = true;
        addInfo("TraceIdEncoder started with prefix: " + prefix);
    }
    
    @Override
    public byte[] encode(ILoggingEvent event) {
        if (!isStarted()) {
            return new byte[0];  // 如果没启动,不输出任何内容
        }
        
        String original = delegate.getLayout().doLayout(event);
        String traceId = TraceContext.getTraceId();
        
        // 如果追踪ID不存在,可选择跳过
        if ("N/A".equals(traceId) && skipIfMissing) {
            return original.getBytes(delegate.getCharset());
        }
        
        return String.format("[%s:%s] %s", prefix, traceId, original)
                .getBytes(delegate.getCharset());
    }
    
    // 配置属性的setter
    public void setDelegateEncoder(PatternLayoutEncoder delegate) {
        this.delegate = delegate;
    }
    
    public void setPrefix(String prefix) {
        this.prefix = prefix;
    }
    
    public void setSkipIfMissing(boolean skipIfMissing) {
        this.skipIfMissing = skipIfMissing;
    }
    
    // ... 其他方法
}

现在配置可以更灵活:

<encoder class="com.mycompany.logging.TraceIdEncoder">
    <prefix>ReqID</prefix>
    <skipIfMissing>true</skipIfMissing>
    <delegateEncoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
        <pattern>%msg%n</pattern>
    </delegateEncoder>
</encoder>

最后的思考:阅读源码的价值

这次经历给我的最大启发是:阅读优秀开源框架的源码,不是为了炫技,而是为了获得“自由”

  1. 从“使用者”到“理解者”的转变:我不再害怕Logback的复杂配置,因为我知道每一行配置对应源码中的哪个类、哪个方法。
  2. 调试能力飞跃:当日志出现奇怪问题时,我能直接跟踪到Logback内部,看看到底是哪个环节出了问题。
  3. 设计模式的生动教材:我在Logback中看到了清晰的职责分离(Logger、Appender、Layout)、装饰者模式(各种Wrapper)、工厂模式等,这比任何设计模式书籍都生动。
  4. 创造的能力:现在,当我有特殊需求时(比如将日志同时输出到控制台和WebSocket,或者实现一个智能的日志采样器),我知道从哪里开始,如何与Logback的现有架构集成。

最重要的是,我不再是框架的“囚徒”。当框架不能满足我的需求时,我有能力去扩展它、改造它,甚至从它的设计中获得灵感,打造属于自己的工具。

这,就是阅读源码最大的回报:从被动接受到主动创造的自由

版权声明 ▶ 本网站名称:黄磊的博客
▶ 本文标题:Logback源码解读:如何自定义一个强大的日志组件?
▶ 本文链接:https://www.huangleicole.com/backend-related/87.html
▶ 转载本站文章需要遵守:商业转载请联系站长,非商业转载请注明出处!!

如果觉得我的文章对你有用,请随意赞赏