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 的执行。
为什么日志必须这么早?
- 第 7、8 步自己就要打日志 。
ApplicationContext的构造、Environment的绑定、各种ApplicationContextInitializer都会输出信息。如果日志系统还没初始化,这些输出会走"默认"配置(Logback 的默认BasicConfigurator,只打控制台、级别 DEBUG),完全不受logging.level.*控制。 spring.factories的加载过程本身要打日志 (SpringFactoriesLoader在某些路径下会 log debug)。- ConfigData 阶段(
spring.config.*)的警告 需要输出。第 7 章讲过ConfigDataNotFoundFailureAnalyzer等,它们依赖日志。 - 更根本的:自动配置引擎自己要打大量 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);
}
三点:
shutdownHookRegistered是static------ 同一个 JVM 里多个 Spring Boot 应用(测试里常见)只注册一次。- 注册到
SpringApplication.getShutdownHandlers(),而不是直接Runtime.addShutdownHook。这是一个 Spring Boot 自己管理的 shutdown handler 集合(第 4 章讲过,它在SpringApplication.shutdownHook里统一注册到 JVM)。 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 的 loggerorg.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));
}
};
}
两件事:
ROOT(忽略大小写)→null。LoggingSystem.ROOT_LOGGER_NAME = "ROOT",而setLogLevel(null, level)表示"设置根 logger"。所以logging.level.root=warn、logging.level.ROOT=warn、logging.level.Root=WARN都能设根级别。- 异常只 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(),看起来是冗余代码。可能是为了在测试中通过子类覆盖来注入 mockConsole。可自测: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.xmllogback-test.xml→logback-test-spring.xmllogback.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.TRACEnativeToSystem: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)。
两个条件:
jul-to-slf4j在 classpath。- 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.");
}
}
三分支:
- 已初始化 → 直接返回(多应用场景)。
- AOT 产出的配置可用 → 用它(Native 场景)。
- 否则 → 走
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:
- 资源 :
logback-model文件。 - Java 序列化 :所有 model 类及其字段类型(
registerJavaSerialization)。 - 反射:所有组件类型的 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
LogbackConfiguratorto 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");
}
两个规则:
configuration/springProperty------ 只能在<configuration>直接子元素位置使用。作用:把 SpringEnvironment的属性读到 Logback 变量里:
xml
<springProperty scope="context" name="appName" source="spring.application.name"/>
<appender ...>
<file>/var/log/${appName}.log</file>
</appender>
*/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" }
}
几个细节:
ecs.version硬编码"8.11"------ 这是 ECS 规范版本,不是 Spring Boot 版本。升级 Spring Boot 不会改它。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。
-
ILoggingEvent::getInstant------ Logback 1.3+ 才有getInstant()(返回java.time.Instant)。这意味着 Spring Boot 4 的 Logback 基线是 1.3+(实际是 1.5.x)。 -
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(排序,保证输出稳定)。
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:
EnvironmentStructuredLoggingJsonMembersCustomizerStructuredLoggingJsonMembersCustomizer.BuilderStackTracePrinter(may benull)ContextPairsWhen 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]
javaCorrelationIdFormatter 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);
}
}
三个要点:
- 只要有一个 part 能解析,就输出全部(不能解析的用空白填充)。
- 一个都解析不了时输出
blank(等宽的空白占位)。 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. 本章未核实清单
按优先级:
- 三个
LoggingSystemFactory的@Order值 (§3.2)。本章只确认了 Logback 是HIGHEST_PRECEDENCE + 1024。 spring.application.pid由谁写入 Environment (§8.4)。若写入晚于 ECS formatter 构造,process.pid会消失。logging.*是否完全没有ConfigurationProperties类 (§4.2)。本章推断"全部靠environment.getProperty直读"。WebServerStartStopLifecycle.getPhase()的值(§2.6)。- Logback
TurboFilter的确切执行时机(§7.1)。 SpringBootConfigurationFactory的发现机制与优先级(§6.5)。META-INF/services中Configurator的文件路径(§7.11)。StandardStackTracePrinter的哈希算法与截断语义(§8.7)。getLoggerFactory()忙等待是否有超时(§7.12)。推断无超时,可能 hang。setPackagingDataEnabled(true)的性能开销(§7.6)。logging.file/logging.path旧属性是否还有兼容层(§5)。
第三方源码(不在本仓库):SLF4J、Logback(含 TurboFilter、JoranConfigurator、Model、SizeAndTimeBasedRollingPolicy)、Log4j2(含 ConfigurationFactory、CompositeConfiguration)、jul-to-slf4j。
12. 本章小结
Spring Boot 4.1.1 的日志体系,核心事实是三条:
- 没有
LoggingAutoConfiguration。日志由LoggingApplicationListener(spring.factories里的ApplicationListener)在ApplicationEnvironmentPreparedEvent时初始化,早于自动配置整整三个启动阶段(§1)。 LoggingSystem是对三种日志后端的统一抽象 ,选择靠spring.factories+@Order,Logback 优先(§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如何复用本章的LoggingSystembean 实现运行时改日志级别- 端点安全:
EndpointRequest与 Spring Security 的集成点
届时你会看到本章埋下的 springBootLoggingSystem / springBootLogFile / springBootLoggerGroups 三个 bean 被真正消费------这正是 §2.5 把它们注册进容器的原因。