Spring Boot 4.1.1 深度源码解析-第 24 章 · 日志系统:`LoggingApplicationListener` 与结构化日志

Spring Boot 4.1.1 深度源码解析-第 24 章 · 日志系统:LoggingApplicationListener 与结构化日志

本章所有类名、常量、XML 资源内容均来自 spring-boot-4.1.1 真实源码。 涉及 Logback / Log4j2 / SLF4J 本体的行为,凡本仓库读不到的,一律标注「未核实」。


1. 引子:Spring Boot 4.1.1 里没有 LoggingAutoConfiguration

先做一个反常识的实证。全仓库搜索:

vbnet 复制代码
$ grep -rn "logging" --include=*.imports . | grep -v "/test/"
./module/spring-boot-actuator-autoconfigure/.../AutoConfiguration.imports:15:...LogFileWebEndpointAutoConfiguration
./module/spring-boot-actuator-autoconfigure/.../AutoConfiguration.imports:16:...LoggersEndpointAutoConfiguration
./module/spring-boot-micrometer-metrics/.../AutoConfiguration.imports:23:...Log4J2MetricsAutoConfiguration
./module/spring-boot-micrometer-metrics/.../AutoConfiguration.imports:24:...LogbackMetricsAutoConfiguration
./module/spring-boot-opentelemetry/.../AutoConfiguration.imports:2:...OpenTelemetryLoggingAutoConfiguration
./module/spring-boot-opentelemetry/.../AutoConfiguration.imports:3:...OtlpLoggingAutoConfiguration

没有任何一个 LoggingAutoConfiguration。 再直接搜类名:

ruby 复制代码
$ grep -rn "class LoggingAutoConfiguration" --include=*.java .
(无输出)

也就是说:日志的初始化在 Spring Boot 4.1.1 中完全不由自动配置机制驱动。

那由什么驱动?答案是 core/spring-boot/src/main/resources/META-INF/spring.factories 里的一行:

properties 复制代码
# Application Listeners
org.springframework.context.ApplicationListener=\
org.springframework.boot.ClearCachesApplicationListener,\
org.springframework.boot.builder.ParentContextCloseApplicationListener,\
org.springframework.boot.context.FileEncodingApplicationListener,\
org.springframework.boot.context.logging.LoggingApplicationListener,\
org.springframework.boot.support.AnsiOutputApplicationListener,\
org.springframework.boot.support.EnvironmentPostProcessorApplicationListener

以及同一文件最开头的:

properties 复制代码
# Logging Systems
org.springframework.boot.logging.LoggingSystemFactory=\
org.springframework.boot.logging.java.JavaLoggingSystem$Factory,\
org.springframework.boot.logging.log4j2.Log4J2LoggingSystem$Factory,\
org.springframework.boot.logging.logback.LogbackLoggingSystem$Factory

两个 SPI,一台戏。本章就是要拆开这台戏。

1.1 为什么日志不能是自动配置

这是一个时序问题 ,不是设计偏好。回顾第 4 章 SpringApplication.run() 的十二步:

scss 复制代码
1.  createBootstrapContext()                    ← BootstrapRegistry 建立
2.  configureHeadlessProperty()
3.  getRunListeners(...)                        ← SpringApplicationRunListener 加载
4.  listeners.starting(...)                     ← 发布 ApplicationStartingEvent
5.  prepareEnvironment(...)                     ← Environment 构建(ConfigData 在此)
6.  listeners.environmentPrepared(...)          ← 发布 ApplicationEnvironmentPreparedEvent ★
7.  createApplicationContext()
8.  prepareContext(...)
9.  refreshContext(...)                         ← ★★ 自动配置在此(AutoConfigurationImportSelector)
10. afterRefresh(...)
11. listeners.started(...)
12. callRunners(...) / listeners.ready(...)

自动配置发生在第 9 步 (invokeBeanFactoryPostProcessors 阶段)。而日志必须在第 6 步 之后立刻就绪。中间隔着第 7、8 步------包括 ApplicationContext 的创建和 ApplicationContextInitializer 的执行。

为什么日志必须这么早?

  1. 第 7、8 步自己就要打日志 。ApplicationContext 的构造、Environment 的绑定、各种 ApplicationContextInitializer 都会输出信息。如果日志系统还没初始化,这些输出会走"默认"配置(Logback 的默认 BasicConfigurator,只打控制台、级别 DEBUG),完全不受 logging.level.* 控制。
  2. spring.factories 的加载过程本身要打日志 (SpringFactoriesLoader 在某些路径下会 log debug)。
  3. ConfigData 阶段(spring.config.*)的警告 需要输出。第 7 章讲过 ConfigDataNotFoundFailureAnalyzer 等,它们依赖日志。
  4. 更根本的:自动配置引擎自己要打大量 DEBUG 日志(条件评估报告)。日志晚于自动配置就永远打不出"为什么这个自动配置没生效"。

所以 Spring Boot 的选择是:把日志初始化挂在 ApplicationEnvironmentPreparedEvent 上------这是 Environment 就绪的第一个 事件,之后再无更早的时机能读到 logging.level.* 这类配置。

由此得到本章的第一条定律(第三十六定律) : 基础设施的初始化时机由"它要为谁服务"决定,而不是由它自己的复杂度决定。日志要服务自动配置,所以它必须早于自动配置,因此不能是自动配置。

对照第 12 章的 BootstrapRegistry:那也是为"早于 ApplicationContext"的需求而设计的机制。两者是同一类问题的两种解法------BootstrapRegistry 解决"早于 context 的对象存储",ApplicationListener 解决"早于 context 的初始化动作"。

1.2 本章的地图

bash 复制代码
core/spring-boot/src/main/java/org/springframework/boot/
├── context/logging/
│   └── LoggingApplicationListener.java        ← 唯一的驱动者(493 行)
└── logging/
    ├── LoggingSystem.java                     ← 抽象(210 行)
    ├── LoggingSystemFactory.java
    ├── DelegatingLoggingSystemFactory.java
    ├── AbstractLoggingSystem.java
    ├── LoggingSystemProperties.java / LoggingSystemProperty.java
    ├── LogFile.java / LogLevel.java
    ├── LoggerGroup.java / LoggerGroups.java
    ├── LoggerConfiguration.java / LoggerConfigurationComparator.java
    ├── LoggingInitializationContext.java
    ├── CorrelationIdFormatter.java
    ├── StackTracePrinter.java / StandardStackTracePrinter.java
    ├── java/       JavaLoggingSystem.java + SimpleFormatter.java + RuntimeHints
    ├── log4j2/     Log4J2LoggingSystem.java + 16 个转换器/布局类
    ├── logback/    LogbackLoggingSystem.java + DefaultLogbackConfiguration.java
    │               + SpringBootJoranConfigurator.java + 18 个类
    └── structured/ 8 个类(结构化日志,3.4+)

对应的资源:

css 复制代码
core/spring-boot/src/main/resources/org/springframework/boot/logging/logback/
├── base.xml                          (594 B,兼容 Boot 1.1)
├── console-appender.xml              (516 B)
├── defaults.xml                      (2561 B)
├── file-appender.xml                 (1077 B)
├── structured-console-appender.xml   (618 B)
└── structured-file-appender.xml      (1179 B)

本章将在 §8 论证一个有意思的现象:Spring Boot 的 Logback 默认配置有"两套" ------一套是 Java 代码(DefaultLogbackConfiguration),一套是 XML(defaults.xml 等)。它们必须保持行为一致,但没有自动化保证。


2. LoggingApplicationListener:五个事件,一个状态机

java 复制代码
public class LoggingApplicationListener implements GenericApplicationListener {

注意是 GenericApplicationListener (不是普通的 ApplicationListener<E>)。这个接口允许一个监听器处理多种 事件类型,并通过 supportsEventType / supportsSourceType 做精确过滤------比 SmartApplicationListener 更底层,比泛型 ApplicationListener 更灵活。

2.1 支持的事件与源

java 复制代码
private static final Class<?>[] EVENT_TYPES = { ApplicationStartingEvent.class,
		ApplicationEnvironmentPreparedEvent.class, ApplicationPreparedEvent.class, ContextClosedEvent.class,
		ApplicationFailedEvent.class };

private static final Class<?>[] SOURCE_TYPES = { SpringApplication.class, ApplicationContext.class };

@Override
public boolean supportsEventType(ResolvableType resolvableType) {
	return isAssignableFrom(resolvableType.getRawClass(), EVENT_TYPES);
}

@Override
public boolean supportsSourceType(@Nullable Class<?> sourceType) {
	return isAssignableFrom(sourceType, SOURCE_TYPES);
}

五个事件,五个职责:

事件 时机 动作
ApplicationStartingEvent 第 4 步,最早 选 LoggingSystem + beforeInitialize()(静音)
ApplicationEnvironmentPreparedEvent 第 6 步 真正的初始化(读配置 + 加载配置 + 设级别)
ApplicationPreparedEvent 第 8 步后 把 LoggingSystem / LogFile / LoggerGroups 注册成 singleton bean
ContextClosedEvent 关闭时 cleanUp()
ApplicationFailedEvent 启动失败 cleanUp()

SOURCE_TYPES 限定为 SpringApplication 和 ApplicationContext------避免响应来自其它源的同类型事件(比如子容器的 ContextClosedEvent)。

2.2 顺序:HIGHEST_PRECEDENCE + 20

java 复制代码
/**
 * The default order for the LoggingApplicationListener.
 */
public static final int DEFAULT_ORDER = Ordered.HIGHEST_PRECEDENCE + 20;

HIGHEST_PRECEDENCE = Integer.MIN_VALUE,所以 order = Integer.MIN_VALUE + 20。

为什么不是 HIGHEST_PRECEDENCE 本身?因为要留出 0--19 给更早的监听器。哪些监听器需要更早?

  • FileEncodingApplicationListener(也在上面的列表里,排在 Logging 之前)------ 它检查 file.encoding 是否符合 spring.mandatory-file-encoding,日志输出的字符集依赖它,所以它必须更早。
  • AnsiOutputApplicationListener(排在 Logging 之后)------ 配置 ANSI 颜色输出。
  • EnvironmentPostProcessorApplicationListener(排最后)------ 触发 EnvironmentPostProcessor。

推测(未核实) :FileEncodingApplicationListener 与 AnsiOutputApplicationListener 的实际 order 值,以及它们在 spring.factories 列表中的书写顺序是否影响实际顺序(SpringFactoriesLoader 返回的顺序 = 文件中的顺序,但 ApplicationListener 的最终顺序由 AnnotationAwareOrderComparator 排序决定,所以书写顺序不决定执行顺序)。可自测:打印各 ApplicationListener 的 getOrder()。

+20 这个偏移量本身没有特殊含义,只是"留 20 个槽位给更紧急的东西"。这类"魔法偏移量"在 Spring Boot 里很常见(第 21 章 gRPC 的 HIGHEST_PRECEDENCE + 1024、第 22 章 HIGHEST_PRECEDENCE + 2 也是同一套路)。

2.3 onApplicationStartingEvent:静音的第一阶段

java 复制代码
private void onApplicationStartingEvent(ApplicationStartingEvent event) {
	this.loggingSystem = LoggingSystem.get(event.getSpringApplication().getClassLoader());
	this.loggingSystem.beforeInitialize();
}

只做两件事:选出 LoggingSystem,然后 beforeInitialize()。

beforeInitialize() 的语义(Javadoc):

Reset the logging system to be limit output. This method may be called before initialize(...) to reduce logging noise until the system has been fully initialized.

即:把日志压到最安静,避免在配置就绪前喷出大量 DEBUG。

具体到 Logback 的实现(§5.2),它是加了一个 TurboFilter 让所有事件 DENY。到 Java Util Logging,beforeInitialize 是把 root logger 级别设成 SEVERE。

2.4 onApplicationEnvironmentPreparedEvent:真正的初始化

java 复制代码
private void onApplicationEnvironmentPreparedEvent(ApplicationEnvironmentPreparedEvent event) {
	SpringApplication springApplication = event.getSpringApplication();
	if (this.loggingSystem == null) {
		this.loggingSystem = LoggingSystem.get(springApplication.getClassLoader());
	}
	initialize(event.getEnvironment(), springApplication.getClassLoader());
}

if (this.loggingSystem == null) 的兜底:如果 ApplicationStartingEvent 没被发出(比如某些嵌入式用法直接调 SpringApplication.run 的变体),这里补上。

initialize() 六步:

java 复制代码
protected void initialize(ConfigurableEnvironment environment, ClassLoader classLoader) {
	getLoggingSystemProperties(environment).apply();
	this.logFile = LogFile.get(environment);
	if (this.logFile != null) {
		this.logFile.applyToSystemProperties();
	}
	this.loggerGroups = new LoggerGroups(DEFAULT_GROUP_LOGGERS);
	initializeEarlyLoggingLevel(environment);
	Assert.state(this.loggingSystem != null, "loggingSystem is not set");
	initializeSystem(environment, this.loggingSystem, this.logFile);
	initializeFinalLoggingLevels(environment, this.loggingSystem);
	registerShutdownHookIfNecessary(environment, this.loggingSystem);
}

六步的顺序是关键,逐一拆解:

第 1 步:getLoggingSystemProperties(environment).apply() ------ 把 logging.* 配置转成 JVM 系统属性 (PID、CONSOLE_LOG_PATTERN、LOG_FILE 等)。必须在加载日志配置文件之前 做,因为 Logback/Log4j2 的 XML 里用 ${PID}、${CONSOLE_LOG_PATTERN} 引用它们。详见 §4。

第 2 步:LogFile.get(environment) + applyToSystemProperties() ------ 设置 LOG_PATH / LOG_FILE 系统属性。

第 3 步:new LoggerGroups(DEFAULT_GROUP_LOGGERS) ------ 建立日志组的初始值 (此时还没读用户的 logging.group.*)。

第 4 步:initializeEarlyLoggingLevel(environment) ------ 处理 --debug / --trace。名叫 "early",因为它要在 initializeSystem 之前算出 this.springBootLogging。

第 5 步:initializeSystem(...) ------ 加载配置文件 / 用默认配置。

第 6 步:initializeFinalLoggingLevels(...) ------ 读 logging.group.*、logging.level.*,应用级别。必须在 initializeSystem 之后,否则设的级别会被配置文件覆盖。

第 7 步(实际是第 7 行):registerShutdownHookIfNecessary(...)。

"第 4 步必须在第 5 步之前、第 6 步必须在第 5 步之后"------这个顺序不是随意的,它精确对应了"配置影响系统、系统影响级别"的因果链。

2.5 onApplicationPreparedEvent:把自己注册成 bean

java 复制代码
private void onApplicationPreparedEvent(ApplicationPreparedEvent event) {
	ConfigurableApplicationContext applicationContext = event.getApplicationContext();
	ConfigurableListableBeanFactory beanFactory = applicationContext.getBeanFactory();
	if (!beanFactory.containsBean(LOGGING_SYSTEM_BEAN_NAME)) {
		Assert.state(this.loggingSystem != null, "loggingSystem is not set");
		beanFactory.registerSingleton(LOGGING_SYSTEM_BEAN_NAME, this.loggingSystem);
	}
	if (this.logFile != null && !beanFactory.containsBean(LOG_FILE_BEAN_NAME)) {
		beanFactory.registerSingleton(LOG_FILE_BEAN_NAME, this.logFile);
	}
	if (this.loggerGroups != null && !beanFactory.containsBean(LOGGER_GROUPS_BEAN_NAME)) {
		beanFactory.registerSingleton(LOGGER_GROUPS_BEAN_NAME, this.loggerGroups);
	}
	if (!beanFactory.containsBean(LOGGING_LIFECYCLE_BEAN_NAME) && applicationContext.getParent() == null) {
		beanFactory.registerSingleton(LOGGING_LIFECYCLE_BEAN_NAME, new Lifecycle());
	}
}

四个 bean 名(三个 public 常量,一个 private):

java 复制代码
public static final String LOGGING_SYSTEM_BEAN_NAME = "springBootLoggingSystem";
public static final String LOG_FILE_BEAN_NAME = "springBootLogFile";
public static final String LOGGER_GROUPS_BEAN_NAME = "springBootLoggerGroups";
private static final String LOGGING_LIFECYCLE_BEAN_NAME = "springBootLoggingLifecycle";

用 registerSingleton(手动注册到单例池 ),而不是注册 BeanDefinition。区别:

  • registerSingleton 的对象不参与 生命周期回调(无 InitializingBean、无 @PostConstruct、无 BPP)------因为它是已经初始化好的工具对象。
  • 每个注册都先 containsBean 检查,且用 if (!...) 保护------因为 LoggingApplicationListener 可能被实例化多次?不,是防止与其它机制(如 Actuator)重复注册。

为什么要把 LoggingSystem 注册成 bean? 因为 Actuator 的 loggers 端点(第 25 章)需要它来动态改日志级别。LogFile 同理(Actuator 的 logfile 端点要读文件内容)。LoggerGroups 则让端点能展示组信息。

Lifecycle 的注册有条件:applicationContext.getParent() == null------只有根容器才注册。子容器(如 Actuator 的管理端口子上下文,第 25 章)不注册,避免重复清理。

2.6 Lifecycle:Integer.MIN_VALUE + 1 的关机哲学

java 复制代码
private final class Lifecycle implements SmartLifecycle {

	private volatile boolean running;

	@Override
	public void start() {
		this.running = true;
	}

	@Override
	public void stop() {
		this.running = false;
		cleanupLoggingSystem();
	}

	@Override
	public boolean isRunning() {
		return this.running;
	}

	@Override
	public int getPhase() {
		// Shutdown late and always after WebServerStartStopLifecycle
		return Integer.MIN_VALUE + 1;
	}

}

getPhase() 返回 Integer.MIN_VALUE + 1。SmartLifecycle 的语义:stop 时按 phase 降序 (phase 大的先 stop)。所以 Integer.MIN_VALUE + 1 意味着几乎最后才 stop。

注释写得很清楚:"Shutdown late and always after WebServerStartStopLifecycle"。

为什么要在 Web 服务器关闭之后才关日志?因为 Web 服务器关闭过程中还要打日志("正在停止 Tomcat"、"连接已关闭")。如果日志先关了,这些就丢了。

那为什么是 +1 而不是 Integer.MIN_VALUE?留给 Integer.MIN_VALUE(更早 stop 的那一档)给别的东西。

未核实 :WebServerStartStopLifecycle 的 phase 值。若它的 phase 是 Integer.MIN_VALUE + 1000 之类,那 +1 确实在它之后。可自测:grep -n "getPhase" module/spring-boot-web-server/...。

ContextClosedEvent 的处理也有配套逻辑:

java 复制代码
private void onContextClosedEvent(ContextClosedEvent event) {
	ApplicationContext applicationContext = event.getApplicationContext();
	if (applicationContext.getParent() != null || applicationContext.containsBean(LOGGING_LIFECYCLE_BEAN_NAME)) {
		return;
	}
	cleanupLoggingSystem();
}

如果容器里有 springBootLoggingLifecycle bean,就跳过 ------因为 Lifecycle.stop() 会做清理,避免重复。如果容器是子容器(getParent() != null),也跳过。

这个"双重保险"(Lifecycle + ContextClosedEvent)体现了 Spring Boot 对"关闭路径多样性"的防御:容器可能正常关闭(走 Lifecycle),也可能异常关闭(走 ContextClosedEvent),还可能启动失败(走 ApplicationFailedEvent)。三条路都要能清理日志。

2.7 关闭钩子:AtomicBoolean 保证只注册一次

java 复制代码
private static final AtomicBoolean shutdownHookRegistered = new AtomicBoolean();

private void registerShutdownHookIfNecessary(Environment environment, LoggingSystem loggingSystem) {
	if (environment.getProperty(REGISTER_SHUTDOWN_HOOK_PROPERTY, Boolean.class, true)) {
		Runnable shutdownHandler = loggingSystem.getShutdownHandler();
		if (shutdownHandler != null && shutdownHookRegistered.compareAndSet(false, true)) {
			registerShutdownHook(shutdownHandler);
		}
	}
}

void registerShutdownHook(Runnable shutdownHandler) {
	SpringApplication.getShutdownHandlers().add(shutdownHandler);
}

三点:

  1. shutdownHookRegistered 是 static ------ 同一个 JVM 里多个 Spring Boot 应用(测试里常见)只注册一次。
  2. 注册到 SpringApplication.getShutdownHandlers() ,而不是直接 Runtime.addShutdownHook。这是一个 Spring Boot 自己管理的 shutdown handler 集合(第 4 章讲过,它在 SpringApplication.shutdownHook 里统一注册到 JVM)。
  3. loggingSystem.getShutdownHandler() 可能为 null ------ Logback 返回 () -> getLoggerContext().stop(),而 JUL 返回 null(基类默认实现)。

REGISTER_SHUTDOWN_HOOK_PROPERTY = "logging.register-shutdown-hook",默认 true。


3. LoggingSystem 抽象与三选一

3.1 LoggingSystem.get(ClassLoader) 的两条路径

java 复制代码
public static LoggingSystem get(ClassLoader classLoader) {
	String loggingSystemClassName = System.getProperty(SYSTEM_PROPERTY);
	if (StringUtils.hasLength(loggingSystemClassName)) {
		if (NONE.equals(loggingSystemClassName)) {
			return new NoOpLoggingSystem();
		}
		return get(classLoader, loggingSystemClassName);
	}
	LoggingSystem loggingSystem = SYSTEM_FACTORY.getLoggingSystem(classLoader);
	Assert.state(loggingSystem != null, "No suitable logging system located");
	return loggingSystem;
}
java 复制代码
public static final String SYSTEM_PROPERTY = LoggingSystem.class.getName();
public static final String NONE = "none";

SYSTEM_PROPERTY = "org.springframework.boot.logging.LoggingSystem"。

所以你可以这样完全绕过探测:

bash 复制代码
java -Dorg.springframework.boot.logging.LoggingSystem=none -jar app.jar
# 或指定实现类(必须有一个 ClassLoader 参数的构造器)
java -Dorg.springframework.boot.logging.LoggingSystem=org.springframework.boot.logging.logback.LogbackLoggingSystem -jar app.jar

none → NoOpLoggingSystem:

java 复制代码
static class NoOpLoggingSystem extends LoggingSystem {
	@Override public void beforeInitialize() { }
	@Override public void setLogLevel(@Nullable String loggerName, @Nullable LogLevel level) { }
	@Override public List<LoggerConfiguration> getLoggerConfigurations() { return Collections.emptyList(); }
	@Override public @Nullable LoggerConfiguration getLoggerConfiguration(String loggerName) { return null; }
}

所有方法空实现。这用于"我完全不想让 Spring Boot 碰我的日志"的场景。

3.2 LoggingSystemFactory:spring.factories + @Order 的双重排序

java 复制代码
public interface LoggingSystemFactory {
	@Nullable LoggingSystem getLoggingSystem(ClassLoader classLoader);

	static LoggingSystemFactory fromSpringFactories() {
		return new DelegatingLoggingSystemFactory(
				(classLoader) -> SpringFactoriesLoader.loadFactories(LoggingSystemFactory.class, classLoader));
	}
}
java 复制代码
private static final LoggingSystemFactory SYSTEM_FACTORY = LoggingSystemFactory.fromSpringFactories();

注意这是 LoggingSystem 里的 static final 字段------类加载时就算好了。

DelegatingLoggingSystemFactory:

java 复制代码
@Override
public @Nullable LoggingSystem getLoggingSystem(ClassLoader classLoader) {
	List<LoggingSystemFactory> delegates = (this.delegates != null) ? this.delegates.apply(classLoader) : null;
	if (delegates != null) {
		for (LoggingSystemFactory delegate : delegates) {
			LoggingSystem loggingSystem = delegate.getLoggingSystem(classLoader);
			if (loggingSystem != null) {
				return loggingSystem;
			}
		}
	}
	return null;
}

第一个返回非 null 的胜出。所以顺序至关重要。

顺序从哪来?SpringFactoriesLoader.loadFactories(...) 会按 @Order / Ordered 排序(第 14 章讲过 SpringFactoriesLoader 的 FailureAnalyzer 排序,同一机制)。

spring.factories 里的书写顺序是:

bash 复制代码
JavaLoggingSystem$Factory,
Log4J2LoggingSystem$Factory,
LogbackLoggingSystem$Factory

但书写顺序不是决定因素 ------决定性的是 @Order。看 Logback 的:

java 复制代码
@Order(Ordered.HIGHEST_PRECEDENCE + 1024)
public static class Factory implements LoggingSystemFactory {

HIGHEST_PRECEDENCE + 1024。而 Java 和 Log4j2 的呢?本章未逐个打开核对,但从"Logback 通常胜出"的事实反推:Logback 的 order 最小(最优先)。

未核实 :JavaLoggingSystem.Factory 与 Log4J2LoggingSystem.Factory 的 @Order 值。从结果推断:Logback (HIGHEST+1024) < Log4j2 < Java(数值越小越优先)。

注意 HIGHEST_PRECEDENCE + 1024 其实是一个相当小 的值(Integer.MIN_VALUE + 1024),比它小的只剩 0--1023。所以 Log4j2/Java 的 order 应该更大。

自测方法 :grep -n "@Order" core/spring-boot/src/main/java/org/springframework/boot/logging/*/*System.java,或直接写一行代码打印三个 Factory 的 AnnotationAwareOrderComparator 排序结果。

这里有个值得警惕的点 :Spring Boot 官方推荐的日志系统是 Logback (spring-boot-starter-logging 默认带 Logback)。但按 @Order,Logback 优先;若 classpath 同时有 Logback 和 Log4j2,Logback 赢。这与"starter 排除 Logback 换 Log4j2"的常规做法一致。

3.3 Factory 的探测方式:静态 PRESENT 布尔

java 复制代码
@Order(Ordered.HIGHEST_PRECEDENCE + 1024)
public static class Factory implements LoggingSystemFactory {

	private static final boolean PRESENT = ClassUtils.isPresent("ch.qos.logback.classic.LoggerContext",
			Factory.class.getClassLoader());

	@Override
	public @Nullable LoggingSystem getLoggingSystem(ClassLoader classLoader) {
		if (PRESENT) {
			return new LogbackLoggingSystem(classLoader);
		}
		return null;
	}

}

PRESENT 是 static final,在 Factory 类加载时算一次 。探测类是 ch.qos.logback.classic.LoggerContext。

注意:ClassUtils.isPresent(name, Factory.class.getClassLoader()) 用的是 Factory 类自己的 ClassLoader ,而不是传入的 classLoader 参数。这是一个细节------如果日志实现的 jar 在子 ClassLoader 里(比如某些插件容器),探测会失败。

为什么不用 ClassUtils.isPresent(name, classLoader)(用传入的)? 推断:因为 PRESENT 必须是 static final 才能做一次计算,而 static 字段初始化时拿不到方法参数。这是一个"性能 vs 正确性"的权衡,Spring Boot 选了性能(static 缓存)。在绝大多数场景(扁平 classpath)下等价。

3.4 LogLevel:7 个级别与一个 LogMethod

java 复制代码
public enum LogLevel {

	TRACE(Log::trace),
	DEBUG(Log::debug),
	INFO(Log::info),
	WARN(Log::warn),
	ERROR(Log::error),
	FATAL(Log::fatal),
	OFF(null);

	private final @Nullable LogMethod logMethod;

	LogLevel(@Nullable LogMethod logMethod) {
		this.logMethod = logMethod;
	}

	public void log(Log logger, Object message) {
		log(logger, message, null);
	}

	public void log(@Nullable Log logger, Object message, @Nullable Throwable cause) {
		if (logger != null && this.logMethod != null) {
			this.logMethod.log(logger, message, cause);
		}
	}

	@FunctionalInterface
	private interface LogMethod {
		void log(Log logger, Object message, @Nullable Throwable cause);
	}

}

七个级别。注意:

  • FATAL 是 Spring Boot 特有的抽象 ,Logback 没有 FATAL(§5 会看到 levels.map(LogLevel.FATAL, Level.ERROR)------FATAL 映射到 Logback 的 ERROR)。Log4j2 有 FATAL。
  • OFF(null) ------ logMethod 为 null,log() 方法里 if (... && this.logMethod != null) 会跳过。
  • Log 是 org.apache.commons.logging.Log(Spring 的 JCL 抽象),不是 SLF4J 的。

这个枚举的价值在于:它是跨日志系统的统一级别抽象 。Actuator 的 loggers 端点用它,配置绑定用它(logging.level.* 的值直接 bind 成 LogLevel)。

3.5 LoggerGroup / LoggerGroups:两个默认组

java 复制代码
private static final Map<String, List<String>> DEFAULT_GROUP_LOGGERS;
static {
	MultiValueMap<String, String> loggers = new LinkedMultiValueMap<>();
	loggers.add("web", "org.springframework.core.codec");
	loggers.add("web", "org.springframework.http");
	loggers.add("web", "org.springframework.web");
	loggers.add("web", "org.springframework.boot.actuate.endpoint.web");
	loggers.add("web", "org.springframework.boot.web.servlet.ServletContextInitializerBeans");
	loggers.add("sql", "org.springframework.jdbc.core");
	loggers.add("sql", "org.hibernate.SQL");
	loggers.add("sql", "org.jooq.tools.LoggerListener");
	DEFAULT_GROUP_LOGGERS = Collections.unmodifiableMap(loggers);
}

只有两个内置组 :web(5 个 logger)和 sql(3 个 logger)。

sql 组值得注意:

  • org.springframework.jdbc.core ------ JdbcTemplate 的日志
  • org.hibernate.SQL ------ Hibernate 打印 SQL 的 logger
  • org.jooq.tools.LoggerListener ------ jOOQ 的 SQL 日志

所以 logging.level.sql=DEBUG 会同时打开这三种访问方式的 SQL 日志。这是一个很实用的快捷方式。

LoggerGroups 的方法:

java 复制代码
public @Nullable LoggerGroup get(String name) {
	return this.groups.get(name);
}

groups 是 ConcurrentHashMap ------ 因为理论上可能有并发访问(Actuator 端点改级别时)。

LoggerGroup.configureLogLevel:

java 复制代码
public void configureLogLevel(@Nullable LogLevel level, BiConsumer<String, @Nullable LogLevel> configurer) {
	this.configuredLevel = level;
	this.members.forEach((name) -> configurer.accept(name, level));
}

记录 configuredLevel 并对每个成员应用。configuredLevel 供 Actuator 展示"这个组当前设成什么了"。

3.6 --debug / --trace:两组预设 logger

java 复制代码
private static final Map<LogLevel, List<String>> SPRING_BOOT_LOGGING_LOGGERS;
static {
	MultiValueMap<LogLevel, String> loggers = new LinkedMultiValueMap<>();
	loggers.add(LogLevel.DEBUG, "sql");
	loggers.add(LogLevel.DEBUG, "web");
	loggers.add(LogLevel.DEBUG, "org.springframework.boot");
	loggers.add(LogLevel.TRACE, "org.springframework");
	loggers.add(LogLevel.TRACE, "org.apache.tomcat");
	loggers.add(LogLevel.TRACE, "org.apache.catalina");
	loggers.add(LogLevel.TRACE, "org.eclipse.jetty");
	loggers.add(LogLevel.TRACE, "org.hibernate.tool.hbm2ddl");
	SPRING_BOOT_LOGGING_LOGGERS = Collections.unmodifiableMap(loggers);
}

--debug 打开 3 项(sql 组、web 组、org.springframework.boot);--trace 打开 5 项(更宽的 org.springframework + 三个服务器/ORM)。

注意 --trace 不包含 --debug 的项 ,它们是互斥的两档。所以 --trace 时 org.springframework.boot 不会变成 DEBUG?

看代码:

java 复制代码
private void initializeEarlyLoggingLevel(ConfigurableEnvironment environment) {
	if (this.parseArgs && this.springBootLogging == null) {
		if (isSet(environment, "debug")) {
			this.springBootLogging = LogLevel.DEBUG;
		}
		if (isSet(environment, "trace")) {
			this.springBootLogging = LogLevel.TRACE;
		}
	}
}

两个 if 不是 else if!所以同时设置 debug 和 trace 时,trace 会覆盖 debug(后一个赋值赢)。

isSet:

java 复制代码
private boolean isSet(ConfigurableEnvironment environment, String property) {
	String value = environment.getProperty(property);
	return (value != null && !value.equals("false"));
}

只有字符串 "false" 才算关闭 。--debug=false → value == "false" → 不启用。但 --debug=0、--debug=no → 启用!(因为它们不等于字面量 "false")

这是一个真实可踩的坑 :--debug=0 会打开 debug 日志。Spring Boot 在这里没有做布尔解析,只做了字符串比较。第 15 章讲 @ConditionalOnBooleanProperty 时提到过 Spring Boot 有严格的布尔解析(只接受 true/false,其它报错),但这里是手写的 isSet,没走那套。

自测方法 :java -jar app.jar --debug=0,观察是否出现 DEBUG 日志。

parseArgs 字段(setParseArgs(boolean))可以关掉这个行为,默认 true。用途:SpringApplicationBuilder 场景下你可能不希望命令行参数影响日志。

应用逻辑:

java 复制代码
protected void initializeSpringBootLogging(LoggingSystem system, LogLevel springBootLogging) {
	BiConsumer<String, @Nullable LogLevel> configurer = getLogLevelConfigurer(system);
	SPRING_BOOT_LOGGING_LOGGERS.getOrDefault(springBootLogging, Collections.emptyList())
		.forEach((name) -> configureLogLevel(name, springBootLogging, configurer));
}

configureLogLevel 会先查 LoggerGroups------所以 "sql" 和 "web" 会被展开成组成员。这就是为什么 SPRING_BOOT_LOGGING_LOGGERS 里可以直接写组名。

3.7 setLogLevels 与 configureLogLevel:组优先于普通名

java 复制代码
protected void setLogLevels(LoggingSystem system, ConfigurableEnvironment environment) {
	BiConsumer<String, @Nullable LogLevel> customizer = getLogLevelConfigurer(system);
	Binder binder = Binder.get(environment);
	Map<String, LogLevel> levels = binder.bind(LOGGING_LEVEL, STRING_LOGLEVEL_MAP).orElseGet(Collections::emptyMap);
	levels.forEach((name, level) -> configureLogLevel(name, level, customizer));
}

private void configureLogLevel(String name, LogLevel level, BiConsumer<String, @Nullable LogLevel> configurer) {
	if (this.loggerGroups != null) {
		LoggerGroup group = this.loggerGroups.get(name);
		if (group != null && group.hasMembers()) {
			group.configureLogLevel(level, configurer);
			return;
		}
	}
	configurer.accept(name, level);
}

名字先当组查,查不到再当普通 logger 名 。所以如果你定义了一个 logging.group.web=...,那么 logging.level.web=DEBUG 会展开成组成员;如果没定义 web 组,logging.level.web=DEBUG 就是给名为 web 的 logger 设级别。

LOGGING_LEVEL 与 STRING_LOGLEVEL_MAP:

java 复制代码
private static final ConfigurationPropertyName LOGGING_LEVEL = ConfigurationPropertyName.of("logging.level");
private static final Bindable<Map<String, LogLevel>> STRING_LOGLEVEL_MAP = Bindable.mapOf(String.class, LogLevel.class);

注意用的是 ConfigurationPropertyName.of("logging.level") 而不是字符串 "logging.level"。第 8 章讲过 ConfigurationPropertyName 的规范化------这里用它做前缀绑定,能让 LOGGING_LEVEL_GROUP 之类的宽松写法(logging.level 写成 LOGGING_LEVEL)也能匹配。

3.8 getLogLevelConfigurer:ROOT → null 的转换

java 复制代码
private BiConsumer<String, @Nullable LogLevel> getLogLevelConfigurer(LoggingSystem system) {
	return (name, level) -> {
		try {
			name = name.equalsIgnoreCase(LoggingSystem.ROOT_LOGGER_NAME) ? null : name;
			system.setLogLevel(name, level);
		}
		catch (RuntimeException ex) {
			this.logger.error(LogMessage.format("Cannot set level '%s' for '%s'", level, name));
		}
	};
}

两件事:

  1. ROOT(忽略大小写)→ null 。LoggingSystem.ROOT_LOGGER_NAME = "ROOT",而 setLogLevel(null, level) 表示"设置根 logger"。所以 logging.level.root=warn、logging.level.ROOT=warn、logging.level.Root=WARN 都能设根级别。
  2. 异常只 log 不抛。设置某个 logger 级别失败(比如 logger 不存在且系统不支持创建)不会让启动失败,只打一条 ERROR。

注意这条 ERROR 用的是 this.logger------即 LoggingApplicationListener 自己的 logger。而此时日志系统已经初始化完成(§2.4 第 5 步之后),所以能看到。


4. LoggingSystemProperty 与 LoggingSystemProperties:配置 → 系统属性的翻译层

这是 Spring Boot 日志体系里最容易被忽略、但最实用 的一环。它解释了那个经典问题:为什么我的 logback.xml 里可以写 ${PID}、${CONSOLE_LOG_PATTERN}?

4.1 LoggingSystemProperty:16 个枚举常量

java 复制代码
public enum LoggingSystemProperty {

	APPLICATION_NAME("APPLICATION_NAME", "spring.application.name", "logging.include-application-name"),
	APPLICATION_GROUP("APPLICATION_GROUP", "spring.application.group", "logging.include-application-group"),
	PID("PID"),
	LOG_FILE("LOG_FILE"),
	LOG_PATH("LOG_PATH"),
	CONSOLE_CHARSET("CONSOLE_LOG_CHARSET", "logging.charset.console"),
	FILE_CHARSET("FILE_LOG_CHARSET", "logging.charset.file"),
	CONSOLE_THRESHOLD("CONSOLE_LOG_THRESHOLD", "logging.threshold.console"),
	FILE_THRESHOLD("FILE_LOG_THRESHOLD", "logging.threshold.file"),
	EXCEPTION_CONVERSION_WORD("LOG_EXCEPTION_CONVERSION_WORD", "logging.exception-conversion-word"),
	CONSOLE_PATTERN("CONSOLE_LOG_PATTERN", "logging.pattern.console"),
	FILE_PATTERN("FILE_LOG_PATTERN", "logging.pattern.file"),
	CONSOLE_STRUCTURED_FORMAT("CONSOLE_LOG_STRUCTURED_FORMAT", "logging.structured.format.console"),
	FILE_STRUCTURED_FORMAT("FILE_LOG_STRUCTURED_FORMAT", "logging.structured.format.file"),
	LEVEL_PATTERN("LOG_LEVEL_PATTERN", "logging.pattern.level"),
	DATEFORMAT_PATTERN("LOG_DATEFORMAT_PATTERN", "logging.pattern.dateformat"),
	CORRELATION_PATTERN("LOG_CORRELATION_PATTERN", "logging.pattern.correlation");

	private final String environmentVariableName;
	private final @Nullable String applicationPropertyName;
	private final @Nullable String includePropertyName;
	...
}

三元组结构:(系统属性名 / 环境变量名,对应的 Spring 配置属性名,是否包含的开关属性名)。

枚举 系统属性名 Spring 属性 include 开关
APPLICATION_NAME APPLICATION_NAME spring.application.name logging.include-application-name
APPLICATION_GROUP APPLICATION_GROUP spring.application.group logging.include-application-group
PID PID --- ---
LOG_FILE LOG_FILE --- ---
LOG_PATH LOG_PATH --- ---
CONSOLE_CHARSET CONSOLE_LOG_CHARSET logging.charset.console ---
FILE_CHARSET FILE_LOG_CHARSET logging.charset.file ---
CONSOLE_THRESHOLD CONSOLE_LOG_THRESHOLD logging.threshold.console ---
FILE_THRESHOLD FILE_LOG_THRESHOLD logging.threshold.file ---
EXCEPTION_CONVERSION_WORD LOG_EXCEPTION_CONVERSION_WORD logging.exception-conversion-word ---
CONSOLE_PATTERN CONSOLE_LOG_PATTERN logging.pattern.console ---
FILE_PATTERN FILE_LOG_PATTERN logging.pattern.file ---
CONSOLE_STRUCTURED_FORMAT CONSOLE_LOG_STRUCTURED_FORMAT logging.structured.format.console ---
FILE_STRUCTURED_FORMAT FILE_LOG_STRUCTURED_FORMAT logging.structured.format.file ---
LEVEL_PATTERN LOG_LEVEL_PATTERN logging.pattern.level ---
DATEFORMAT_PATTERN LOG_DATEFORMAT_PATTERN logging.pattern.dateformat ---
CORRELATION_PATTERN LOG_CORRELATION_PATTERN logging.pattern.correlation ---

注意 Javadoc 里叫 environmentVariableName,实际上这些是系统属性 (System.setProperty),名字形如 CONSOLE_LOG_PATTERN。叫"environment variable"是历史命名(早期 Spring Boot 确实把它们当环境变量风格的 key 用)。在 Logback XML 里 ${CONSOLE_LOG_PATTERN} 会先查 Logback 的 context property,再查系统属性------所以能取到。

4.2 apply():16 次设置 + 2 个特例

java 复制代码
protected void apply(@Nullable LogFile logFile, PropertyResolver resolver) {
	setSystemProperty(LoggingSystemProperty.APPLICATION_NAME, resolver);
	setSystemProperty(LoggingSystemProperty.APPLICATION_GROUP, resolver);
	setSystemProperty(LoggingSystemProperty.PID, new ApplicationPid().toString());
	setSystemProperty(LoggingSystemProperty.CONSOLE_CHARSET, resolver, getDefaultConsoleCharset().name());
	setSystemProperty(LoggingSystemProperty.FILE_CHARSET, resolver, getDefaultFileCharset().name());
	setSystemProperty(LoggingSystemProperty.CONSOLE_THRESHOLD, resolver, this::thresholdMapper);
	setSystemProperty(LoggingSystemProperty.FILE_THRESHOLD, resolver, this::thresholdMapper);
	setSystemProperty(LoggingSystemProperty.EXCEPTION_CONVERSION_WORD, resolver);
	setSystemProperty(LoggingSystemProperty.CONSOLE_PATTERN, resolver);
	setSystemProperty(LoggingSystemProperty.FILE_PATTERN, resolver);
	setSystemProperty(LoggingSystemProperty.CONSOLE_STRUCTURED_FORMAT, resolver);
	setSystemProperty(LoggingSystemProperty.FILE_STRUCTURED_FORMAT, resolver);
	setSystemProperty(LoggingSystemProperty.LEVEL_PATTERN, resolver);
	setSystemProperty(LoggingSystemProperty.DATEFORMAT_PATTERN, resolver);
	setSystemProperty(LoggingSystemProperty.CORRELATION_PATTERN, resolver);
	if (logFile != null) {
		logFile.applyToSystemProperties();
	}
	if (!this.environment.getProperty("logging.console.enabled", Boolean.class, true)) {
		setSystemProperty(LoggingSystemProperty.CONSOLE_THRESHOLD.getEnvironmentVariableName(), "OFF");
	}
}

15 次属性设置 + 2 个特例:

特例 1:PID 不来自配置,来自 new ApplicationPid().toString() 。ApplicationPid 是 Spring Boot 的工具类(第 18 章提过),它从 JVM 名(RuntimeMXBean.getName(),形如 12345@hostname)里解析 PID,解析不到就返回 "????"。

特例 2:logging.console.enabled=false 时强制 CONSOLE_LOG_THRESHOLD=OFF 。这是一个兜底 :注意它是直接用字符串 "OFF" 调用 setSystemProperty(String, String),绕过了 LoggingSystemProperty 的三元组逻辑。而且它在最后执行,覆盖 前面第 6 行设置的 CONSOLE_THRESHOLD。

未核实 :logging.console.enabled 这个属性是否有对应的 @ConfigurationProperties 类(本章未在 spring-boot-logging 相关模块找到 LoggingProperties 之类的类)。从 §1 的发现看,Spring Boot 4.1.1 没有日志的 ConfigurationProperties 类------所有 logging.* 属性都是直接 environment.getProperty 读取的,没有绑定对象 。这是一个值得注意的事实。可自测:grep -rn "ConfigurationProperties(\"logging" --include=*.java .。

4.3 setSystemProperty 的四参数版本:一个"包含开关"的实现

java 复制代码
private void setSystemProperty(LoggingSystemProperty property, PropertyResolver resolver,
		@Nullable String defaultValue, Function<@Nullable String, @Nullable String> mapper) {
	if (property.getIncludePropertyName() != null) {
		if (!resolver.getProperty(property.getIncludePropertyName(), Boolean.class, Boolean.TRUE)) {
			return;
		}
	}
	String applicationPropertyName = property.getApplicationPropertyName();
	String value = (applicationPropertyName != null) ? resolver.getProperty(applicationPropertyName) : null;
	value = (value != null) ? value : this.defaultValueResolver.apply(applicationPropertyName);
	value = (value != null) ? value : defaultValue;
	value = mapper.apply(value);
	setSystemProperty(property.getEnvironmentVariableName(), value);
	if (property == LoggingSystemProperty.APPLICATION_NAME && StringUtils.hasText(value)) {
		// LOGGED_APPLICATION_NAME is deprecated for removal in 4.0.0
		setSystemProperty("LOGGED_APPLICATION_NAME", "[%s] ".formatted(value));
	}
}

五级取值链:

markdown 复制代码
1. include 开关检查(若配了 logging.include-xxx=false → 直接 return,不设置)
2. applicationPropertyName 对应的配置值
3. defaultValueResolver(由具体 LoggingSystem 提供,见 §7.3 的 correlation pattern)
4. defaultValue 参数(如 charset 的默认值)
5. mapper 变换(如 thresholdMapper)

LOGGED_APPLICATION_NAME 的兼容处理 :注释说 "is deprecated for removal in 4.0.0",但代码还在 (4.1.1 里仍然设置)。它的值是 "[应用名] "(带方括号和尾空格),供老版本的 logback.xml 引用。

这是一个值得记的现象:注释说"4.0.0 移除",但 4.1.1 还在设置 。要么注释是笔误(应该是 5.0.0),要么移除计划被推迟。无论哪种,都提醒我们:不要只看注释判断 API 的生命周期,要看代码。

4.4 thresholdMapper:YAML 的 OFF 陷阱

java 复制代码
private @Nullable String thresholdMapper(@Nullable String input) {
	// YAML converts an unquoted OFF to false
	if ("false".equals(input)) {
		return "OFF";
	}
	return input;
}

这是一个非常具体的兼容性 hack:YAML 里写 logging.threshold.console: OFF(不加引号)会被 YAML 解析器当成布尔 false (因为 YAML 1.1 把 off/on/yes/no 当作布尔)。

所以 thresholdMapper 把 "false" 反解回 "OFF"。

副作用 :如果你真的想设 CONSOLE_LOG_THRESHOLD=false(虽然没意义),会被变成 OFF。以及------如果你在 properties 文件里写 logging.threshold.console=false,也会被变成 OFF。这在 properties 文件里是反直觉的。

自测 :logging.threshold.console=false → 观察控制台是否完全不输出(OFF 的效果)。

4.5 系统属性设置器:System.getProperty(name) == null 才写

java 复制代码
private static final BiConsumer<String, @Nullable String> systemPropertySetter = (name, value) -> {
	if (System.getProperty(name) == null && value != null) {
		System.setProperty(name, value);
	}
};

不覆盖已有的系统属性 。这是关键设计:-DCONSOLE_LOG_PATTERN=xxx 命令行指定的,优先级高于 application.yml 里配的。

这与第 6 章讲的"属性优先级"略有不同------那里是 Environment 的 PropertySource 顺序;这里是系统属性层面的"先到先得"。

setter 可以被替换(构造器参数),Logback 就用了一个变体(§5.4 的 loggerContext::putProperty)------把值写到 Logback 的 context property 而不是系统属性,这样同一个 JVM 跑多个 Spring Boot 应用时互不干扰。

4.6 getPropertyResolver():为什么不用 Environment 直接读

java 复制代码
private PropertyResolver getPropertyResolver() {
	if (this.environment instanceof ConfigurableEnvironment configurableEnvironment) {
		PropertySourcesPropertyResolver resolver = new PropertySourcesPropertyResolver(
				configurableEnvironment.getPropertySources());
		resolver.setConversionService(configurableEnvironment.getConversionService());
		resolver.setIgnoreUnresolvableNestedPlaceholders(true);
		return resolver;
	}
	return this.environment;
}

新建一个 PropertySourcesPropertyResolver 而不是直接用 Environment。两个 setter 是关键:

  • setConversionService(...) ------ 复用 Environment 的转换器(这样 logging.threshold.console: OFF 能转成 LogLevel 之类)。
  • setIgnoreUnresolvableNestedPlaceholders(true) ------ 遇到 ${foo} 无法解析时不抛异常,保留原样。

为什么要忽略?因为日志 pattern 里常见 %d{...} 之类,或者用户可能在 logging.pattern.console 里写了 ${MY_VAR} 期望由 Logback 自己解析(Logback 有 ${} 语法)。如果 Spring 在这里抢先解析并报错,就破坏了 Logback 的能力。

这是一个"两层占位符"的经典场景 :Spring 解析一层,Logback 再解析一层。Spring Boot 用 setIgnoreUnresolvableNestedPlaceholders(true) 让位给下游。第 8 章讲 Binder 时提过 PlaceholdersResolver,这里是同一个问题的另一面。

4.7 默认字符集:Console vs File 的不对称

java 复制代码
protected Charset getDefaultConsoleCharset() {
	Console console = getConsole();
	return (console != null) ? console.charset() : Charset.defaultCharset();
}

protected Charset getDefaultFileCharset() {
	return StandardCharsets.UTF_8;
}
  • 控制台字符集 :System.console() 存在则用 console.charset()(Windows 上可能是 GBK,Linux 上是 UTF-8),否则 Charset.defaultCharset()。
  • 文件字符集 :硬编码 UTF-8。

这个不对称是有意的:控制台要跟随终端(否则中文乱码),文件要统一(否则换机器就乱码)。

System.console() 在 IDE 里运行或输出被重定向时返回 null------所以 IDE 里跑和命令行跑,控制台字符集可能不同。这是一个常见的"本地没问题,部署后乱码"的来源。

getConsole() 是 protected 且 @since 3.5.0,并且 LogbackLoggingSystemProperties 重写了它(只是为了调 super,看似无意义------推测是为了测试时能 mock)。

推测(未核实) :LogbackLoggingSystemProperties.getConsole() 重写只是调 super.getConsole(),看起来是冗余代码。可能是为了在测试中通过子类覆盖来注入 mock Console。可自测:git log -p 查这个方法的引入原因。


5. LogFile:name 与 path 的四种组合

java 复制代码
public class LogFile {

	public static final String FILE_NAME_PROPERTY = "logging.file.name";
	public static final String FILE_PATH_PROPERTY = "logging.file.path";

	private final @Nullable String file;
	private final @Nullable String path;

	LogFile(String file) {
		this(file, null);
	}

	LogFile(@Nullable String file, @Nullable String path) {
		Assert.isTrue(StringUtils.hasLength(file) || StringUtils.hasLength(path), "'file' or 'path' must not be empty");
		this.file = file;
		this.path = path;
	}

	@Override
	public String toString() {
		if (StringUtils.hasLength(this.file)) {
			return this.file;
		}
		return new File(this.path, "spring.log").getPath();
	}

	public static @Nullable LogFile get(PropertyResolver propertyResolver) {
		String file = propertyResolver.getProperty(FILE_NAME_PROPERTY);
		String path = propertyResolver.getProperty(FILE_PATH_PROPERTY);
		if (StringUtils.hasLength(file) || StringUtils.hasLength(path)) {
			return new LogFile(file, path);
		}
		return null;
	}
}

四种组合:

logging.file.name logging.file.path toString() 结果 LOG_PATH 系统属性
未配 未配 ---(LogFile.get 返回 null,不写文件) 不设置
/var/log/app.log --- /var/log/app.log 不设置
--- /var/log /var/log/spring.log /var/log
/var/log/app.log /var/log /var/log/app.log(name 优先) /var/log

applyTo:

java 复制代码
public void applyTo(Properties properties) {
	put(properties, LoggingSystemProperty.LOG_PATH, this.path);
	put(properties, LoggingSystemProperty.LOG_FILE, toString());
}

private void put(Properties properties, LoggingSystemProperty property, @Nullable String value) {
	if (StringUtils.hasLength(value)) {
		properties.put(property.getEnvironmentVariableName(), value);
	}
}

只在有值时设置。所以只配 name 时 LOG_PATH 不会被设置。

logging.file.name 优先于 logging.file.path ------ 这是 Spring Boot 2.2 之后的语义(2.1 及之前是 logging.file 和 logging.path)。如果你看到老教程写 logging.file=xxx,那是旧版。

未核实 :是否还有 logging.file / logging.path 的废弃属性兼容层。第 16 章讲过 spring-boot-properties-migrator 处理这类重命名,但本章未在日志路径上找到 PropertiesMigrationListener 的条目。自测:grep -rn "logging.file\b" core/spring-boot/src/main/resources/META-INF/additional-spring-configuration-metadata.json。


6. AbstractLoggingSystem:配置文件的"约定优先"查找

java 复制代码
@Override
public void initialize(LoggingInitializationContext initializationContext, @Nullable String configLocation,
		@Nullable LogFile logFile) {
	if (StringUtils.hasLength(configLocation)) {
		initializeWithSpecificConfig(initializationContext, configLocation, logFile);
		return;
	}
	initializeWithConventions(initializationContext, logFile);
}

private void initializeWithConventions(LoggingInitializationContext initializationContext,
		@Nullable LogFile logFile) {
	String config = getSelfInitializationConfig();
	if (config != null && logFile == null) {
		// self initialization has occurred, reinitialize in case of property changes
		reinitialize(initializationContext);
		return;
	}
	if (config == null) {
		config = getSpringInitializationConfig();
	}
	if (config != null) {
		loadConfiguration(initializationContext, config, logFile);
		return;
	}
	loadDefaults(initializationContext, logFile);
}

四级决策,画成流程:

scss 复制代码
1. logging.config 显式指定? → loadConfiguration(指定位置)
2. 否则:classpath 上有"标准配置文件"?(logback.xml / log4j2.xml / logging.properties)
   ├─ 有 且 没配 logFile → reinitialize()(重新初始化以应用系统属性)
   └─ 有 且 配了 logFile → 继续往下(因为需要重新加载以带上 LOG_FILE)
3. 否则:classpath 上有"spring 变体配置文件"?(logback-spring.xml / log4j2-spring.xml)
   → loadConfiguration(变体)
4. 否则:loadDefaults()(用 Spring Boot 内置的默认配置)

6.1 -spring 变体的生成规则

java 复制代码
protected String[] getSpringConfigLocations() {
	String[] locations = getStandardConfigLocations();
	for (int i = 0; i < locations.length; i++) {
		String extension = StringUtils.getFilenameExtension(locations[i]);
		int extensionLength = (extension != null) ? (extension.length() + 1) : 0;
		locations[i] = locations[i].substring(0, locations[i].length() - extensionLength) + "-spring." + extension;
	}
	return locations;
}

把扩展名前面插入 -spring:

  • logback.xml → logback-spring.xml
  • logback-test.xml → logback-test-spring.xml
  • logback.groovy → logback-spring.groovy

为什么要 -spring 变体? 因为 -spring 变体可以使用 Spring Boot 的扩展标签(<springProperty>、<springProfile>),而这些标签需要 Spring Environment,只有 Spring Boot 的 SpringBootJoranConfigurator 能解析。

而非 -spring 的标准文件名(logback.xml)由 Logback 自己加载(在 Spring Boot 介入之前,Logback 的自动初始化就做了),所以 Spring Boot 只能"重新初始化"来应用系统属性。这就是上面第 2 步 reinitialize() 的由来。

Logback 的标准配置位置:

java 复制代码
@Override
protected String[] getStandardConfigLocations() {
	return new String[] { "logback-test.groovy", "logback-test.xml", "logback.groovy", "logback.xml" };
}

四个,按优先级 :logback-test.groovy > logback-test.xml > logback.groovy > logback.xml。

logback-test.* 优先于 logback.* ------ 这是为了让测试环境的配置自动覆盖生产配置(测试时 src/test/resources/logback-test.xml 在 classpath 上)。

6.2 findConfig 与"存在即认为已应用"

java 复制代码
private @Nullable String findConfig(String[] locations) {
	for (String location : locations) {
		ClassPathResource resource = new ClassPathResource(location, this.classLoader);
		if (resource.exists()) {
			return "classpath:" + location;
		}
	}
	return null;
}

简单的 classpath 查找。注意 getSelfInitializationConfig() 的 Javadoc:

By default this method checks getStandardConfigLocations() and assumes that any file that exists will have been applied.

"假设任何存在的文件都已被应用"------因为 Logback/Log4j2 在类初始化时就会自动找并加载这些文件。Spring Boot 只是"事后知道"。

6.3 getPackagedConfigFile:内置配置文件的路径推导

java 复制代码
protected final String getPackagedConfigFile(String fileName) {
	String defaultPath = ClassUtils.getPackageName(getClass());
	defaultPath = defaultPath.replace('.', '/');
	defaultPath = defaultPath + "/" + fileName;
	defaultPath = "classpath:" + defaultPath;
	return defaultPath;
}

把当前类的包名 转成路径。例如 JavaLoggingSystem 在 org.springframework.boot.logging.java 包,调 getPackagedConfigFile("logging.properties") 得到:

bash 复制代码
classpath:org/springframework/boot/logging/java/logging.properties

这是一个优雅的做法:配置文件与代码同包存放,路径自动推导,不需要硬编码。

6.4 LogLevels<T>:双向映射表

java 复制代码
protected static class LogLevels<T> {

	private final Map<LogLevel, T> systemToNative;
	private final Map<T, LogLevel> nativeToSystem;

	public void map(LogLevel system, T nativeLevel) {
		this.systemToNative.putIfAbsent(system, nativeLevel);
		this.nativeToSystem.putIfAbsent(nativeLevel, system);
	}
	...
}

两个 Map,双向映射。注意 putIfAbsent------先注册的赢。

Logback 的映射:

java 复制代码
levels.map(LogLevel.TRACE, Level.TRACE);
levels.map(LogLevel.TRACE, Level.ALL);   // ← 第二次 map 同 key
levels.map(LogLevel.DEBUG, Level.DEBUG);
levels.map(LogLevel.INFO, Level.INFO);
levels.map(LogLevel.WARN, Level.WARN);
levels.map(LogLevel.ERROR, Level.ERROR);
levels.map(LogLevel.FATAL, Level.ERROR); // ← FATAL → ERROR
levels.map(LogLevel.OFF, Level.OFF);

map(TRACE, TRACE) 后 map(TRACE, ALL):

  • systemToNative:putIfAbsent(TRACE, TRACE) 成功;putIfAbsent(TRACE, ALL) 失败 (key 已存在)→ TRACE → Level.TRACE
  • nativeToSystem:putIfAbsent(TRACE, TRACE) 成功;putIfAbsent(ALL, TRACE) 成功 → Level.ALL 反查得到 TRACE

所以 Level.ALL 能反查成 TRACE,但 TRACE 只能正查成 Level.TRACE。 这是"多对一"映射的正确处理方式:正向用第一个,反向全收。

对比 Log4j2:

java 复制代码
levels.map(LogLevel.TRACE, Level.TRACE);
levels.map(LogLevel.DEBUG, Level.DEBUG);
levels.map(LogLevel.INFO, Level.INFO);
levels.map(LogLevel.WARN, Level.WARN);
levels.map(LogLevel.ERROR, Level.ERROR);
levels.map(LogLevel.FATAL, Level.FATAL);  // ← Log4j2 有真正的 FATAL
levels.map(LogLevel.OFF, Level.OFF);

对比 JUL:

java 复制代码
levels.map(LogLevel.TRACE, Level.FINEST);
levels.map(LogLevel.DEBUG, Level.FINE);
levels.map(LogLevel.INFO, Level.INFO);
levels.map(LogLevel.WARN, Level.WARNING);
levels.map(LogLevel.ERROR, Level.SEVERE);
levels.map(LogLevel.FATAL, Level.SEVERE);  // ← FATAL → SEVERE
levels.map(LogLevel.OFF, Level.OFF);

getSupported() 返回 nativeToSystem.values()(去重后),所以 Logback 支持 7 个(含 ALL 反查的 TRACE 已去重),Log4j2 支持 7 个,JUL 支持 6 个(FATAL 和 ERROR 都映射到 SEVERE,去重后少一个)。

6.5 Log4J2LoggingSystem 的反常:getStandardConfigLocations() 抛异常

java 复制代码
@Override
protected String[] getStandardConfigLocations() {
	// With Log4J2 we use the ConfigurationFactory
	throw new IllegalStateException("Standard config locations cannot be used with Log4J2");
}

直接抛异常 。因为 Log4j2 有自己的 ConfigurationFactory 机制(SpringBootConfigurationFactory),Spring Boot 不参与文件名约定。

对应的 getSelfInitializationConfig() / getSpringInitializationConfig() 都被重写:

java 复制代码
@Override
protected @Nullable String getSelfInitializationConfig() {
	return getConfigLocation(getLoggerContext().getConfiguration());
}

@Override
protected @Nullable String getSpringInitializationConfig() {
	ConfigurationFactory configurationFactory = ConfigurationFactory.getInstance();
	try {
		Configuration springConfiguration = configurationFactory.getConfiguration(getLoggerContext(), "-spring",
				null, getClassLoader());
		String configLocation = getConfigLocation(springConfiguration);
		return (configLocation != null && configLocation.contains("-spring")) ? configLocation : null;
	}
	catch (ConfigurationException ex) {
		statusLogger.warn("Could not load Spring-specific Log4j Core configuration", ex);
		return null;
	}
}

configurationFactory.getConfiguration(ctx, "-spring", null, classLoader) ------ 第二个参数是 name,Log4j2 的 ConfigurationFactory 用它拼文件名(log4j2-spring.xml)。

注意最后的检查 :configLocation.contains("-spring")。因为如果没找到 log4j2-spring.xml,Log4j2 会返回一个默认的 DefaultConfiguration,其 location 可能是 null 或不带 -spring。这个 contains 检查就是用来区分"找到了 spring 变体"和"退回默认"。

对比 Logback 的 findConfig(看文件是否存在),Log4j2 这里用的是"问 ConfigurationFactory 要,看返回的名字"。两套完全不同的机制 ,因为两个日志系统的配置发现机制本来就不同。这是 LoggingSystem 抽象存在的价值------把差异封装在子类里。

SpringBootConfigurationFactory 在 org.springframework.boot.logging.log4j2 包,未在 spring.factories 里注册(Log4j2 用 META-INF/log4j2.component.properties 或 @Plugin 注解发现)。

未核实 :SpringBootConfigurationFactory 如何被 Log4j2 发现(推断是通过 Log4j2 的 @Plugin + ConfigurationFactory 的 @Order/权重机制),以及它的优先级是否高于 Log4j2 内置的 XmlConfigurationFactory。自测:cat core/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/SpringBootConfigurationFactory.java。


7. LogbackLoggingSystem 深潜:三个"抑制"技巧与 AOT 旁路

LogbackLoggingSystem 是 Spring Boot 里最复杂的 LoggingSystem 实现(约 500 行),也是唯一实现了 BeanFactoryInitializationAotProcessor 的:

java 复制代码
public class LogbackLoggingSystem extends AbstractLoggingSystem implements BeanFactoryInitializationAotProcessor {

7.1 SUPPRESS_ALL_FILTER:全局静音开关

java 复制代码
private static final TurboFilter SUPPRESS_ALL_FILTER = new TurboFilter() {

	@Override
	public FilterReply decide(Marker marker, ch.qos.logback.classic.Logger logger, Level level, String format,
			Object[] params, Throwable t) {
		return FilterReply.DENY;
	}

};

一个 TurboFilter,对所有事件返回 DENY。

TurboFilter 与普通的 Logback Filter 有什么区别?TurboFilter 在 LoggingEvent 对象创建之前就执行 ,所以它能拦截掉"连事件对象都不该构造"的场景(性能更好,且能拦住 ILoggingEvent 构造过程中的副作用)。

未核实 :Logback 的 TurboFilter 的确切时机(推断在 Logger.filterAndLog 的最早阶段,早于 LoggingEvent 构造)。自测:在 decide 里打断点看调用栈。

三处使用:

(1) beforeInitialize() ------ 启动时静音:

java 复制代码
@Override
public void beforeInitialize() {
	LoggerContext loggerContext = getLoggerContext();
	if (isAlreadyInitialized(loggerContext)) {
		return;
	}
	super.beforeInitialize();
	configureJdkLoggingBridgeHandler();
	loggerContext.getTurboFilterList().add(SUPPRESS_ALL_FILTER);
}

(2) withLoggingSuppressed(Runnable) ------ 配置期间静音:

java 复制代码
private void withLoggingSuppressed(Runnable action) {
	TurboFilterList turboFilters = getLoggerContext().getTurboFilterList();
	turboFilters.add(SUPPRESS_ALL_FILTER);
	try {
		action.run();
	}
	finally {
		turboFilters.remove(SUPPRESS_ALL_FILTER);
	}
}

loadDefaults / loadConfiguration / putInitializationContextObjects 都包在它里面。为什么?因为加载配置的过程中,Logback 自己会打一堆状态日志("正在解析 appender"、"正在启动 encoder"),这些噪音不应该出现在用户的控制台上。

(3) initialize() 结束时移除:

java 复制代码
loggerContext.getTurboFilterList().remove(SUPPRESS_ALL_FILTER);
markAsInitialized(loggerContext);

注意移除的时机:super.initialize(...) 或 AOT 初始化之后。

7.2 isAlreadyInitialized / markAsInitialized:用 LoggerContext 的 object map 做标记

java 复制代码
private boolean isAlreadyInitialized(LoggerContext loggerContext) {
	return loggerContext.getObject(LoggingSystem.class.getName()) != null;
}

private void markAsInitialized(LoggerContext loggerContext) {
	loggerContext.putObject(LoggingSystem.class.getName(), new Object());
}

private void markAsUninitialized(LoggerContext loggerContext) {
	loggerContext.removeObject(LoggingSystem.class.getName());
}

LoggerContext 有一个 Map<String, Object> objectMap(Logback 的 ContextBase 提供 putObject/getObject)。Spring Boot 用它存"是否已初始化"的标记。

为什么不用一个 Java 字段? 因为 LoggingSystem 实例可能被创建多次(每个 SpringApplication 一个),而 LoggerContext 是 JVM 全局单例 (LoggerFactory.getILoggerFactory() 返回同一个)。用 context 存标记,能跨 LoggingSystem 实例共享状态------这在"一个 JVM 跑多个 Spring Boot 应用"(测试里常见)时是必要的。

beforeInitialize() 开头的 if (isAlreadyInitialized(...)) return; 就是这个作用:第二个应用启动时不再重复静音和装桥接器。

7.3 JUL → SLF4J 桥接:三个条件

java 复制代码
private void configureJdkLoggingBridgeHandler() {
	try {
		if (isBridgeJulIntoSlf4j()) {
			removeJdkLoggingBridgeHandler();
			SLF4JBridgeHandler.install();
			this.bridgeHandlerInstalled = true;
		}
	}
	catch (Throwable ex) {
		// Ignore. No java.util.logging bridge is installed.
	}
}

private boolean isBridgeJulIntoSlf4j() {
	return isBridgeHandlerAvailable() && isJulUsingASingleConsoleHandlerAtMost();
}

private boolean isBridgeHandlerAvailable() {
	return ClassUtils.isPresent(BRIDGE_HANDLER, getClassLoader());
}

private boolean isJulUsingASingleConsoleHandlerAtMost() {
	java.util.logging.Logger rootLogger = LogManager.getLogManager().getLogger("");
	Handler[] handlers = rootLogger.getHandlers();
	return handlers.length == 0 || (handlers.length == 1 && handlers[0] instanceof ConsoleHandler);
}

BRIDGE_HANDLER = "org.slf4j.bridge.SLF4JBridgeHandler"(来自 jul-to-slf4j jar)。

两个条件:

  1. jul-to-slf4j 在 classpath。
  2. JUL 的 root logger 最多只有一个 ConsoleHandler。

第 2 个条件很有意思:如果 JUL 的 root logger 挂了多个 handler,或者挂了非 ConsoleHandler 的 handler(比如用户自己配了 FileHandler),Spring Boot 就不装桥接器。

为什么?因为装桥接器要摘掉 root logger 的 handler(见 removeDefaultRootHandler)。如果用户精心配置了 JUL 的 handler,Spring Boot 贸然摘掉就是破坏用户意图。所以"只在 JUL 处于默认状态(0 或 1 个 ConsoleHandler)时才接管"。

removeJdkLoggingBridgeHandler():

java 复制代码
private void removeJdkLoggingBridgeHandler() {
	try {
		removeDefaultRootHandler();
		SLF4JBridgeHandler.uninstall();
	}
	catch (Throwable ex) {
		// Ignore and continue
	}
}

先摘 root handler,再 uninstall 桥接器。

LevelChangePropagator:

java 复制代码
private void addLevelChangePropagator(LoggerContext loggerContext) {
	LevelChangePropagator levelChangePropagator = new LevelChangePropagator();
	levelChangePropagator.setResetJUL(true);
	levelChangePropagator.setContext(loggerContext);
	loggerContext.addListener(levelChangePropagator);
}

只在桥接器装上了才加(stopAndReset 里 if (isBridgeHandlerInstalled()))。作用:把 Logback 的级别变化同步回 JUL 。因为桥接后 JUL 的日志走 SLF4J,但 JUL 自身的 logger.isLoggable(...) 判断用的是 JUL 的级别------如果不同步,会出现"SLF4J 级别是 DEBUG,但 JUL 级别还是 INFO,导致日志在源头就被过滤掉"。

stopAndReset:

java 复制代码
private void stopAndReset(LoggerContext loggerContext) {
	loggerContext.stop();
	loggerContext.reset();
	if (isBridgeHandlerInstalled()) {
		addLevelChangePropagator(loggerContext);
	}
}

reset() 会清空监听器,所以要在 reset 之后重新加。

已知的性能陷阱(JUL 桥接) :SLF4JBridgeHandler 对每条 JUL 日志都要做级别的双向转换,且 JUL 的 LogRecord 构造开销大。Spring Boot 文档建议如果性能敏感,考虑 LoggingSystem 的其他方案。此为推断,未核实官方是否这样表述。

7.4 initialize():AOT 优先的三分支

java 复制代码
@Override
public void initialize(LoggingInitializationContext initializationContext, @Nullable String configLocation,
		@Nullable LogFile logFile) {
	LoggerContext loggerContext = getLoggerContext();
	putInitializationContextObjects(loggerContext, initializationContext);
	if (isAlreadyInitialized(loggerContext)) {
		return;
	}
	if (!initializeFromAotGeneratedArtifactsIfPossible(initializationContext, logFile)) {
		super.initialize(initializationContext, configLocation, logFile);
	}
	loggerContext.getTurboFilterList().remove(SUPPRESS_ALL_FILTER);
	markAsInitialized(loggerContext);
	if (StringUtils.hasText(System.getProperty(CONFIGURATION_FILE_PROPERTY))) {
		getLogger(LogbackLoggingSystem.class.getName()).warn("Ignoring '" + CONFIGURATION_FILE_PROPERTY
				+ "' system property. Please use 'logging.config' instead.");
	}
}

三分支:

  1. 已初始化 → 直接返回(多应用场景)。
  2. AOT 产出的配置可用 → 用它(Native 场景)。
  3. 否则 → 走 AbstractLoggingSystem 的常规路径(§6)。

最后一个 if 是一个用户友好的警告 :如果用户用了 -Dlogback.configurationFile=xxx(Logback 原生的方式),Spring Boot 会警告"请改用 logging.config"。因为 logback.configurationFile 会让 Logback 在 Spring Boot 介入之前就加载配置,导致 logging.level.* 等 Spring 配置失效。

CONFIGURATION_FILE_PROPERTY = "logback.configurationFile"。

这是一个"检测到用户可能踩坑就主动警告"的模式,与第 23 章 OSIV 的 WARN 是同一套路。Spring Boot 在这类"静默失效"的地方都会加 WARN。

7.5 AOT:把 Logback 的 Model 序列化到 META-INF/spring/logback-model

java 复制代码
private boolean initializeFromAotGeneratedArtifactsIfPossible(LoggingInitializationContext initializationContext,
		@Nullable LogFile logFile) {
	if (!AotDetector.useGeneratedArtifacts()) {
		return false;
	}
	if (initializationContext != null) {
		Environment environment = initializationContext.getEnvironment();
		Assert.state(environment != null, "'environment' must not be null");
		applySystemProperties(environment, logFile);
	}
	LoggerContext loggerContext = getLoggerContext();
	stopAndReset(loggerContext);
	withLoggingSuppressed(() -> putInitializationContextObjects(loggerContext, initializationContext));
	SystemStatusListener.addTo(loggerContext);
	SpringBootJoranConfigurator configurator = new SpringBootJoranConfigurator(initializationContext);
	configurator.setContext(loggerContext);
	boolean configuredUsingAotGeneratedArtifacts = configurator.configureUsingAotGeneratedArtifacts();
	if (configuredUsingAotGeneratedArtifacts) {
		reportConfigurationErrorsIfNecessary(loggerContext);
	}
	return configuredUsingAotGeneratedArtifacts;
}

核心是 SpringBootJoranConfigurator.configureUsingAotGeneratedArtifacts(),它做两件事:

java 复制代码
boolean configureUsingAotGeneratedArtifacts() {
	if (!new PatternRules(getContext()).load()) {
		return false;
	}
	Model model = new ModelReader().read();
	processModel(model);
	registerSafeConfiguration(model);
	return true;
}
  • PatternRules.load() ------ 从 META-INF/spring/logback-pattern-rules(一个 .properties)读回转换规则。
  • ModelReader.read() ------ 从 META-INF/spring/logback-model 反序列化 一个 Model 对象树。

为什么 AOT 要序列化 Model 而不是保留 XML? 因为解析 XML(Joran)需要大量反射(PropertySetter、bean introspection),这在 Native 镜像里要么需要大量 hints,要么根本不行。AOT 阶段就把 XML 解析成 Model 对象树(Logback 自己的中间表示),运行时直接反序列化 + 执行 model handler,跳过 XML 解析。

ModelWriter.writeTo:

java 复制代码
private void writeTo(GenerationContext generationContext) {
	byte[] serializedModel = serializeModel();
	generationContext.getGeneratedFiles()
		.handleFile(Kind.RESOURCE, MODEL_RESOURCE_LOCATION,
				new RequireNewOrMatchingContentFileHandler(serializedModel));
	generationContext.getRuntimeHints().resources().registerPattern(MODEL_RESOURCE_LOCATION);
	ReflectionHints reflectionHints = generationContext.getRuntimeHints().reflection();
	serializationTypes(this.model).forEach(reflectionHints::registerJavaSerialization);
	reflectionTypes(this.model).forEach((type) -> generationContext.getRuntimeHints()
		.reflection()
		.registerType(TypeReference.of(type), MemberCategory.INVOKE_PUBLIC_METHODS,
				MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS));
}

MODEL_RESOURCE_LOCATION = "META-INF/spring/logback-model"。

注册三类 hints:

  1. 资源 :logback-model 文件。
  2. Java 序列化 :所有 model 类及其字段类型(registerJavaSerialization)。
  3. 反射:所有组件类型的 public 方法和构造器。

RequireNewOrMatchingContentFileHandler 是一个有趣的保险:

java 复制代码
@Override
public void acceptWithException(FileHandler file) throws Exception {
	if (file.exists()) {
		InputStreamSource content = file.getContent();
		Assert.state(content != null, "Unable to get file content");
		byte[] existingContent = content.getInputStream().readAllBytes();
		if (!Arrays.equals(this.newContent, existingContent)) {
			throw new IllegalStateException(
					"Logging configuration differs from the configuration that has already been written. "
							+ "Update your logging configuration so that it is the same for each context");
		}
	}
	else {
		file.create(new ByteArrayResource(this.newContent));
	}
}

如果同一个构建过程里,不同上下文(如主应用 + 测试切片)产出了不同的 Logback 配置,直接报错 。因为 Native 镜像只能有一份 logback-model,不一致就必然有一个是错的。

这是一个早失败设计:与其运行时悄悄用错配置,不如编译期报错。

7.6 loadDefaults:DefaultLogbackConfiguration 的编程式配置

java 复制代码
@Override
protected void loadDefaults(LoggingInitializationContext initializationContext, @Nullable LogFile logFile) {
	LoggerContext loggerContext = getLoggerContext();
	stopAndReset(loggerContext);
	withLoggingSuppressed(() -> {
		boolean debug = Boolean.getBoolean("logback.debug");
		putInitializationContextObjects(loggerContext, initializationContext);
		SystemStatusListener.addTo(loggerContext, debug);
		Environment environment = initializationContext.getEnvironment();
		Assert.state(environment != null, "'environment' must not be null");
		// Apply system properties directly in case the same JVM runs multiple apps
		new LogbackLoggingSystemProperties(environment, getDefaultValueResolver(environment),
				loggerContext::putProperty)
			.apply(logFile);
		LogbackConfigurator configurator = (!debug) ? new LogbackConfigurator(loggerContext)
				: new DebugLogbackConfigurator(loggerContext);
		new DefaultLogbackConfiguration(logFile).apply(configurator);
		loggerContext.setPackagingDataEnabled(true);
		loggerContext.start();
	});
}

关键点:

(1) -Dlogback.debug=true 切到 DebugLogbackConfigurator 。它会在 Logback 的 StatusManager 里记录每一步配置动作("Adding appender X"、"Configuring logger Y with level Z")。

(2) new LogbackLoggingSystemProperties(..., loggerContext::putProperty) ------ 注释写得很清楚:"Apply system properties directly in case the same JVM runs multiple apps"。用 loggerContext::putProperty 作为 setter,把属性写到 Logback context 而不是 JVM 系统属性。这样同 JVM 多应用互不干扰。

(3) loggerContext.setPackagingDataEnabled(true) ------ 开启 packaging data,Logback 在打印异常堆栈时会额外显示每个栈帧来自哪个 jar([app.jar:0.0.1])。这对排查依赖冲突极有价值,但有性能开销(Logback 需要读 jar 的 manifest)。

未核实 :setPackagingDataEnabled(true) 的性能影响幅度。Logback 文档说它在异常发生时才计算,影响有限。此为推断。

7.7 DefaultLogbackConfiguration:两套默认配置之一(Java 版)

java 复制代码
class DefaultLogbackConfiguration {

	private static final String DEFAULT_CHARSET = StandardCharsets.UTF_8.name();
	private static final String NAME_AND_GROUP = "%esb(){APPLICATION_NAME}%esb{APPLICATION_GROUP}";
	private static final String DATETIME = "%d{${LOG_DATEFORMAT_PATTERN:-yyyy-MM-dd'T'HH:mm:ss.SSSXXX}}";

	private static final String DEFAULT_CONSOLE_LOG_PATTERN = faint(DATETIME) + " "
			+ colorByLevel("${LOG_LEVEL_PATTERN:-%5p}") + " " + magenta("${PID:-}") + " "
			+ faint("--- " + NAME_AND_GROUP + "[%15.15t] ${LOG_CORRELATION_PATTERN:-}") + cyan("%-40.40logger{39}")
			+ " " + faint(":") + " %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}}";

	static final String CONSOLE_LOG_PATTERN = "${CONSOLE_LOG_PATTERN:-" + DEFAULT_CONSOLE_LOG_PATTERN;

	private static final String DEFAULT_FILE_LOG_PATTERN = DATETIME + " ${LOG_LEVEL_PATTERN:-%5p} ${PID:-} --- "
			+ NAME_AND_GROUP + "[%t] ${LOG_CORRELATION_PATTERN:-}"
			+ "%-40.40logger{39} : %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}";

	static final String FILE_LOG_PATTERN = "${FILE_LOG_PATTERN:-" + DEFAULT_FILE_LOG_PATTERN;
	...
}

这就是 Spring Boot 那句熟悉的日志格式:

sql 复制代码
2026-10-17T18:15:19.123+08:00  INFO 12345 --- [           main] com.example.MyApplication           : Started MyApplication in 1.234 seconds

拆解 console pattern:

片段 含义
faint(DATETIME) 时间戳,ANSI 淡色
colorByLevel("${LOG_LEVEL_PATTERN:-%5p}") %clr(${LOG_LEVEL_PATTERN:-%5p}){} ------ 按级别自动着色(ERROR 红、WARN 黄、DEBUG 绿...)
magenta("${PID:-}") PID,紫色
faint("--- " + NAME_AND_GROUP + "[%15.15t] ${LOG_CORRELATION_PATTERN:-}") 分隔符 + 应用名/组 + 线程名(右对齐 15 宽)+ correlation id
cyan("%-40.40logger{39}") logger 名,青色,左对齐 40 宽(超过 40 截断,但 logger{39} 是 Logback 的缩写规则)
faint(":") 冒号
%m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx} 消息 + 换行 + 异常

%esb(){APPLICATION_NAME} 中的 esb 是 Spring Boot 注册的转换词(EnclosedInSquareBracketsConverter,见 defaults.xml 的 <conversionRule conversionWord="esb" .../>)。它的作用:如果 APPLICATION_NAME 有值,输出 [应用名](带方括号);没值则输出空。这就是为什么没配 spring.application.name 时日志里没有方括号。

%15.15t 的线程名右对齐 15 字符;文件 pattern 里用 %t(不限制宽度)------控制台对齐好看,文件里省空间。

7.8 两套默认配置的对照

DefaultLogbackConfiguration(Java 代码)与 defaults.xml + console-appender.xml + file-appender.xml(XML)必须是行为等价的。对照:

项目 Java 版 XML 版
转换词注册 config.conversionRule("clr", ColorConverter.class, ColorConverter::new) 等 5 个 <conversionRule conversionWord="clr" class="...ColorConverter"/> 等 5 个
CONSOLE_LOG_PATTERN putProperty(config, "CONSOLE_LOG_PATTERN", CONSOLE_LOG_PATTERN) <property name="CONSOLE_LOG_PATTERN" value="${CONSOLE_LOG_PATTERN:-...}"/>
appender new ConsoleAppender<>() + createAppender(...) <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
文件滚动 SizeAndTimeBasedRollingPolicy 编程设置 同名类 + <rollingPolicy> 子元素
8 个 logger 级别 config.logger("org.apache.catalina.startup.DigesterFactory", Level.ERROR) 等 <logger name="..." level="ERROR"/> 等
root config.root(Level.INFO, consoleAppender[, fileAppender]) <root level="INFO"><appender-ref .../>...</root>

defaults.xml 里那 8 个 logger:

xml 复制代码
<logger name="org.apache.catalina.startup.DigesterFactory" level="ERROR"/>
<logger name="org.apache.catalina.util.LifecycleBase" level="ERROR"/>
<logger name="org.apache.coyote.http11.Http11NioProtocol" level="WARN"/>
<logger name="org.apache.sshd.common.util.SecurityUtils" level="WARN"/>
<logger name="org.apache.tomcat.util.net.NioSelectorPool" level="WARN"/>
<logger name="org.eclipse.jetty.util.component.AbstractLifeCycle" level="ERROR"/>
<logger name="org.hibernate.validator.internal.util.Version" level="WARN"/>
<logger name="org.springframework.boot.actuate.endpoint.jmx" level="WARN"/>

这些都是"启动噪音"的常见来源------Tomcat/Jetty/SSH 在启动时会在 ERROR/WARN 级别打一些无害的信息,Spring Boot 把它们压到更严的级别。

例如 org.apache.catalina.startup.DigesterFactory 会打 "The web.xml... does not contain any Servlet...",这在 Spring Boot(无 web.xml)下是无意义的 ERROR。

为什么要用两套? 因为:

  • Java 版 用于"没有配置文件"的场景(默认),它快 (不需要 XML 解析)。类注释说:"Uses LogbackConfigurator to improve startup time."
  • XML 版 用于用户在自己的 logback.xml 里 <include resource="org/springframework/boot/logging/logback/defaults.xml"/> 的场景(这是官方推荐的做法)。

代价 :两套必须同步维护。任何一边的改动(比如新增一个转换词)都要在两边都做。这是一个已知的技术债。

自测方法 :对比 DefaultLogbackConfiguration 的 defaults() 方法与 defaults.xml 的内容,看是否有不对称(比如某个 property 只在一边存在)。

7.9 滚动策略默认值

file-appender.xml 与 Java 版一致:

xml 复制代码
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
	<fileNamePattern>${LOGBACK_ROLLINGPOLICY_FILE_NAME_PATTERN:-${LOG_FILE}.%d{yyyy-MM-dd}.%i.gz}</fileNamePattern>
	<cleanHistoryOnStart>${LOGBACK_ROLLINGPOLICY_CLEAN_HISTORY_ON_START:-false}</cleanHistoryOnStart>
	<maxFileSize>${LOGBACK_ROLLINGPOLICY_MAX_FILE_SIZE:-10MB}</maxFileSize>
	<totalSizeCap>${LOGBACK_ROLLINGPOLICY_TOTAL_SIZE_CAP:-0}</totalSizeCap>
	<maxHistory>${LOGBACK_ROLLINGPOLICY_MAX_HISTORY:-7}</maxHistory>
</rollingPolicy>

默认值:单个 10MB、保留 7 天、总大小不限(0 = 不限)、文件名 xxx.log.2026-10-17.0.gz。

RollingPolicySystemProperty 枚举:

java 复制代码
public enum RollingPolicySystemProperty {
	FILE_NAME_PATTERN("file-name-pattern"),
	CLEAN_HISTORY_ON_START("clean-history-on-start"),
	MAX_FILE_SIZE("max-file-size"),
	TOTAL_SIZE_CAP("total-size-cap"),
	MAX_HISTORY("max-history");

	private final String environmentVariableName;
	private final String applicationPropertyName;

	RollingPolicySystemProperty(String applicationPropertyName) {
		this.environmentVariableName = "LOGBACK_ROLLINGPOLICY_" + name();
		this.applicationPropertyName = "logging.logback.rollingpolicy." + applicationPropertyName;
	}
}

系统属性名是 "LOGBACK_ROLLINGPOLICY_" + name(),即 LOGBACK_ROLLINGPOLICY_MAX_FILE_SIZE。配置名是 logging.logback.rollingpolicy.max-file-size。

注意 name() 是枚举名(下划线),而 applicationPropertyName 是短横线------两套命名风格(系统属性用下划线,配置属性用短横线)。这与第 11 章讲环境变量映射时提到的"下划线 vs 短横线"是同一件事。

LogbackLoggingSystemProperties.applyRollingPolicyProperties:

java 复制代码
private void applyRollingPolicyProperties(PropertyResolver resolver) {
	applyRollingPolicy(RollingPolicySystemProperty.FILE_NAME_PATTERN, resolver);
	applyRollingPolicy(RollingPolicySystemProperty.CLEAN_HISTORY_ON_START, resolver);
	applyRollingPolicy(RollingPolicySystemProperty.MAX_FILE_SIZE, resolver, DataSize.class);
	applyRollingPolicy(RollingPolicySystemProperty.TOTAL_SIZE_CAP, resolver, DataSize.class);
	applyRollingPolicy(RollingPolicySystemProperty.MAX_HISTORY, resolver);
}

private <T> void applyRollingPolicy(RollingPolicySystemProperty property, PropertyResolver resolver,
		Class<T> type) {
	T value = getProperty(resolver, property.getApplicationPropertyName(), type);
	if (value != null) {
		String stringValue = String.valueOf((value instanceof DataSize dataSize) ? dataSize.toBytes() : value);
		setSystemProperty(property.getEnvironmentVariableName(), stringValue);
	}
}

DataSize 转成字节数 (dataSize.toBytes())。所以 logging.logback.rollingpolicy.max-file-size=20MB 会变成 20971520。因为 Logback 的 FileSize 需要纯数字(字节)。

容错:

java 复制代码
private <T> @Nullable T getProperty(PropertyResolver resolver, String key, Class<T> type) {
	try {
		return resolver.getProperty(key, type);
	}
	catch (ConversionFailedException | ConverterNotFoundException ex) {
		if (type != DataSize.class) {
			throw ex;
		}
		String value = resolver.getProperty(key);
		return (T) DataSize.ofBytes(FileSize.valueOf(value).getSize());
	}
}

如果 Spring 的 DataSize 转换失败,就用 Logback 自己的 FileSize.valueOf 再试一次。这是一个双解析器容错------因为 Logback 的 FileSize 支持 10MB、10mb、10 MB 等格式,而 Spring 的 DataSize 更严格。

7.10 SpringBootJoranConfigurator:两个 Spring 专属标签

java 复制代码
@Override
public void addElementSelectorAndActionAssociations(RuleStore ruleStore) {
	super.addElementSelectorAndActionAssociations(ruleStore);
	ruleStore.addRule(new ElementSelector("configuration/springProperty"), SpringPropertyAction::new);
	ruleStore.addRule(new ElementSelector("*/springProfile"), SpringProfileAction::new);
	ruleStore.addTransparentPathPart("springProfile");
}

两个规则:

  1. configuration/springProperty ------ 只能在 <configuration> 直接子元素位置使用。作用:把 Spring Environment 的属性读到 Logback 变量里:
xml 复制代码
<springProperty scope="context" name="appName" source="spring.application.name"/>
<appender ...>
    <file>/var/log/${appName}.log</file>
</appender>
  1. */springProfile ------ 可以在任意位置(所以 addTransparentPathPart("springProfile") 让它在 XPath 匹配时"透明",不打断父路径)。作用:按 profile 条件包含配置:
xml 复制代码
<springProfile name="dev">
    <logger name="com.example" level="DEBUG"/>
</springProfile>
<springProfile name="prod | staging">
    <root level="WARN"/>
</springProfile>

addTransparentPathPart 是关键------没有它,configuration/appender/springProfile/encoder 这样的嵌套路径就匹配不上 */springProfile。

SpringProfileIfNestedWithinSecondPhaseElementSanityChecker:

java 复制代码
@Override
protected void sanityCheck(Model topModel) {
	super.sanityCheck(topModel);
	performCheck(new SpringProfileIfNestedWithinSecondPhaseElementSanityChecker(), topModel);
}

这是一个配置校验器 :检查 <springProfile> 是否被嵌在 Logback 的"第二阶段元素"(如 <appender>)里------那种情况下 springProfile 不生效(因为 appender 是先创建的,profile 判断太晚)。

这就是 §6.1 提到的"为什么需要 -spring 变体"的深层原因之一:只有 SpringBootJoranConfigurator 能解析这些标签,而它只在 Spring Boot 加载配置时才用上。

7.11 RootLogLevelConfigurator:一个 META-INF/services 的小技巧

java 复制代码
public class RootLogLevelConfigurator extends ContextAwareBase implements Configurator {

	@Override
	public ExecutionStatus configure(LoggerContext loggerContext) {
		loggerContext.getLogger(Logger.ROOT_LOGGER_NAME).setLevel(Level.INFO);
		return ExecutionStatus.INVOKE_NEXT_IF_ANY;
	}

}

Javadoc:"Logback Configurator, registered through META-INF/services, that sets the root log level to Level.INFO."

Logback 1.3+ 引入了 Configurator SPI(通过 META-INF/services/ch.qos.logback.classic.spi.Configurator)。Spring Boot 注册一个,作用:在 Logback 自己完成初始化后、Spring Boot 接管前,把 root 级别设成 INFO。

为什么要这一层?因为 Logback 的默认 root 级别是 DEBUG 。在 Spring Boot 的 beforeInitialize()(加 SUPPRESS_ALL_FILTER)生效之前,如果有任何日志产生(比如 Logback 自己的状态输出),就会是 DEBUG 级别的。设成 INFO 能减少这类噪音。

ExecutionStatus.INVOKE_NEXT_IF_ANY ------ 不阻止后续 Configurator。

未核实 :META-INF/services 文件的确切位置与内容。自测:find core/spring-boot/src/main/resources/META-INF/services -type f。

7.12 getLoggerFactory() 与 SubstituteLoggerFactory 的忙等待

java 复制代码
private ILoggerFactory getLoggerFactory() {
	ILoggerFactory factory = LoggerFactory.getILoggerFactory();
	while (factory instanceof SubstituteLoggerFactory) {
		try {
			Thread.sleep(50);
		}
		catch (InterruptedException ex) {
			Thread.currentThread().interrupt();
			throw new IllegalStateException("Interrupted while waiting for non-substitute logger factory", ex);
		}
		factory = LoggerFactory.getILoggerFactory();
	}
	return factory;
}

SubstituteLoggerFactory 是 SLF4J 在初始化未完成时 返回的占位工厂(此时调用 getLogger 会返回一个"记录事件但不输出"的 logger,等初始化完成后回放)。

Spring Boot 在这里忙等待(每 50ms 重试)直到拿到真正的工厂。

这是一个潜在的风险点 :如果 SLF4J 初始化永远不完成(比如 classpath 冲突导致绑定失败),这里会无限循环 。 未核实:是否有超时机制。从代码看没有。自测:构造一个 SLF4J 无绑定的 classpath,观察是否 hang。

getLoggerContext() 的错误信息是本章最友好的一处:

java 复制代码
Assert.state(factory instanceof LoggerContext,
		() -> String.format(
				"LoggerFactory is not a Logback LoggerContext but Logback is on "
						+ "the classpath. Either remove Logback or the competing "
						+ "implementation (%s loaded from %s). If you are using "
						+ "WebLogic you will need to add 'org.slf4j' to "
						+ "prefer-application-packages in WEB-INF/weblogic.xml",
				factory.getClass(), getLocation(factory)));

不仅说明问题,还给出加载位置 (getLocation 从 ProtectionDomain.getCodeSource() 拿 jar 路径),甚至专门提到 WebLogic 的解决办法。这是"错误信息即文档"的范例。

7.13 reportConfigurationErrorsIfNecessary:把 Logback 状态变成异常

java 复制代码
private void reportConfigurationErrorsIfNecessary(LoggerContext loggerContext) {
	StringBuilder errors = new StringBuilder();
	List<Throwable> suppressedExceptions = new ArrayList<>();
	for (Status status : loggerContext.getStatusManager().getCopyOfStatusList()) {
		if (status.getLevel() == Status.ERROR) {
			errors.append((!errors.isEmpty()) ? String.format("%n") : "");
			errors.append(status);
			if (status.getThrowable() != null) {
				suppressedExceptions.add(status.getThrowable());
			}
		}
	}
	if (errors.isEmpty()) {
		if (!StatusUtil.contextHasStatusListener(loggerContext)) {
			this.statusPrinter.printInCaseOfErrorsOrWarnings(loggerContext);
		}
		return;
	}
	IllegalStateException ex = new IllegalStateException(
			String.format("Logback configuration error detected: %n%s", errors));
	suppressedExceptions.forEach(ex::addSuppressed);
	throw ex;
}

Logback 把配置问题记在 StatusManager 里(不抛异常)。Spring Boot 把它们捞出来,抛 IllegalStateException 并把原始异常作为 suppressed 附上去。

为什么要把配置错误变成启动失败?因为"日志配置错了但应用照常启动"会导致你以为在打日志其实没打------这是最难受的故障模式。

注意 if (!StatusUtil.contextHasStatusListener(loggerContext)) 才 print------如果用户自己配了 StatusListener,就不抢着打印。

7.14 cleanUp() 与 getShutdownHandler()

java 复制代码
@Override
public void cleanUp() {
	LoggerContext context = getLoggerContext();
	markAsUninitialized(context);
	super.cleanUp();
	if (this.bridgeHandlerInstalled) {
		removeJdkLoggingBridgeHandler();
		this.bridgeHandlerInstalled = false;
	}
	context.getStatusManager().clear();
	context.getTurboFilterList().remove(SUPPRESS_ALL_FILTER);
}

@Override
public Runnable getShutdownHandler() {
	return () -> getLoggerContext().stop();
}

cleanUp 四件事:取消初始化标记、卸载 JUL 桥接器、清空状态、移除抑制过滤器。

getShutdownHandler 返回 loggerContext.stop()------这是让 Logback 在 JVM 退出时 flush 缓冲区的关键。没有它,最后几条日志(包括"应用已停止")可能丢失。


8. 结构化日志:Spring Boot 3.4+ 的完整体系

结构化日志(Structured Logging)是 Spring Boot 近年最重要的日志特性。它让每条日志以 JSON 输出,可被 ELK / Graylog / Loki 直接摄入。

8.1 启用方式:三个层次

结构化日志的开关不是传统的 logging.* 属性,而是三种格式属性:

属性 系统属性 作用
logging.structured.format.console CONSOLE_LOG_STRUCTURED_FORMAT 控制台用结构化格式
logging.structured.format.file FILE_LOG_STRUCTURED_FORMAT 文件用结构化格式

值可以是:

  • 内置格式的 ID:ecs、gelf、logstash
  • 或 StructuredLogFormatter 实现类的全限定类名

例如:

yaml 复制代码
logging:
  structured:
    format:
      console: ecs
      file: logstash

工作原理 :回到 §7.7 的 DefaultLogbackConfiguration.createEncoder:

java 复制代码
private Encoder<ILoggingEvent> createEncoder(LogbackConfigurator config, String type) {
	Charset charset = resolveCharset(config, "${" + type + "_LOG_CHARSET}");
	String structuredLogFormat = resolve(config, "${" + type + "_LOG_STRUCTURED_FORMAT}");
	if (StringUtils.hasLength(structuredLogFormat)) {
		StructuredLogEncoder encoder = createStructuredLogEncoder(structuredLogFormat);
		encoder.setCharset(charset);
		return encoder;
	}
	PatternLayoutEncoder encoder = new PatternLayoutEncoder();
	encoder.setCharset(charset);
	encoder.setPattern(resolve(config, "${" + type + "_LOG_PATTERN}"));
	return encoder;
}

关键分支 :structuredLogFormat 有值 → 用 StructuredLogEncoder;否则 → 用传统的 PatternLayoutEncoder。

也就是说:结构化日志与传统 pattern 是互斥的,同一个 appender 只能选一个。 你不能"既要 JSON 又要自定义 pattern"。

如果你用 XML 配置,则用 Spring Boot 提供的 include:

xml 复制代码
<include resource="org/springframework/boot/logging/logback/structured-console-appender.xml"/>
<include resource="org/springframework/boot/logging/logback/structured-file-appender.xml"/>

对比传统版:

xml 复制代码
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
	<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
		<level>${CONSOLE_LOG_THRESHOLD}</level>
	</filter>
	<encoder class="org.springframework.boot.logging.logback.StructuredLogEncoder">
		<format>${CONSOLE_LOG_STRUCTURED_FORMAT}</format>
		<charset>${CONSOLE_LOG_CHARSET}</charset>
	</encoder>
</appender>

StructuredLogEncoder 有 format 和 charset 两个属性------又一次两套默认配置的对称。

8.2 StructuredLogEncoder:Environment 从 LoggerContext 里取

java 复制代码
public class StructuredLogEncoder extends EncoderBase<ILoggingEvent> {

	private final ThrowableProxyConverter throwableProxyConverter = new ThrowableProxyConverter();

	private @Nullable String format;
	private @Nullable StructuredLogFormatter<ILoggingEvent> formatter;
	private @Nullable Charset charset = StandardCharsets.UTF_8;

	@Override
	public void start() {
		Assert.state(this.format != null, "Format has not been set");
		this.formatter = createFormatter(this.format);
		super.start();
		this.throwableProxyConverter.start();
	}

	private StructuredLogFormatter<ILoggingEvent> createFormatter(String format) {
		Environment environment = (Environment) getContext().getObject(Environment.class.getName());
		Assert.state(environment != null, "Unable to find Spring Environment in logger context");
		return new StructuredLogFormatterFactory<>(ILoggingEvent.class, environment, this::addAvailableParameters,
				this::addCommonFormatters)
			.get(format);
	}
}

Environment 是从 Logback 的 LoggerContext 里取的,不是从 Spring 容器注入的。

因为 StructuredLogEncoder 是由 Logback 自己 new 出来的(XML 里写 class 名),Spring 无法注入。所以 §7.6 的 putInitializationContextObjects 把 Environment 放进了 context:

java 复制代码
private void putInitializationContextObjects(LoggerContext loggerContext,
		LoggingInitializationContext initializationContext) {
	withLoggingSuppressed(
			() -> loggerContext.putObject(Environment.class.getName(), initializationContext.getEnvironment()));
}

这是一个"跨框架传参"的技巧 :Logback 的 context object map 充当了 Spring 与 Logback 之间的桥梁。同样的手法在 §7.2 的 isAlreadyInitialized 里也用了。

addAvailableParameters 注入 ThrowableProxyConverter:

java 复制代码
private void addAvailableParameters(AvailableParameters availableParameters) {
	availableParameters.add(ThrowableProxyConverter.class, this.throwableProxyConverter);
}

addCommonFormatters 注册三种内置格式:

java 复制代码
private void addCommonFormatters(CommonFormatters<ILoggingEvent> commonFormatters) {
	commonFormatters.add(CommonStructuredLogFormat.ELASTIC_COMMON_SCHEMA, this::createEcsFormatter);
	commonFormatters.add(CommonStructuredLogFormat.GRAYLOG_EXTENDED_LOG_FORMAT, this::createGraylogFormatter);
	commonFormatters.add(CommonStructuredLogFormat.LOGSTASH, this::createLogstashFormatter);
}

8.3 CommonStructuredLogFormat:三个内置格式

java 复制代码
public enum CommonStructuredLogFormat {
	ELASTIC_COMMON_SCHEMA("ecs"),
	GRAYLOG_EXTENDED_LOG_FORMAT("gelf"),
	LOGSTASH("logstash");

	private final String id;
	...
}
枚举 ID 全称
ELASTIC_COMMON_SCHEMA ecs Elastic Common Schema
GRAYLOG_EXTENDED_LOG_FORMAT gelf Graylog Extended Log Format
LOGSTASH logstash Logstash JSON

8.4 ECS 格式的实际 JSON 结构

ElasticCommonSchemaStructuredLogFormatter.jsonMembers 定义了完整的输出。逐行看:

java 复制代码
members.add("@timestamp", ILoggingEvent::getInstant);
members.add("log").usingMembers((log) -> {
	log.add("level", ILoggingEvent::getLevel);
	log.add("logger", ILoggingEvent::getLoggerName);
});
members.add("process").usingMembers((process) -> {
	process.add("pid", environment.getProperty("spring.application.pid", Long.class)).whenNotNull();
	process.add("thread").usingMembers((thread) -> thread.add("name", ILoggingEvent::getThreadName));
});
ElasticCommonSchemaProperties.get(environment).jsonMembers(members);
members.add("message", ILoggingEvent::getFormattedMessage);
members.add().usingPairs(contextPairs.nested((pairs) -> {
	pairs.addMapEntries(ILoggingEvent::getMDCPropertyMap);
	pairs.add(ILoggingEvent::getKeyValuePairs, keyValuePairExtractor);
}));
Function<@Nullable ILoggingEvent, @Nullable Object> getThrowableProxy = (event) -> (event != null)
		? event.getThrowableProxy() : null;
members.add().whenNotNull(getThrowableProxy).usingMembers((throwableMembers) -> {
	throwableMembers.add("error").usingMembers((error) -> {
		error.add("type", ILoggingEvent::getThrowableProxy).as(IThrowableProxy::getClassName);
		error.add("message", ILoggingEvent::getThrowableProxy).as(IThrowableProxy::getMessage);
		error.add("stack_trace", extractor::stackTrace);
	});
});
members.add("tags", ILoggingEvent::getMarkerList)
	.whenNotNull()
	.as(ElasticCommonSchemaStructuredLogFormatter::getMarkers)
	.whenNotEmpty();
members.add("ecs").usingMembers((ecs) -> ecs.add("version", "8.11"));

产出的 JSON 形如:

json 复制代码
{
  "@timestamp": "2026-10-17T10:15:19.123456Z",
  "log": { "level": "INFO", "logger": "com.example.MyApplication" },
  "process": { "pid": 12345, "thread": { "name": "main" } },
  "service": { "name": "my-app", "version": "1.0.0", "environment": "prod", "node": { "name": "host-1" } },
  "message": "Started MyApplication in 1.234 seconds",
  "error": { "type": "java.lang.IllegalStateException", "message": "...", "stack_trace": "..." },
  "tags": ["MY_MARKER"],
  "ecs": { "version": "8.11" }
}

几个细节:

  1. ecs.version 硬编码 "8.11" ------ 这是 ECS 规范版本,不是 Spring Boot 版本。升级 Spring Boot 不会改它。
  2. process.pid 来自 environment.getProperty("spring.application.pid", Long.class) ,而不是 PID 系统属性。

这里有一个值得核实的不一致 :§4.2 里 LoggingSystemProperties 设置了 PID 系统属性,而 ECS formatter 却读 spring.application.pid 这个 Spring 属性。这两者不是同一个来源 ! ApplicationPid 写入 PID 系统属性;而 spring.application.pid 是由另一个机制(推测是 ApplicationPidFileWriter 或某个 EnvironmentPostProcessor)写入 Environment 的。 未核实 :spring.application.pid 由谁写入、何时写入。若 start() 时它还没被设置,process.pid 就会因 whenNotNull() 而整个字段消失 。自测:打印 ECS 日志看是否有 process.pid。

  1. ILoggingEvent::getInstant ------ Logback 1.3+ 才有 getInstant()(返回 java.time.Instant)。这意味着 Spring Boot 4 的 Logback 基线是 1.3+(实际是 1.5.x)。

  2. tags 递归展开 Marker 的引用:

java 复制代码
private static Set<String> getMarkers(List<Marker> markers) {
	Set<String> result = new TreeSet<>();
	addMarkers(result, markers.iterator());
	return result;
}

private static void addMarkers(Set<String> result, Iterator<Marker> iterator) {
	while (iterator.hasNext()) {
		Marker marker = iterator.next();
		result.add(marker.getName());
		if (marker.hasReferences()) {
			addMarkers(result, marker.iterator());
		}
	}
}

SLF4J 的 Marker 可以有引用(references)形成图。这里递归收集并放进 TreeSet(排序,保证输出稳定)。

  1. members.add().usingPairs(...) ------ add() 不带名字,表示"把这些 pair 直接合并到当前层级"(不是放进子对象)。这是 JsonWriter 的"扁平合并"能力。

8.5 ContextPairs:MDC 的两种布局

java 复制代码
public class ContextPairs {
	private final boolean include;
	private final String prefix;

	public <T> BiConsumer<T, BiConsumer<String, Object>> flat(String delimiter, Consumer<Pairs<T>> pairs) { ... }
	public <T> BiConsumer<T, BiConsumer<String, Object>> nested(Consumer<Pairs<T>> pairs) { ... }
}

MDC(Mapped Diagnostic Context)的数据可以两种形式输出:

  • flat(delimiter, ...) ------ 平铺,如 {"trace_id": "abc", "user_id": "u1"}
  • nested(...) ------ 嵌套(ECS 用),如 {"trace": {"id": "abc"}}

nested 内部用 joinWith("."),所以 MDC 的 key trace.id 会变成嵌套对象 {"trace":{"id":...}}。

include=false 时返回 none()(空 BiConsumer),整个 MDC 不输出。

配置:logging.structured.json.context.include(默认 true)与 logging.structured.json.context.prefix。

8.6 StructuredLoggingJsonProperties:七个维度的 JSON 定制

java 复制代码
record StructuredLoggingJsonProperties(Set<String> include, Set<String> exclude, Map<String, String> rename,
		Map<String, String> add, @Nullable StackTrace stackTrace, @Nullable Context context,
		Set<Class<? extends StructuredLoggingJsonMembersCustomizer<?>>> customizer) {

	static @Nullable StructuredLoggingJsonProperties get(Environment environment) {
		return Binder.get(environment)
			.bind("logging.structured.json", StructuredLoggingJsonProperties.class)
			.orElse(null);
	}
	...
}

七个维度:

字段 配置前缀 作用
include logging.structured.json.include 只保留这些路径(空 = 全部)
exclude logging.structured.json.exclude 排除这些路径
rename logging.structured.json.rename 路径重命名(map: 原路径 → 新名)
add logging.structured.json.add 额外添加的固定键值对
stackTrace logging.structured.json.stack-trace.* 堆栈打印配置
context logging.structured.json.context.* MDC 处理
customizer logging.structured.json.customizer 自定义 StructuredLoggingJsonMembersCustomizer 类名集合

注意 get() 返回 @Nullable ------没配 logging.structured.json.* 就返回 null,而不是一个全默认值的对象。后续所有地方都要判空(StructuredLogFormatterFactory 里到处是 properties != null)。

8.7 StackTrace:三种 printer 与"隐式 standard"

java 复制代码
record StackTrace(@Nullable String printer, @Nullable Root root, @Nullable Integer maxLength,
		@Nullable Integer maxThrowableDepth, @Nullable Boolean includeCommonFrames,
		@Nullable Boolean includeHashes) {

	@Nullable StackTracePrinter createPrinter(Environment environment) {
		String name = sanitizePrinter();
		if ("loggingsystem".equals(name) || (name.isEmpty() && !hasAnyOtherProperty())) {
			return null;
		}
		StandardStackTracePrinter standardPrinter = createStandardPrinter();
		if ("standard".equals(name) || name.isEmpty()) {
			return standardPrinter;
		}
		Assert.state(printer() != null, "'printer' must not be null");
		return (StackTracePrinter) new Instantiator<>(StackTracePrinter.class, (parameters) -> {
			parameters.add(StandardStackTracePrinter.class, standardPrinter);
			parameters.add(Environment.class, environment);
		}).instantiate(printer());
	}

	private String sanitizePrinter() {
		return Objects.toString(printer(), "").toLowerCase(Locale.ROOT).replace("-", "");
	}

	private boolean hasAnyOtherProperty() {
		return Stream.of(root(), maxLength(), maxThrowableDepth(), includeCommonFrames(), includeHashes())
			.anyMatch(Objects::nonNull);
	}
}

printer 的取值与语义:

printer 值 结果
未配 且 其它 5 个属性也没配 null → 用日志系统自己的 堆栈渲染(Logback 的 %wEx 等)
未配 但 其它属性配了至少一个 用 StandardStackTracePrinter(Spring Boot 自己的实现)
logging-system null(同上第一行)
standard StandardStackTracePrinter
其它(全限定类名) 用 Instantiator 反射实例化

为什么"配了其它属性就隐式用 standard"? 因为日志系统自己的堆栈渲染器不支持 maxLength、root=FIRST、includeHashes 这些定制。你既然表达了定制意愿,Spring Boot 就必须换成自己的实现。

这是一个隐式升级 的设计:用户不需要显式写 printer=standard,只要配了 max-length 就自动切换。

sanitizePrinter 做 toLowerCase + 去 -:所以 logging-system、loggingSystem、LOGGING_SYSTEM 等价。

createStandardPrinter:

java 复制代码
private StandardStackTracePrinter createStandardPrinter() {
	StandardStackTracePrinter printer = (root() == Root.FIRST) ? StandardStackTracePrinter.rootFirst()
			: StandardStackTracePrinter.rootLast();
	PropertyMapper map = PropertyMapper.get();
	printer = map.from(this::maxLength).to(printer, StandardStackTracePrinter::withMaximumLength);
	printer = map.from(this::maxThrowableDepth)
		.to(printer, StandardStackTracePrinter::withMaximumThrowableDepth);
	printer = map.from(this::includeCommonFrames)
		.to(printer, apply(StandardStackTracePrinter::withCommonFrames));
	printer = map.from(this::includeHashes).to(printer, apply(StandardStackTracePrinter::withHashes));
	return printer;
}

默认 rootLast(异常根因在最后,符合 Java 习惯);root=FIRST 时根因在最前(更利于快速定位)。

apply(...) 只对 true 生效:

java 复制代码
private BiFunction<StandardStackTracePrinter, Boolean, StandardStackTracePrinter> apply(
		UnaryOperator<StandardStackTracePrinter> action) {
	return (printer, value) -> (!value) ? printer : action.apply(printer);
}

未核实 :StandardStackTracePrinter 的完整实现(是否带哈希、如何截断)。withHashes 推测是为每个堆栈计算一个稳定哈希,便于日志聚合去重(这是 Elastic 推荐的实践)。自测:cat core/spring-boot/src/main/java/org/springframework/boot/logging/StandardStackTracePrinter.java。

8.8 StructuredLogFormatterFactory:Instantiator 的参数注入

java 复制代码
public StructuredLogFormatterFactory(Class<E> logEventType, Environment environment,
		@Nullable Consumer<AvailableParameters> availableParameters,
		Consumer<CommonFormatters<E>> commonFormatters) {
	StructuredLoggingJsonProperties properties = StructuredLoggingJsonProperties.get(environment);
	...
	this.instantiator = new Instantiator<>(Object.class, (allAvailableParameters) -> {
		allAvailableParameters.add(Environment.class, environment);
		allAvailableParameters.add(StructuredLoggingJsonMembersCustomizer.class,
				new JsonMembersCustomizerBuilder(properties).build());
		allAvailableParameters.add(StructuredLoggingJsonMembersCustomizer.Builder.class,
				new JsonMembersCustomizerBuilder(properties));
		allAvailableParameters.add(StackTracePrinter.class,
				(type) -> getStackTracePrinter(properties, environment));
		allAvailableParameters.add(ContextPairs.class, (type) -> getContextPairs(properties));
		if (availableParameters != null) {
			availableParameters.accept(allAvailableParameters);
		}
	}, failureHandler);
	...
}

Instantiator 是 Spring Boot 的工具类(org.springframework.boot.util.Instantiator),它根据构造器的参数类型从 AvailableParameters 里取值注入。这让 StructuredLogFormatter 的实现类可以按需声明 构造器参数------Environment、StackTracePrinter、ContextPairs、StructuredLoggingJsonMembersCustomizer、Logback 的 ThrowableProxyConverter 都可以有选择地要。

StructuredLogFormatter 的 Javadoc 明确列出了可用参数:

Implementing classes can declare the following parameter types in the constructor:

  • Environment
  • StructuredLoggingJsonMembersCustomizer
  • StructuredLoggingJsonMembersCustomizer.Builder
  • StackTracePrinter (may be null)
  • ContextPairs

When using Logback, implementing classes can also use the following parameter types in the constructor:

  • ch.qos.logback.classic.pattern.ThrowableProxyConverter

failureHandler:

java 复制代码
private static final FailureHandler failureHandler = (type, implementationName, failure) -> {
	if (!(failure instanceof ClassNotFoundException)) {
		throw new IllegalArgumentException(
				"Unable to instantiate " + implementationName + " [" + type.getName() + "]", failure);
	}
};

ClassNotFoundException 被吞掉(返回 null,继续尝试其它),其它异常直接抛。这允许用同一个类名字符串尝试多种类型。

get(String format):

java 复制代码
public StructuredLogFormatter<E> get(String format) {
	StructuredLogFormatter<E> formatter = this.commonFormatters.get(this.instantiator, format);
	formatter = (formatter != null) ? formatter : getUsingClassName(format);
	if (formatter != null) {
		return formatter;
	}
	throw new IllegalArgumentException(
			"Unknown format '%s'. Values can be a valid fully-qualified class name or one of the common formats: %s"
				.formatted(format, this.commonFormatters.getCommonNames()));
}

先查内置 ID,再当类名。错误信息里列出了所有可用的内置格式名。

类型检查:

java 复制代码
private void checkTypeArgument(Object formatter) {
	Class<?> typeArgument = GenericTypeResolver.resolveTypeArgument(formatter.getClass(),
			StructuredLogFormatter.class);
	Assert.state(this.logEventType.equals(typeArgument),
			() -> "Type argument of %s must be %s but was %s".formatted(formatter.getClass().getName(),
					this.logEventType.getName(), (typeArgument != null) ? typeArgument.getName() : "null"));
}

检查泛型实参 !如果你写了一个 StructuredLogFormatter<Log4j2Event> 却在 Logback 环境用,会得到明确错误:

python 复制代码
Type argument of com.example.MyFormatter must be ch.qos.logback.classic.spi.ILoggingEvent but was org.apache.logging.log4j.core.LogEvent

这是一个泛型类型安全的运行时检查------因为格式化器是按类名字符串加载的,编译期无法保证。

8.9 CorrelationIdFormatter:W3C trace context 的格式化

java 复制代码
public static final CorrelationIdFormatter DEFAULT = CorrelationIdFormatter.of("traceId(32),spanId(16)");

默认格式:traceId(32),spanId(16) ------ 对应 W3C Trace Context 的 traceparent 头。

Javadoc 给了完整例子:

The following example would return a formatted result of [01234567890123456789012345678901-0123456789012345]

java 复制代码
CorrelationIdFormatter formatter = CorrelationIdFormatter.of("traceId(32),spanId(16)");
Map<String, String> mdc = Map.of("traceId", "01234567890123456789012345678901", "spanId", "0123456789012345");
return formatter.format(mdc::get);

核心实现:

java 复制代码
public void formatTo(UnaryOperator<@Nullable String> resolver, Appendable appendable) {
	Predicate<Part> canResolve = (part) -> StringUtils.hasLength(resolver.apply(part.name()));
	try {
		if (this.parts.stream().anyMatch(canResolve)) {
			appendable.append('[');
			for (Iterator<Part> iterator = this.parts.iterator(); iterator.hasNext();) {
				appendable.append(iterator.next().resolve(resolver));
				if (iterator.hasNext()) {
					appendable.append('-');
				}
			}
			appendable.append("] ");
		}
		else {
			appendable.append(this.blank);
		}
	}
	catch (IOException ex) {
		throw new UncheckedIOException(ex);
	}
}

三个要点:

  1. 只要有一个 part 能解析,就输出全部(不能解析的用空白填充)。
  2. 一个都解析不了时输出 blank(等宽的空白占位)。
  3. blank 预先算好 :this.blank = String.format("[%s] ", parts.stream().map(Part::blank).collect(Collectors.joining(" ")))。

"输出总是定宽" ------这是为了让日志对齐。没有 trace 信息时,会输出 [ ] (一串空格),保持列对齐。

%correlationId 转换词在 defaults.xml 里注册:

xml 复制代码
<conversionRule conversionWord="correlationId" class="org.springframework.boot.logging.logback.CorrelationIdConverter"/>

而 LogbackLoggingSystem.getDefaultLogCorrelationPattern():

java 复制代码
@Override
protected String getDefaultLogCorrelationPattern() {
	return "%correlationId";
}

配合 AbstractLoggingSystem.getDefaultValueResolver:

java 复制代码
protected Function<@Nullable String, @Nullable String> getDefaultValueResolver(Environment environment) {
	String defaultLogCorrelationPattern = getDefaultLogCorrelationPattern();
	return (name) -> {
		String applicationPropertyName = LoggingSystemProperty.CORRELATION_PATTERN.getApplicationPropertyName();
		Assert.state(applicationPropertyName != null, "applicationPropertyName must not be null");
		if (StringUtils.hasLength(defaultLogCorrelationPattern) && applicationPropertyName.equals(name)
				&& environment.getProperty(LoggingSystem.EXPECT_CORRELATION_ID_PROPERTY, Boolean.class, false)) {
			return defaultLogCorrelationPattern;
		}
		return null;
	};
}

关键:只有 logging.expect-correlation-id=true 时,默认的 %correlationId 才生效。

java 复制代码
public static final String EXPECT_CORRELATION_ID_PROPERTY = "logging.expect-correlation-id";

为什么需要这个开关?因为 %correlationId 在没有 trace 上下文时会输出一串空格(定宽占位),对不使用链路追踪的应用是纯粹的噪音。所以默认关闭。

如果你用 Micrometer Tracing / OpenTelemetry(第 26 章),应设 logging.expect-correlation-id=true,日志里就会出现 [traceId-spanId]。


9. 全章决策表速查

9.1 日志系统选择

classpath 选中的 LoggingSystem
有 ch.qos.logback.classic.LoggerContext LogbackLoggingSystem
无 Logback,有 Log4j2 Log4J2LoggingSystem
两者都无 JavaLoggingSystem
-Dorg.springframework.boot.logging.LoggingSystem=none NoOpLoggingSystem
-Dorg.springframework.boot.logging.LoggingSystem=<类名> 反射实例化(需 ClassLoader 单参构造器)

9.2 配置文件查找

场景 使用的配置
logging.config=xxx xxx(SystemPropertyUtils.resolvePlaceholders 解析后)
classpath 有 logback.xml,没配 logFile reinitialize()(重新加载以应用系统属性)
classpath 有 logback.xml,配了 logFile 继续找 -spring 变体 → 否则默认配置
classpath 有 logback-spring.xml logback-spring.xml(支持 <springProperty> / <springProfile>)
都没有 DefaultLogbackConfiguration(编程式默认)

Logback 标准位置优先级:logback-test.groovy > logback-test.xml > logback.groovy > logback.xml

9.3 结构化日志格式

值 格式
ecs Elastic Common Schema(ecs.version=8.11)
gelf Graylog Extended Log Format
logstash Logstash JSON
全限定类名 自定义 StructuredLogFormatter(泛型实参必须匹配)

9.4 日志级别来源优先级

ini 复制代码
1. logging.level.<name>       (普通 logger 或组名)
2. --debug / --trace          (SPRING_BOOT_LOGGING_LOGGERS 预设)
3. logback.xml / defaults.xml 里的 <logger level="...">   ← 会被上面覆盖
4. Logback 默认(DEBUG),被 RootLogLevelConfigurator 改成 INFO

initializeSystem(加载配置文件)在 initializeFinalLoggingLevels(应用 logging.level.*)之前 ,所以 logging.level.* 最终生效。


10. 本章新增定律

  • 第三十六定律:基础设施的初始化时机由"它要为谁服务"决定。日志要服务自动配置,所以它必须早于自动配置,因此不能是自动配置(§1.1)。
  • 第三十七定律:跨框架的传参用对方的 context 容器 。Environment 通过 LoggerContext.putObject 传给 Logback 创建的 encoder(§8.2)。
  • 第三十八定律:两套等价实现是技术债 。DefaultLogbackConfiguration(Java)与 defaults.xml(XML)必须手动保持同步(§7.8)。
  • 第三十九定律:静默失效处必须有 WARN 。logback.configurationFile 的检测(§7.4)、OSIV 的默认开启(第 23 章)都是实例。
  • 第四十定律:隐式升级比显式声明友好 。logging.structured.json.stack-trace 只要配了任一子属性,就自动从"日志系统的堆栈渲染"切到 StandardStackTracePrinter(§8.7)。
  • 第四十一定律:AOT 下把"解析结果"序列化,而不是保留原始输入 。Logback 的 XML 被解析成 Model 对象树再 Java 序列化(§7.5)。

11. 本章未核实清单

按优先级:

  1. 三个 LoggingSystemFactory 的 @Order 值 (§3.2)。本章只确认了 Logback 是 HIGHEST_PRECEDENCE + 1024。
  2. spring.application.pid 由谁写入 Environment (§8.4)。若写入晚于 ECS formatter 构造,process.pid 会消失。
  3. logging.* 是否完全没有 ConfigurationProperties 类 (§4.2)。本章推断"全部靠 environment.getProperty 直读"。
  4. WebServerStartStopLifecycle.getPhase() 的值(§2.6)。
  5. Logback TurboFilter 的确切执行时机(§7.1)。
  6. SpringBootConfigurationFactory 的发现机制与优先级(§6.5)。
  7. META-INF/services 中 Configurator 的文件路径(§7.11)。
  8. StandardStackTracePrinter 的哈希算法与截断语义(§8.7)。
  9. getLoggerFactory() 忙等待是否有超时(§7.12)。推断无超时,可能 hang。
  10. setPackagingDataEnabled(true) 的性能开销(§7.6)。
  11. logging.file / logging.path 旧属性是否还有兼容层(§5)。

第三方源码(不在本仓库):SLF4J、Logback(含 TurboFilter、JoranConfigurator、Model、SizeAndTimeBasedRollingPolicy)、Log4j2(含 ConfigurationFactory、CompositeConfiguration)、jul-to-slf4j。


12. 本章小结

Spring Boot 4.1.1 的日志体系,核心事实是三条:

  1. 没有 LoggingAutoConfiguration 。日志由 LoggingApplicationListener(spring.factories 里的 ApplicationListener)在 ApplicationEnvironmentPreparedEvent 时初始化,早于自动配置整整三个启动阶段(§1)。
  2. LoggingSystem 是对三种日志后端的统一抽象 ,选择靠 spring.factories + @Order,Logback 优先(§3)。
  3. 配置项通过"系统属性"这条旁路传给日志框架 :logging.pattern.console → CONSOLE_LOG_PATTERN 系统属性 → Logback XML 里的 ${CONSOLE_LOG_PATTERN}(§4)。这是理解"为什么我的 logback.xml 能用 Spring Boot 的变量"的唯一答案。

以及两个"现代"部分:

  • 结构化日志 (§8)是一个完整的、可扩展的体系,含三个内置格式、七个定制维度、一个 Instantiator 驱动的参数注入机制。
  • AOT 支持 (§7.5)把 XML 解析结果序列化成 META-INF/spring/logback-model,并用"内容必须一致"的检查防止多上下文冲突。

13. 下一章预告

第 25 章进入 Actuator 与健康检查,那是 Spring Boot 可观测性的正面:

  • @Endpoint 的三种暴露技术(Web MVC / WebFlux / JMX)与端点 ID 的字符集限制
  • HealthIndicator 的贡献者模型、健康组(management.endpoint.health.group.*)与健康状态 HTTP 状态码映射
  • 管理端口子上下文 :为什么 management.server.port=8081 会创建第二个 ApplicationContext,它和主上下文共享什么、不共享什么
  • LoggersEndpoint 如何复用本章的 LoggingSystem bean 实现运行时改日志级别
  • 端点安全:EndpointRequest 与 Spring Security 的集成点

届时你会看到本章埋下的 springBootLoggingSystem / springBootLogFile / springBootLoggerGroups 三个 bean 被真正消费------这正是 §2.5 把它们注册进容器的原因。

相关推荐
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 22 章 · 数据源、连接池与事务
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 17 章 应用类型推断与上下文工厂:`WebApplicationType`
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 23 章 · JPA / Hibernate 与持久化模块
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 12 章 · 事件总线与 Bootstrap 上下文
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 05 章 · `ApplicationProperties` 与 `spring.main.*`:启动配置
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 07 章 · ConfigData 体系深潜:位置解析、profile 迭代求解
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 20 章 · WebFlux 与 Reactor Netty:响应式栈与编解码
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 21 章 · gRPC 新纪元:`spring-boot-grpc-server`
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 13 章 · Bean 定义加载:`BeanDefinitionLoader`、组件扫描与 Bean 命名
java·后端·开源