解疑释惑-日志体系之slf4j+logback组合-二

解疑释惑 - 日志体系之 slf4j + logback 组合(二)

引言

上一篇随笔从 slf4j 出发,完整分析了门面设计模式、静态绑定机制、初始化状态机和 MDC 体系。在 slf4j 的 bind() 方法中,最终通过 StaticLoggerBinder.getSingleton() 拿到了日志实现的工厂。这篇就沿着 slf4j 的绑定机制走进 logback 的世界。


一、初始化入口:当 StaticLoggerBinder 醒来

上一篇分析到,slf4j 的 bind() 方法通过 ClassLoader.loadClass() 加载 org/slf4j/impl/StaticLoggerBinder 类,然后调用它的 getSingleton()。在 logback 中,这个类位于 logback-classic 模块中:

// logback-classic: org/slf4j/impl/StaticLoggerBinder.java
public class StaticLoggerBinder implements LoggerFactoryBinder {

    //  不能是 final!
    public static String REQUESTED_API_VERSION = "1.7.16";

    private static StaticLoggerBinder SINGLETON = new StaticLoggerBinder();
    private static Object KEY = new Object();

    static {
        //  ← 类加载时立即触发初始化
        SINGLETON.init();
    }

    private boolean initialized = false;
    private LoggerContext defaultLoggerContext = new LoggerContext();
    private final ContextSelectorStaticBinder contextSelectorBinder =
        ContextSelectorStaticBinder.getSingleton();

    private StaticLoggerBinder() {
        defaultLoggerContext.setName(CoreConstants.DEFAULT_CONTEXT_NAME);
    }

    public static StaticLoggerBinder getSingleton() {
        return SINGLETON;
    }
}

熟悉的配方。和 slf4j 一样,logback 也使用了静态代码块 + 单例的模式。类加载的一瞬间,static 块就会执行 SINGLETON.init()

这里有一个不容易注意到的小细节:REQUESTED_API_VERSION 字段不能使用 final 修饰。这是为什么?这和我们上一篇分析 slf4j 的 versionSanityCheck() 时讨论的内容是同一个问题——常量折叠。如果这个字段是 final 的,Java 编译器在编译时会把字段值内联到所有引用它的地方。那么即使运行时使用不同版本的 jar 包,版本检查也会"虚假通过"。去掉 final,版本号就变成可以在运行时读取的实际字段值,版本兼容性检查才真正有意义。

init() 方法的核心逻辑十分简洁:

void init() {
    try {
        try {
            new ContextInitializer(defaultLoggerContext).autoConfig();
        } catch (JoranException je) {
            Util.report("Failed to auto configure default logger context", je);
        }
        if (!StatusUtil.contextHasStatusListener(defaultLoggerContext)) {
            StatusPrinter.printInCaseOfErrorsOrWarnings(defaultLoggerContext);
        }
        contextSelectorBinder.init(defaultLoggerContext, KEY);
        initialized = true;
    } catch (Exception t) {
        Util.report("Failed to instantiate [" + LoggerContext.class.getName() + "]", t);
    }
}

ContextInitializer.autoConfig() 是整个 logback 的配置入口。它做的事情可以用一句话概括:找到配置文件,然后解析它;找不到就用默认配置兜底

// ContextInitializer.java
public void autoConfig() throws JoranException {
    StatusListenerConfigHelper.installIfAsked(loggerContext);
    URL url = findURLOfDefaultConfigurationFile(true);
    if (url != null) {
        configureByResource(url);
    } else {
        Configurator c = EnvUtil.loadFromServiceLoader(Configurator.class);
        if (c != null) {
            c.setContext(loggerContext);
            c.configure(loggerContext);
        } else {
            BasicConfigurator basicConfigurator = new BasicConfigurator();
            basicConfigurator.setContext(loggerContext);
            basicConfigurator.configure(loggerContext);
        }
    }
}

findURLOfDefaultConfigurationFile() 的查找顺序如下:

  1. 检查系统属性 logback.configurationFile 指定的路径(先尝试作为 URL 解析,失败则降级为 classpath 资源查找,再失败则作为本地文件路径处理——一段健壮的三级降级逻辑)
  2. 检查 classpath 中的 logback-test.xml
  3. 检查 classpath 中的 logback.xml
  4. 以上都没有 → 查找 ServiceLoader<Configurator>(SPI 扩展点)
  5. SPI 也没有 → 使用 BasicConfigurator 兜底

这个查找链路的设计思路和 slf4j 的 bind() 如出一辙——先主动查找,找不到就降级兜底。区别在于,slf4j 找不到绑定实现时会降级到 NOP(吞掉所有日志),而 logback 找不到配置文件时会降级到 BasicConfigurator(输出到控制台)。

说到 BasicConfigurator,它的逻辑极其直白:

// BasicConfigurator.java
public void configure(LoggerContext lc) {
    addInfo("Setting up default configuration.");

    ConsoleAppender<ILoggingEvent> ca = new ConsoleAppender<ILoggingEvent>();
    ca.setContext(lc);
    ca.setName("console");
    LayoutWrappingEncoder<ILoggingEvent> encoder =
        new LayoutWrappingEncoder<ILoggingEvent>();
    encoder.setContext(lc);

    // 等同于 PatternLayout 的默认格式
    TTLLLayout layout = new TTLLLayout();
    layout.setContext(lc);
    layout.start();
    encoder.setLayout(layout);

    ca.setEncoder(encoder);
    ca.start();

    Logger rootLogger = lc.getLogger(Logger.ROOT_LOGGER_NAME);
    rootLogger.addAppender(ca);
}

可以清楚地看到:创建一个 ConsoleAppender,配上一个 TTLLLayout(固定格式的 Layout,等价于 PatternLayout 的 %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n),然后挂到 root logger 上。所以在没有任何配置文件的空项目中,logback 依然能在控制台打出日志——这就是它的兜底机制。

从 slf4j 的视角看:slf4j 在 bind() 中调用 StaticLoggerBinder.getSingleton().getLoggerFactory(),这个方法内部通过 ContextSelectorStaticBinder.getContextSelector().getLoggerContext() 返回 LoggerContext(而非直接暴露 defaultLoggerContext 字段——ContextSelectorStaticBinder 作为中间层存在,是为了适配热部署 / 多 Context 等场景)。slf4j 不关心这个 LoggerContext 是怎么配置出来的——是默认配置、SPI 扩展还是 logback.xml 解析出来的——它只关心能拿到一个 ILoggerFactory

想一想,这个设计有没有让你想起 Spring 的 ApplicationContext?配置文件(application.xml)→ 自动配置(Spring Boot)→ 反射创建 Bean → 依赖注入。logback 的 Joran 引擎某种程度上就是一个"微型 Spring 容器"——专门为日志组件设计的 IoC 容器。这个类比我们在第六节中会深入展开。


二、全局容器:LoggerContext

拿到了一个配置好的 LoggerContext 之后,slf4j 是怎么用的?

还记得上篇中 slf4jLoggerFactory.getLogger(Class<?>) 最终调了谁吗?调用链是这样的:

LoggerFactory.getLogger(Class<?>)
  → getILoggerFactory()  // 返回 StaticLoggerBinder.getLoggerFactory() 的结果
    → LoggerContext.getLogger(name)

所以 LoggerContext 才是那个真正的 ILoggerFactory——slf4j 的接口定义了 getLogger(String) 的行为规范,而 logback 用 LoggerContext 实现了这条规范。

// LoggerContext 的核心字段
public class LoggerContext extends ContextBase
        implements ILoggerFactory, LifeCycle {

    //  根 Logger
    final Logger root;
    //  Logger 缓存
    private Map<String, Logger> loggerCache;
    //  全局过滤器
    private final TurboFilterList turboFilterList;
    //  生命周期监听器
    private List<LoggerContextListener> loggerContextListenerList;
}

LoggerContext 在构造时就创建了 root logger:

public LoggerContext() {
    super();
    this.loggerCache = new ConcurrentHashMap<String, Logger>();
    this.root = new Logger(Logger.ROOT_LOGGER_NAME, null, this);
    //  root 默认 DEBUG 级别
    this.root.setLevel(Level.DEBUG);
    loggerCache.put(Logger.ROOT_LOGGER_NAME, root);
    initEvaluatorMap();
    size = 1;
}

这里 Logger.ROOT_LOGGER_NAME 的值是 "ROOT"。默认级别是 DEBUG。同时 root logger 的 parent 字段是 null,这就是整个 Logger 树的根节点。

getLogger(String name) 的实现,则向我们展示了 Logger 层次结构的构建过程:

public final Logger getLogger(final String name) {
    if (Logger.ROOT_LOGGER_NAME.equalsIgnoreCase(name)) {
        return root;
    }

    // 先查缓存
    Logger childLogger = (Logger) loggerCache.get(name);
    if (childLogger != null) {
        return childLogger;
    }

    // 缓存没有,按 "." 分段逐级创建
    int i = 0;
    Logger logger = root;
    while (true) {
        int h = LoggerNameUtil.getSeparatorIndexOf(name, i);
        String childName;
        if (h == -1) {
            childName = name;
        } else {
            childName = name.substring(0, h);
        }
        i = h + 1;
        synchronized (logger) {
            childLogger = logger.getChildByName(childName);
            if (childLogger == null) {
                childLogger = logger.createChildByName(childName);
                loggerCache.put(childName, childLogger);
                incSize();
            }
        }
        logger = childLogger;
        if (h == -1) {
            return childLogger;
        }
    }
}

举例说明:当第一次调用 getLogger("cn.imadc.app") 时:

  1. 从 root 开始,取第一段 "cn"
  2. root 没有子节点 "cn" → 创建 Logger("cn", root, this)
  3. "cn" 为父,取下一段 "cn.imadc"
  4. "cn" 没有子节点 "cn.imadc" → 创建 Logger("cn.imadc", cnLogger, this)
  5. "cn.imadc" 为父,取最后一段 "cn.imadc.app"
  6. "cn.imadc" 没有子节点 "cn.imadc.app" → 创建 Logger("cn.imadc.app", imadcLogger, this),放入缓存

最终形成 root → cn → cn.imadc → cn.imadc.app 的树形层次结构。Java 包名的天然树形结构与 Logger 的层次继承体系形成了完美对应——这不是巧合,而是 logback 对 Java 包机制的深刻理解。

Logger 类的核心字段:

public final class Logger implements org.slf4j.Logger,
        LocationAwareLogger, AppenderAttachable<ILoggingEvent>, Serializable {

    //  Logger 名称
    private String name;
    //  本 Logger 指定的级别(可为 null)
    transient private Level level;
    //  有效级别(继承自父级)
    transient private int effectiveLevelInt;
    //  父 Logger
    transient private Logger parent;
    //  子 Logger 列表
    transient private List<Logger> childrenList;
    //  Appender 集合
    transient private AppenderAttachableImpl<ILoggingEvent> aai;
    //  是否向上传播
    transient private boolean additive = true;
    //  所属的上下文
    final transient LoggerContext loggerContext;
}

关键点在于 leveleffectiveLevelInt 的区别:

  • level:本 Logger 显式设置的级别,可以为 null
  • effectiveLevelInt:实际生效的级别。如果 levelnull,则向上继承父 Logger 的有效级别,直到 root
public synchronized void setLevel(Level newLevel) {
    if (level == newLevel) { return; }

    // root logger 不允许设为 null——它是整个层次结构的锚点
    if (newLevel == null && isRootLogger()) {
        throw new IllegalArgumentException(
            "The level of the root logger cannot be set to null");
    }

    level = newLevel;
    if (newLevel == null) {
        //  继承父级
        effectiveLevelInt = parent.effectiveLevelInt;
    } else {
        //  使用自己的
        effectiveLevelInt = newLevel.levelInt;
    }

    // 通知所有子 Logger 重新计算有效级别
    if (childrenList != null) {
        for (Logger child : childrenList) {
            child.handleParentLevelChange(effectiveLevelInt);
        }
    }

    // 通知所有 LoggerContextListener(Spring Boot 的 LoggingSystem
    // 等外部组件正是通过这个回调感知日志级别变更的)
    loggerContext.fireOnLevelChange(this, newLevel);
}

handleParentLevelChange() 方法只在子 Logger 的 levelnull 时才更新其有效级别,然后继续向下递归通知。这种级联通知保证了"改一个父节点,所有继承的子节点同步生效"。

值得注意的是 fireOnLevelChange() 这一行——它触发了 LoggerContextListener 的回调,是 Spring Boot 的 LoggingSystem 等外部组件感知日志级别变更的关键机制。没有这一行,运行中通过 JMX 或 Actuator 动态修改日志级别时,外部组件将完全不知情。

另外,childrenList 使用了 CopyOnWriteArrayList——读多写少的最优解。Logger 层次结构在初始化之后极少变动,但每次日志调用时 Logger 都要访问其子节点列表,读操作的频繁程度远超写操作。

有了这些基础设施,一个 Logger 全家都准备好了。那一条日志真正打出来的时候,到底是什么样的流程?我们追一条看看。


三、全链路流转:一条日志的生命之旅

现在,让我们追踪 logger.info("Hello World") 的每一步。

3.1 info() → filterAndLog

public void info(String msg) {
    filterAndLog_0_Or3Plus(FQCN, null, Level.INFO, msg, null, null);
}

注意 FQCN 这个参数——它是 ch.qos.logback.classic.Logger 的全限定类名(Fully Qualified Class Name)。它的作用是给 LoggingEvent 提供调用者位置信息——在计算调用者位置时,logback 需要知道哪些栈帧属于框架代码,从而跳过它们。具体来说,受 FQCN 影响的模式是 %C(调用者类名)、%M(方法名)、%F(文件名)、%L(行号)。

你可能会奇怪方法名为什么叫 filterAndLog_0_Or3Plus。实际上 Logger 类中有三个 filterAndLog 方法:filterAndLog_0_Or3Plus 处理零参数或可变参数的情况(info(msg)info(format, argArray...)),filterAndLog_1 处理单参数的情况(info(format, arg)),filterAndLog_2 处理双参数的情况(info(format, arg1, arg2))。

为什么要分成三个方法?源代码中的注释写得很清楚:

The next methods are not merged into one because of the time we gain by not creating a new Object[] with the params. This reduces the cost of not logging by about 20 nanoseconds.

简单说就是:避免为参数少的调用创建一个新的 Object[]info(String msg) 根本不需要 Object[],但如果不拆分方法,就得额外 new Object[0]——每个日志调用都多分配一个数组,在 Ceki Gülcü 眼里这是不可接受的性能浪费。20 纳秒的节省,乘以每天亿次的日志调用,就是实实在在的收益。

3.2 TurboFilter 优先检查

private void filterAndLog_0_Or3Plus(final String localFQCN,
        final Marker marker, final Level level, final String msg,
        final Object[] params, final Throwable t) {

    // 第一步:TurboFilter 链检查
    final FilterReply decision = loggerContext
        .getTurboFilterChainDecision_0_3OrMore(marker, this, level, msg, params, t);

    if (decision == FilterReply.NEUTRAL) {
        // 第二步:有效级别检查
        if (effectiveLevelInt > level.levelInt) {
            return;
        }
    } else if (decision == FilterReply.DENY) {
        return;  // 被拒绝,直接返回
    }

    // 第三步:构建事件并输出
    buildLoggingEventAndAppend(localFQCN, marker, level, msg, params, t);
}

这里有两级过滤:

第一级:TurboFilter。它是全局级别的过滤器,注册在 LoggerContext 上。FilterReply 是一个三态枚举:

  • DENY:直接丢弃,后续步骤全部跳过
  • NEUTRAL:不表态,继续走常规流程
  • ACCEPT:直接通过,跳过级别检查

第二级:有效级别检查。只有当 TurboFilter 返回 NEUTRAL 时才执行。effectiveLevelInt > level.levelInt 的意思是"当前 Logger 的有效级别比要打的日志级别高"——比如有效级别是 WARN(30000),而要打的是 INFO(20000),那就直接跳过。级别值越大越"严格"。

顺便提一个容易混淆的点:filterAndLog 方法族负责的是过滤(TurboFilter 链 + 级别检查),不负责防重入。防重入发生在 Appender 层面——AppenderBase.doAppend()synchronized + guard 标志阻止同一线程递归调用,UnsynchronizedAppenderBase.doAppend()ThreadLocal 标志实现同样的效果。如果你的 Appender 内部又触发了日志调用(比如 append() 里写了 logger.info()),Logger 层自己是拦不住的,靠的是 Appender 层的 guard。

// Level.java 中的数值定义
//  Integer.MAX_VALUE
public static final Level OFF   = new Level(OFF_INT,    "OFF");
//  40000
public static final Level ERROR = new Level(ERROR_INT,  "ERROR");
//  30000
public static final Level WARN  = new Level(WARN_INT,   "WARN");
//  20000
public static final Level INFO  = new Level(INFO_INT,   "INFO");
//  10000
public static final Level DEBUG = new Level(DEBUG_INT,  "DEBUG");
//  5000
public static final Level TRACE = new Level(TRACE_INT,  "TRACE");
//  Integer.MIN_VALUE
public static final Level ALL   = new Level(ALL_INT,    "ALL");

ERROR(40000) > DEBUG(10000) 为 true → DEBUG 级别被跳过,所以 ERROR 级别不输出 DEBUG 日志。这个设计初看起来反直觉,因为这个"大于"是数值上的大,不是日志详细程度上的大。如果你刚开始也很难记住,可以这样理解:integer 值越大的级别越"沉默",它不会输出比自己"小"的日志。

3.3 构建事件与传播

通过两级过滤后,才真正创建日志事件:

private void buildLoggingEventAndAppend(final String localFQCN,
        final Marker marker, final Level level, final String msg,
        final Object[] params, final Throwable t) {
    LoggingEvent le = new LoggingEvent(localFQCN, this, level, msg, t, params);
    le.setMarker(marker);
    callAppenders(le);
}

callAppenders() 是日志事件最终被分发的入口:

public void callAppenders(ILoggingEvent event) {
    int writes = 0;
    for (Logger l = this; l != null; l = l.parent) {
        writes += l.appendLoopOnAppenders(event);
        if (!l.additive) {
            //  遇到了 additive=false 的 Logger,停止向上
            break;
        }
    }
    if (writes == 0) {
        loggerContext.noAppenderDefinedWarning(this);
    }
}

这里有两个非常重要的设计:

第一,Logger 层次传播。日志事件从当前 Logger 开始,沿父链向上传播,逐级调用每个父 Logger 的 Appender。比如 cn.imadc.app 的 Logger 触发了 info(),事件会依次经过:

  • cn.imadc.app 自己的 Appender
  • cn.imadc 的 Appender
  • cn 的 Appender
  • root 的 Appender

第二,additive 标志。如果某个中间 Logger 设置了 additive=false,传播就会在这一层停止。注意源码中的顺序:writes += l.appendLoopOnAppenders(event) 先执行,然后才检查 !l.additive——也就是说,当前 Logger 自己的 Appender 总是会被触发additive=false 只影响是否继续向上传播到父节点。比如 cn.imadc 设置了 additive=false,事件来自子节点 cn.imadc.app 向上到达 cn.imadc 时,cn.imadc 的 Appender 正常输出,但传播在此停止,不会再上行到 cn 和 root。这个机制用于隔离日志输出——比如你想让某个模块的日志单独输出到一个文件,而不污染主日志文件。

private int appendLoopOnAppenders(ILoggingEvent event) {
    if (aai != null) {
        return aai.appendLoopOnAppenders(event);
    } else {
        return 0;
    }
}

注意 Logger 的 aai 字段:它用到了延迟初始化的惰性策略。在 addAppender() 方法中才创建 AppenderAttachableImpl 实例,而在此之前 aainull(详见源码注释中的线程安全分析)。这样,一个没有 Appender 的 Logger 不会浪费任何内存。

3.4 AppenderAttachableImpl:Appender 的故事

AppenderAttachableImplAppender 的集合管理器:

public class AppenderAttachableImpl<E> implements AppenderAttachable<E> {
    final private COWArrayList<Appender<E>> appenderList =
        new COWArrayList<Appender<E>>(new Appender[0]);

    public int appendLoopOnAppenders(E e) {
        int size = 0;
        final Appender<E>[] appenderArray = appenderList.asTypedArray();
        final int len = appenderArray.length;
        for (int i = 0; i < len; i++) {
            appenderArray[i].doAppend(e);
            size++;
        }
        return size;
    }
}

底层使用了 COWArrayList(Copy-On-Write 的 ArrayList),保证了读操作的无锁并发安全。Logger 在运行期间读取 Appender 的频率极高,而修改 Appender 列表(添加/删除)则少得多——COW 在这种"读多写少"的场景中是最优解。


四、输出体系:Appender 之道

Appender 是 logback 中负责处理日志事件的组件。它的接口设计得非常简洁:

public interface Appender<E> extends LifeCycle, ContextAware, FilterAttachable<E> {
    String getName();
    void setName(String name);
    void doAppend(E event) throws LogbackException;
}

一个 Appender 同时也是一个 LifeCycle(有 start/stop 生命周期);一个 ContextAware(持有 LoggerContext 引用,能写入状态消息);一个 FilterAttachable(可以挂载 Filter 链)。这种多接口继承的设计将正交的能力维度拆解得很干净。

logback 提供了两个 Appender 的抽象基类——AppenderBaseUnsynchronizedAppenderBase。它们的区别在于防重入策略

AppenderBase — synchronized 防重入

abstract public class AppenderBase<E> extends ContextAwareBase implements Appender<E> {
    private boolean guard = false;

    public synchronized void doAppend(E eventObject) {
        //  防止重入
        if (guard) { return; }
        try {
            guard = true;
            if (!this.started) { return; }
            if (getFilterChainDecision(eventObject) == FilterReply.DENY) { return; }
            this.append(eventObject);
        } finally {
            guard = false;
        }
    }
}

UnsynchronizedAppenderBase — ThreadLocal 防重入

abstract public class UnsynchronizedAppenderBase<E>
        extends ContextAwareBase implements Appender<E> {
    private ThreadLocal<Boolean> guard = new ThreadLocal<Boolean>();

    public void doAppend(E eventObject) {
        //  ThreadLocal 检查
        if (Boolean.TRUE.equals(guard.get())) { return; }
        try {
            guard.set(Boolean.TRUE);
            if (!this.started) { return; }
            if (getFilterChainDecision(eventObject) == FilterReply.DENY) { return; }
            this.append(eventObject);
        } finally {
            guard.set(Boolean.FALSE);
        }
    }
}

核心流程完全一致:Guard 检查 → started 检查 → Filter 链检查 → 调用 append()。区别只在于防重入的实现方式。

那为什么要有两个基类?

AppenderBase 使用 synchronized + boolean guardsynchronized 保证了同一时刻只有一个线程能执行 doAppend(),同时保证了 started 等状态字段的跨线程可见性(源码中 started 字段声明为 volatile);guard 则防止同一线程的重入(比如在 append() 方法内部又触发了日志写入)。这种方案线程安全,但 synchronized 有一定开销。

UnsynchronizedAppenderBase 使用 ThreadLocal<Boolean> guard。每个线程独立维护自己的 guard 标志,不需要锁。值得注意的是,它的 started 字段不是 volatile——这是因为 doAppend() 入口有 ThreadLocal guard 保护,且读写时机受控制。源码注释中写道:

using a ThreadLocal instead of a boolean adds 75 nanoseconds per doAppend invocation. This is tolerable as doAppend takes at least a few microseconds on a real appender.

也就是说,ThreadLocal 方案每次调用多 75 纳秒的开销,但对于真正的 IO 操作来说,这显然是微不足道的。而它换来的好处是完全无锁并发

还有一个容易忽略的细节。回到源码中,AppenderBaseUnsynchronizedAppenderBasedoAppend() 都维护了两个计数器——statusRepeatCountexceptionCount——和一个上限常量(AppenderBaseALLOWED_REPEATS = 5UnsynchronizedAppenderBase 的是 3)。当 appender 未启动或写入失败时,这些错误日志最多只上报 5 次(或 3 次)

这其实是一个"防止日志洪泛"的设计。想象一下:如果 appender 因为磁盘满而持续写入失败,每次失败都往 statusManager 塞一条错误日志,这个错误日志本身又要触发写入——那就变成了死循环式的自我轰炸。用计数器限制上报次数,是这个场景下最朴素也最有效的保护机制。我们后面还会在"降级兜底"的主题下再次碰到这个思路。

目前的 Appender 继承体系:

Appender<E>
  ├── AppenderBase<E>          (synchronized guard)
  └── UnsynchronizedAppenderBase<E>  (ThreadLocal guard)
       ├── OutputStreamAppender<E>   → 核心:Encoder + OutputStream
       │    ├── ConsoleAppender<E>   → System.out / System.err
       │    └── FileAppender<E>      → 写文件
       │         └── RollingFileAppender<E>  → 支持滚动策略
       ├── AsyncAppenderBase<E>      → 完全重写 doAppend:BlockingQueue + Worker
       └── ...(SMTP、Socket、Syslog 等网络 Appender)

注意 AsyncAppenderBase 虽然继承自 UnsynchronizedAppenderBase,但它完全重写了 doAppend() 方法——不再调用父类的 append() 模板,而是将事件 offer 到一个 BlockingQueue 中,由独立 Worker 线程异步消费。这是理解"异步"Appender 机制的关键——它用的不是 ThreadLocal guard,而是生产者-消费者模型的并发策略。

大部分真正做 IO 的 Appender 继承自 UnsynchronizedAppenderBase,因为 IO 操作本身耗时就远超 75 纳秒,用 ThreadLocal 换掉锁是完全值得的。

Appender 级别的 Filter 链。回顾 AppenderBase.doAppend()UnsynchronizedAppenderBase.doAppend() 的源码,在 guard 和 started 检查之后都有一行 if (getFilterChainDecision(eventObject) == FilterReply.DENY) { return; }。这是因为 Appender<E> 继承自 FilterAttachable<E>——每个 Appender 自身可以挂载一条 Filter 链。和全局的 TurboFilter 不同,Appender 级别的 Filter 只作用于当前这一个 Appender,不影响事件是否进入其他 Appender。典型的生产实践是:LevelFilter(精确匹配某个级别)配合 ThresholdFilter(设置阈值),实现精确的日志级别路由——比如 ERROR 级别单独走一个 Appender 写文件,INFO 及以上走另一个 Appender 输出到控制台。

4.1 OutputStreamAppender:Encoder + OutputStream

这是所有输出型 Appender 的核心基类。在深入之前需要说明:这里出现的 Encoder 的本质是把日志事件转换为字节数组。它的完整设计将在第五章展开,这里读者只需要知道 encoder.encode(event) 返回 byte[] 即可。

public class OutputStreamAppender<E> extends UnsynchronizedAppenderBase<E> {
    protected Encoder<E> encoder;
    protected final ReentrantLock lock = new ReentrantLock(false);
    private OutputStream outputStream;
    boolean immediateFlush = true;

    protected void subAppend(E event) {
        if (!isStarted()) { return; }
        try {
            // 延迟处理:对 DeferredProcessingAware 的事件提前做准备工作
            if (event instanceof DeferredProcessingAware) {
                ((DeferredProcessingAware) event).prepareForDeferredProcessing();
            }
            byte[] byteArray = this.encoder.encode(event);
            writeBytes(byteArray);
        } catch (IOException ioe) {
            //  遇到 IO 异常,直接进入停止状态
            this.started = false;
            addStatus(new ErrorStatus("IO failure in appender", this, ioe));
        }
    }

    private void writeBytes(byte[] byteArray) throws IOException {
        if (byteArray == null || byteArray.length == 0) return;
        lock.lock();
        try {
            this.outputStream.write(byteArray);
            if (immediateFlush) {
                this.outputStream.flush();
            }
        } finally {
            lock.unlock();
        }
    }
}

关键流程:事件 → Encoder.encode() → byte[] → OutputStream.write()。注意 lock 是在写字节时才加上的(不是整个 doAppend() 都加锁),这个粒度设计很用心——Encoder 阶段无锁,只有写流时才加锁。

immediateFlush 默认值为 true,意味着每次写入都会立刻调用 flush()。这保证了日志不丢失,但会显著降低吞吐量。在生产环境中,通常建议设置为 false,让 JVM 缓冲区来批量处理。

DeferredProcessingAware 是一个标记接口,表示事件对象需要做延迟处理——最典型的例子是日志事件中"调用者信息"的获取(文件名、行号),这是一种代价很高的操作,应当只在真正需要时才执行。

此外,OutputStreamAppender 还有一个历史包袱:

public void setLayout(Layout<E> layout) {
    addWarn("This appender no longer admits a layout as a sub-component, ...");
    addWarn("To ensure compatibility, wrapping your layout in LayoutWrappingEncoder.");
    LayoutWrappingEncoder<E> lwe = new LayoutWrappingEncoder<E>();
    lwe.setLayout(layout);
    lwe.setContext(context);
    this.encoder = lwe;
}

OutputStreamAppender 的设计早期依赖 Layout 来格式化事件,后来在保持向后兼容的前提下将格式化逻辑抽象为 Encoder。如果有人仍然调用 setLayout()(而不是 setEncoder()),logback 会自动用 LayoutWrappingEncoder 包装起来——旧接口不废弃,但用新接口重新实现,保证平滑过渡。


五、架构演进:从 Layout 到 Encoder 的兼容升级

Logback 1.x 的格式化组件经历过一次重要的抽象升级:从 LayoutEncoder

5.1 Layout:面向字符串

最早的日志格式化很简单——把日志事件变成一个漂亮的字符串。

public interface Layout<E> extends ContextAware, LifeCycle {
    //  事件 → 字符串
    String doLayout(E event);
    //  文件头
    String getFileHeader();
    //  展示头
    String getPresentationHeader();
    //  展示尾
    String getPresentationFooter();
    //  文件尾
    String getFileFooter();
    //  内容类型
    String getContentType();
}

TTLLLayout 是默认布局,输出固定格式 %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n。而 PatternLayout 是更通用的版本,允许用户通过 %d%t%logger%msg 等模式自由组合格式。

5.2 Encoder:面向字节

Encoder 是在 Layout 之上的一次抽象升级:

public interface Encoder<E> extends ContextAware, LifeCycle {
    //  编码器头(字节)
    byte[] headerBytes();
    //  事件 → byte[]
    byte[] encode(E event);
    //  编码器尾(字节)
    byte[] footerBytes();
}

区别在于返回值:Layout.doLayout() 返回 StringEncoder.encode() 返回 byte[]。这看起来差别不大,但背后蕴含着重要的语义转换:

Encoder 的设计让 Appender 不再关心"日志是什么格式的字符串",只关心"我要写到流里的字节是什么"。这让 appender 可以更自然地处理二进制格式输出。

5.3 LayoutWrappingEncoder:桥接模式

但如何在升级的同时不打破已有的 Layout 生态?答案就是桥接:

public class LayoutWrappingEncoder<E> extends EncoderBase<E> {
    protected Layout<E> layout;

    public byte[] encode(E event) {
        String txt = layout.doLayout(event);
        //  String → byte[]
        return convertToBytes(txt);
    }

    public byte[] headerBytes() {
        StringBuilder sb = new StringBuilder();
        appendIfNotNull(sb, layout.getFileHeader());
        appendIfNotNull(sb, layout.getPresentationHeader());
        if (sb.length() > 0) {
            sb.append(CoreConstants.LINE_SEPARATOR);
        }
        return convertToBytes(sb.toString());
    }
    // footerBytes() 类似...
}

LayoutWrappingEncoder 内部持有一个 Layoutencode() 方法先调用 layout.doLayout() 得到字符串,再转成字节数组。它就相当于一个适配器,把旧的 Layout 接口适配到了新的 Encoder 接口上。

这层桥接的意义在于:所有基于 Layout 的现有实现(PatternLayoutTTLLLayoutHTMLLayout)都能"零修改"地在新的 Encoder 架构下工作。logback 没有选择粗暴地废弃 Layout,而是用一个薄薄的适配层完成了架构演进。可以对比一下 log4j 1.x 和 logback 的区别——log4j 1.x 只有 Layout(没有 Encoder),而 log4j 2.x 引入了 Layout<T> extends Encoder<LogEvent>,直接把 Layout 变成了 Encoder 的子接口,改了一刀。logback 选择的是用适配器桥接,保持向后兼容。两种思路没有对错,但体现的工程哲学不同:重构的彻底 vs 演进的渐进。

5.4 PatternLayout 与 Converter 链

在实际生产中,最常用的格式化配置是 PatternLayout。它继承自 logback-corePatternLayoutBase,内部维护了一个 Converter 链表,每个模式占位符对应一个 Converter:%d 对应 DateConverter(日期时间)、%t 对应 ThreadConverter(线程名)、%-5level 对应 LevelConverter(日志级别)、%c 对应 LoggerConverter(Logger 名称)、%m 对应 MessageConverter(日志消息体)、%X 对应 MDCConverter(MDC 上下文)、%ex 对应 ThrowableProxyConverter(异常堆栈)。

PatternLayoutBase 在初始化时将模式字符串解析为一棵 Converter 树,然后每次格式化事件时遍历这棵树。这些 Converter 都在 logback-classicpattern 包中实现(包括 ClassicConverter 抽象基类和 TargetLengthBasedClassNameAbbreviator 等辅助工具),这也是为什么 PatternLayout 定义在 logback-classic 模块——它的 Converter 直接依赖 ILoggingEvent 接口,而那是 logback-classic SPI 的一部分。


六、配置内核:Joran XML 解析引擎的工作原理

在第一节中我们看到,ContextInitializer.autoConfig() 找到配置文件后会调用 configureByResource(url)。这个方法创建了 JoranConfigurator,然后调用 doConfigure(url)

Joran 是什么?它是 logback 自研的XML 配置解析框架。从 logback.xml 到运行时的对象图,完整流程是这样的:

logback.xml (文本)
  │
  ├── SAX Parser (org.xml.sax)
  │    └── List<SaxEvent>  (JSON 风格的 XML 事件流)
  │
  ├── Interpreter + EventPlayer
  │    └── 按顺序播放 SaxEvent,匹配 RuleStore 中的规则
  │
  └── Action.begin() / body() / end()
       └── 反射创建对象、设置属性、建立关联
            └── 最终:LoggerContext + Logger + Appender 对象图

6.1 规则的注册

JoranConfigurator 继承自 JoranConfiguratorBase,后者继承自 GenericConfigurator。每个类都在 addInstanceRules() 中注册自己关注的规则:

// JoranConfigurator — logback-classic 特定规则
public void addInstanceRules(RuleStore rs) {
    //  先注册父类的通用规则
    super.addInstanceRules(rs);

    rs.addRule(new ElementSelector("configuration"), new ConfigurationAction());
    rs.addRule(new ElementSelector("configuration/logger"), new LoggerAction());
    rs.addRule(new ElementSelector("configuration/root"), new RootLoggerAction());
    rs.addRule(new ElementSelector("configuration/appender/sift"), new SiftAction());
    rs.addRule(new ElementSelector("*/if"), new IfAction());
    rs.addRule(new ElementSelector("*/if/then"), new ThenAction());
    rs.addRule(new ElementSelector("*/if/else"), new ElseAction());
    rs.addRule(new ElementSelector("configuration/include"), new IncludeAction());
    // ...
}

RuleStore 本质上是一个 ElementSelector → Action 的映射表。ElementSelector 是 XPath 风格的路径表达式,比如 configuration/appender 匹配 <configuration><appender> 元素。

注意到代码中的 NOPAction 吗?<if> 下的 <then><else> 子元素被注册为 NOPAction——也就是"什么都不做"。这不是多余的设计——如果没有这些 NOPAction,<then><else> 内部的元素会被 Joran 的通用规则(如 */param)误匹配,导致配置解析错误。这种"占位屏蔽"策略在基于规则驱动的 XML 解析框架中是一种常见手法。

6.2 Action 的执行模型

每个 Action 对应一个 SAX 事件类。它的三个方法:

public abstract class Action extends ContextAwareBase {
    public abstract void begin(InterpretationContext ic, String name,
            Attributes attributes) throws ActionException;
    public void body(InterpretationContext ic, String body)
            throws ActionException { }
    public abstract void end(InterpretationContext ic, String name)
            throws ActionException;
}

当 SAX 解析到 <configuration> 开始标签时,ConfigurationAction.begin() 被调用。
当解析到 <configuration> 结束标签 </configuration> 时,ConfigurationAction.end() 被调用。
当标签体中有文本内容时(如 <level>DEBUG</level>),body() 被调用。

以最核心的 <appender> 标签为例,AppenderAction 的处理流程大致是:

  1. begin():从 class 属性反射创建 Appender 实例,设置 name,调用 interpretationContext.pushObject(appender) 将 Appender 压入解释上下文的对象栈
  2. 子元素(如 <encoder><file><rollingPolicy> 等)的处理不在 begin() 方法内进行,而是由 SAX 解析器逐元素触发独立的 startElement 事件:Interpreter 在 RuleStore 中查找匹配的 Action,对于 <encoder> 等没有显式注册规则的子元素,Joran 使用 ImplicitAction 机制——具体是 NestedComplexPropertyIANestedBasicPropertyIA——通过 DefaultNestedComponentRegistry 查找对应的组件类型,反射创建并注入到父对象(Appender)中
  3. end():调用 appender.start() 启动 Appender 的生命周期,然后从上下文中弹出 Appender(后续 <appender-ref> 通过名称引用这个已经就绪的 Appender)

嵌套属性注入是 Joran 的核心机制。比如 <appender><encoder><pattern>%msg%n</pattern></encoder></appender>,Joran 会:

  1. 找到 Appender 的 setEncoder() 方法 → 需要创建一个 Encoder 对象
  2. 通过默认嵌套组件注册表为 Encoder 找到合适的实现类
  3. 反射创建 Encoder,调用 setEncoder() 注入到 Appender
  4. 继续解析 Encoder 的子元素,找到 setPattern() 注入到 Encoder

整个流程和 Spring IoC 的 XML 配置解析如出一辙,只是规模小得多。它本质上就是一个专门为 logback 对象图设计的声明式构造器

6.3 为什么 logback 要自研一个配置框架?

你可能会想:直接用 Spring 或者 Commons Configuration 不好吗?答案是零依赖原则。logback 作为日志框架,不能反过来依赖 Spring 这种"重量级"框架来解析自己的配置——否则就会出现"为了输出日志而先加载 Spring"的循环依赖问题。Joran 虽然功能有限,但它足够轻量,且完全自给自足。

另外一个加分项是,Joran 支持条件配置(<if> / <then> / <else>),这依赖于独立的 Janino 表达式求值器——一个可选的编译时依赖,没有它条件配置自动降级为不可用,不影响正常配置的解析。


七、一点感悟

技术层面

1. 分层设计的力量。 logback-corelogback-classic 的职责分离是整个设计中最精彩的部分。logback-core 不依赖任何日志 API,纯做"管道"(Appender、Layout、Encoder、Joran、滚动策略);logback-classic 做"内容"(Logger、Level、LoggingEvent、PatternLayout 的 Converter)。关心输出到哪里的代码和关心输出什么意思的代码,在模块边界上完全解耦。

这也是为什么 logback 能同时支持 logback-classic(SLF4J 实现)和 logback-access(HTTP 访问日志)两个场景——两者的 Event 类型不同,但它们的 Appender、布局、配置机制完全共享。

2. 组合优于继承,接口优于具体类。 Logger 不直接持有 List<Appender>,而是委托给 AppenderAttachableImpl;Appender 不直接处理字节,而是委托给 EncoderOutputStreamAppender 不直接持有 OutputStream,而是通过 ResilientOutputStreamBase 提供恢复能力。每一层的委托对象都可以独立替换、独立测试,这就是面向接口编程相比"一个上帝类搞定一切"的优势所在。

3. 演化与兼容。LayoutEncoder,logback 没有选择硬废弃,而是用 LayoutWrappingEncoder 桥接。向后兼容不是软弱,而是一种工程美德——每个做出兼容努力的框架,都值得我们花时间学习,因为兼容的背后是作者对用户的尊重。

设计层面

1. 性能意识深深刻在基因里。 filterAndLog_0_Or3Plus / filterAndLog_1 / filterAndLog_2 三个方法的存在,childrenList 使用 CopyOnWriteArrayListAppenderAttachableImpl 使用 COWArrayListUnsynchronizedAppenderBase 使用 ThreadLocal 替代 synchronized……这些都是小到 20 纳秒级别的优化,但它们加起来,构成了 logback 以"高性能"立足江湖的底气。Ceki Gülcü 把每次日志调用都看成是"成本",然后想尽一切办法降低它——这是一种精益求精的工程思维。

2. 降级兜底是最可靠的容错策略。 没有配置文件?降级到 BasicConfigurator 输出到控制台。没有 SPI 实现?降级到默认。找不到 StaticMDCBinder?降级到 NOPMDCAdapter。甚至当 Appender 本身出现故障(磁盘满、网络断),AppenderBaseUnsynchronizedAppenderBase 里也有 statusRepeatCount 计数器的保护——错误日志最多只上报 3 到 5 次,避免日志框架自身的错误报告演变成二次灾难。logback 深度践行了"优雅降级"的理念——在核心主路径上坚持降级兜底,只在明确违反契约时才抛异常(比如 getLogger(null) 抛出 IllegalArgumentException、SPI Configurator 失败时抛出 LogbackException)。这种保守主义是基础设施软件的必修课。

3. 从 log4j 1.x 到 logback 的演化。 Ceki Gülcü 的两次创作——log4j 1.x 和 logback——完美诠释了"第一次做出来,第二次做对"的迭代哲学。logback 吸取了 log4j 1.x 的全部教训:级别体系改用数值而非字符串比较、Appender 体系引入 Encoder 架构、配置引擎完全重写、异步 Appender 内置。同时,他还通过创建 slf4j 这个门面,在商业角度上实现了"标准制定者 + 标准实现者"的双重身份——先定标准收拢生态,再通过实现建立事实上的绑定关系。

4. 一个值得回味的生态细节。 你可能注意到了——slf4j 和 logback 的作者是同一个人,而 slf4j 的绑定规范(StaticLoggerBinder 放在 org/slf4j/impl/ 路径下)天然倾向于 logback。这不是阴谋,是眼光。Ceki 在离开 log4j 1.x 后没有直接写第二个日志框架,而是先写了门面——因为他知道,只要生态依赖了你的接口标准,你的实现就成为默认选择。这跟 Android 兼容 OpenGL ES 但底层用自家实现、跟 Google 用 Chromium 控制 Web 标准——是同一种思路。标准即护城河。


总的来说,logback 不是一个"好用就行"的框架。它是一本被写成 Java 代码的最佳实践教科书——从模块分层、接口设计、状态管理、配置引擎到并发优化,每一处都能读出作者的思考。读完 slf4j 和 logback 的源码,一个 Java 工程师对"如何设计一个可扩展的框架"应该会有一个全新的认识。


写在最后

距离这个系列上一篇的随笔,已经过去了近一年。

彼时儿子刚满一岁,蹒跚学步,摇摇晃晃地扶着墙走向我。而此刻写下这些文字,已是 2026 年盛夏——从 2016 年毕业算起,整整十年了。

十年,足以让一个新人变成老手,让一个刚出校门的毛头小子变成父亲。

十年前,在学校学 Java,刚实习的时候,幸得一位老师傅手把手带我做真实项目,带我入门,我很感激他。

那时候,买技术书籍来看,在搜索引擎里捞答案,在论坛里一篇篇抄代码、改 bug,觉得只要把技术学透了,这碗饭就能端一辈子。

如今回头看,这种想法天真得可爱——那种“把一件事彻底搞懂”的踏实感,越来越稀缺了。

面对下一个十年,说实话,我很迷茫。

AI 迭代的速度,快得让我来不及消化。今天它能写工具类,明天它能写整个模块,后天呢?会不会有一天,一个产品经理拿着 PRD 跟 AI 对话半小时,就能直接部署上线?那时候我们这些写代码的人,位置在哪里?

但迷茫归迷茫,路还得走。

那个曾经扶墙走向我的小家伙,如今已经能满地跑了。他不知道爸爸在电脑前敲些什么,但他知道爸爸是在“修东西”。我希望下一个十年,我依然在“修东西”——修那些 AI 修不好的、需要人类去判断、去权衡、去负责的东西。

与所有还在死磕细节、仍在迷茫中前行的同行共勉。

下一个十年,期望我们还在场上见。

posted @ 2026-08-01 10:50  杜劲松  阅读(10)  评论(0)    收藏  举报