十年经验竟不懂Springboot日志

前言

日志,是开发中熟悉又陌生的伙伴,熟悉是因为我们经常会在各种场合打印日志,陌生是因为大部分时候我们都不太关心日志是怎么打印出来的,因为打印一条日志,在我们看来是一件太平常不过的事情了,特别是在宇宙第一框架Springboot的加持下,日志打印是怎么工作的就更没人关注了。

但是了解日志框架怎么工作,以及学会Springboot 怎么和Log4j2Logback 等日志框架集成,对我们扩展日志功能以及优雅打印日志大有好处,甚至在有些场景,还能通过调整日志的打印策略来提升我们的系统吞吐量。所以本文将以Springboot 集成Log4j2 为例,详细说明Springboot 框架下Log4j2 是如何工作的,你可能会担心,如果是使用Logback 日志框架该怎么办呢,其实Log4j2Logback 极其相似,Springboot 在启动时处理Log4j2 和处理Logback 也几乎是一样的套路,所以学会Springboot 框架下Log4j2 如何工作,切换成Logback也是轻轻松松的。

本文遵循一个该深则深,该浅则浅的整体指导方针,全方位的阐述Springboot中日志怎么工作,思维导图如下所示。

Springboot 版本:2.7.2
Log4j2 版本:2.17.2

正文

一. Log4j2简单工作原理分析

使用Log4j2 打印日志时,我们自己接触最多的就是Logger 对象了,Logger 对象叫做日志打印器,负责打印日志,一个Logger对象,结构简单示意如下。

实际打印日志的是Logger 对象使用的Appender 对象,至于Appender 对象怎么打印日志,不在我们本文的关注范围内。特别注意,在Log4j2 中,Logger 对象实际只是一个壳子,灵魂是其持有的LoggerConfig 对象,LoggerConfig 决定打印时使用哪些Appender 对象,以及Logger的级别。

LoggerConfigAppender 通常是在Log4j2 的配置文件中定义出来的,配置文件通常命名为Log4j2.xmlLog4j2 框架在初始化时,会去加载这个配置文件并解析成一个配置对象Configuration,示意如下。

我们每在配置文件的<Appenders >标签下增加一项,解析得到的Configurationappenders 中就多一个Appender ,每在<Loggers >标签下增加一项,解析得到的ConfigurationloggerConfigs 中就多一个LoggerConfig ,并且LoggerConfig 解析出来时,其和Appender的关系也就确认了。

Log4j2 中,还有一个LoggerContext 对象,这个对象持有上述的Configuration 对象,我们使用的每一个Logger ,一开始都会先去LoggerContextloggerRegistry 中获取,如果没有,则会创建一个Logger 出来再缓存到LoggerContextloggerRegistry 中,同时我们在创建Logger 时其实核心就是要为这个创建的Logger 找到它对应的LoggerConfig ,那么去哪里找LoggerConfig 呢,当然就是去Configuration 中找,所以LoggerLoggerContextConfiguration的关系可以描述成下面这样子。

所以Log4j2 在这种结构下,要修改日志打印器是十分方便的,我们通过LoggerContext 就可以拿到Configuration ,拿到Configuration 之后,我们就可以方便的操作LoggerConfig了,例如最常用的日志打印器级别热更新就是这么完成的。

在继续阅读后文之前,有一个很重要的概念需要阐述清楚,那就是对于Springboot 来说,Springboot 在操作Logger 时,操作的对象就是一个Logger ,比如要给一个名字为com.honey.LoginLogger 设置级别为DEBUG ,那么在Springboot 看来,它就是在设置名字为com.honey.LoginLogger 的级别为DEBUG ,但是具体到Log4j2 框架,其实底层是在设置名字为com.honey.LoginLoggerConfig 的级别为DEBUG ,而具体到Logback 框架,就是在设置名字为com.honey.LoginLogger 的级别为DEBUG

二. Springboot日志简单配置说明

我们在Springboot 中使用Log4j2 时,虽然大部分时候我们还是会提供一个Log4j2.xml 文件来供Log4j2 框架读取,但是Springboot 也提供了一些配置来供我们使用,在分析Springboot日志启动机制前,先学习一下里面的若干配置项可以方便我们后续的机制理解。

1. logging.file.name

假如我们像下面这样配置。

yaml 复制代码
logging:
  file:
    name: test.log

那么Springboot 会把日志内容输出一份到当前项目根路径下的test.log文件中。

2. logging.file.path

假如我们像下面这样配置。

yaml 复制代码
logging:
  file:
    path: /

那么Springboot 会把日志内容输出一份到指定目录下的spring.log文件中。

3. logging.level

假如我们像下面这样配置。

yaml 复制代码
logging:
  level:
    com.pww.App: warn

那么我们可以指定名称为com.pww.App 的日志打印器的级别为warn级别。

三. Springboot日志启动机制分析

通常我们使用Springboot 时,就算不提供Log4j2.xml 配置文件,Springboot 也能输出很漂亮的日志,那么Springboot 肯定在背后有帮我们完成Log4j2Logback 等框架的初始化,那么本节就刨析一下Springboot中的日志启动机制。

Springboot 中的日志启动主要依赖于LoggingApplicationListener ,这个监听器在Springboot启动流程中主要会监听如下三个事件。

  • ApplicationStartingEvent 。在启动SpringApplication 之后就发布该事件,先于EnvironmenApplicationContext可用之前发布;
  • ApplicationEnvironmentPreparedEvent 。在Environmen准备好之后立即发布;
  • ApplicationPreparedEvent 。在ApplicationContext完全准备好之后但刷新容器之前发布。

下面依次分析下监听到这些事件后,LoggingApplicationListener会完成一些什么事情来帮助初始化日志框架。

1. 监听到ApplicationStartingEvent

LoggingApplicationListeneronApplicationStartingEvent() 方法如下所示。

java 复制代码
private void onApplicationStartingEvent(ApplicationStartingEvent event) {
    // 读取org.springframework.boot.logging.LoggingSystem系统属性来加载得到LoggingSystem
    this.loggingSystem = LoggingSystem.get(event.getSpringApplication().getClassLoader());
    // 调用LoggingSystem的beforeInitialize()方法提前做一些初始化准备工作
    this.loggingSystem.beforeInitialize();
}

Springboot 中操作日志的最关键的一个对象就是LoggingSystem ,这个对象会在Springboot 的整个生命周期中掌控着日志,在LoggingApplicationListener 监听到ApplicationStartingEvent 事件后,第一件事情就是先读取org.springframework.boot.logging.LoggingSystem 系统属性,得到要加载的LoggingSystem 的全限定名,然后完成加载。如果是使用Log4j2 框架,对应的LoggingSystemLog4J2LoggingSystem ,如果是使用Logback 框架,对应的LoggingSystemLogbackLoggingSystem ,当然我们也可以在LoggingApplicationListener 监听到ApplicationStartingEvent 事件之前,提前把org.springframework.boot.logging.LoggingSystem 设置为我们自己提供的LoggingSystem 的全限定名,这样我们就可以对Springboot中的日志初始化做一些定制修改。

拿到LoggingSystem 后,就会调用其beforeInitialize() 方法来完成日志框架初始化前的一些准备,这里看一下Log4J2LoggingSystembeforeInitialize() 方法实现,如下所示。

java 复制代码
@Override
public void beforeInitialize() {
    LoggerContext loggerContext = getLoggerContext();
    if (isAlreadyInitialized(loggerContext)) {
        return;
    }
    super.beforeInitialize();
    // 添加一个过滤器
    // 这个过滤器会阻止所有日志的打印
    loggerContext.getConfiguration().addFilter(FILTER);
}

上述方法最关键的就是添加了一个过滤器,虽然叫做过滤器,但是实则为阻断器,因为这个FILTER 会阻止所有日志打印,Springboot这样设计是为了防止日志系统在完全完成初始化前打印出不可控的日志。

所以小结一下,LoggingApplicationListener 监听到ApplicationStartingEvent之后,主要完成两件事情。

  1. 从系统属性中拿到LoggingSystem的全限定名并完成加载;
  2. 调用LoggingSystembeforeInitialize() 方法来添加会拒绝打印任何日志的过滤器以阻止日志打印。

2. 监听到ApplicationEnvironmentPreparedEvent

LoggingApplicationListeneronApplicationEnvironmentPreparedEvent() 方法如下所示。

java 复制代码
private void onApplicationEnvironmentPreparedEvent(ApplicationEnvironmentPreparedEvent event) {
    SpringApplication springApplication = event.getSpringApplication();
    if (this.loggingSystem == null) {
        this.loggingSystem = LoggingSystem.get(springApplication.getClassLoader());
    }
    // 因为此时Environment已经完成了加载
    // 获取到Environment并继续调用initialize()方法
    initialize(event.getEnvironment(), springApplication.getClassLoader());
}

继续跟进LoggingApplicationListenerinitialize() 方法。

java 复制代码
protected void initialize(ConfigurableEnvironment environment, ClassLoader classLoader) {
    // 把通过logging.xxx配置的值设置到系统属性中
    getLoggingSystemProperties(environment).apply();
    this.logFile = LogFile.get(environment);
    if (this.logFile != null) {
        // 把logging.file.name和logging.file.path的值设置到系统属性中
        this.logFile.applyToSystemProperties();
    }
    // 基于预置的web和sql日志打印器初始化LoggerGroups
    this.loggerGroups = new LoggerGroups(DEFAULT_GROUP_LOGGERS);
    // 读取配置中的debug和trace是否设置为true
    // 哪个为true就把springBootLogging级别设置为什么
    // 同时设置为true则trace优先级更高
    initializeEarlyLoggingLevel(environment);
    // 调用到具体的LoggingSystem实际初始化日志框架
    initializeSystem(environment, this.loggingSystem, this.logFile);
    // 完成日志打印器组和日志打印器的级别的设置
    initializeFinalLoggingLevels(environment, this.loggingSystem);
    registerShutdownHookIfNecessary(environment, this.loggingSystem);
}

上述方法概括下来就是做了三部分的事情。

  1. 把日志相关配置设置到系统属性中。例如我们可以通过logging.pattern.console 来配置标准输出日志格式,但是在XML 文件里面没办法读取到logging.pattern.console 配置的值,此时就需要设置一个系统属性,属性名是CONSOLE_LOG_PATTERN ,属性值是logging.pattern.console 配置的值,后续在XML 文件中就可以通过${sys:CONSOLE_LOG_PATTERN }读取到logging.pattern.console 配置的值。下表是Springboot中日志配置和系统属性名的对应关系:
配置项 系统属性名
logging.exception-conversion-word EXCEPTION_CONVERSION_WORD
logging.pattern.console CONSOLE_LOG_PATTERN
logging.charset.console CONSOLE_LOG_CHARSET
logging.pattern.dateformat LOG_DATEFORMAT_PATTERN
logging.pattern.file FILE_LOG_PATTERN
logging.charset.file FILE_LOG_CHARSET
logging.pattern.level LOG_LEVEL_PATTERN
logging.file.name LOG_FILE
logging.file.path LOG_PATH
  1. 调用LoggingSysteminitialize() 方法来完成日志框架初始化。这里就是实际完成Log4j2Logback等框架的初始化;
  2. 在日志框架完成初始化后基于logging.level的配置来设置日志打印器组和日志打印器的级别。

上述第2点是Springboot 如何完成具体的日志框架的初始化,这个在后面章节中会详细分析。上述第3点是日志框架初始化完毕后,Springboot 如何帮助我们完成日志打印器组或日志打印器的级别的设置,这里就扯出来一个概念:日志打印器组,也就是LoggerGroup

我们如果要操作一个Logger ,那么实际就是要拿着这个Logger 的名称,去找到Logger ,然后再进行操作,这在Logger 不多的时候是没问题的,但是假如我有几十上百个Logger 呢,一个一个去找到Logger 再操作无疑是很不现实的,一个实际的场景就是修改Logger 的级别,如果是通过Logger 的名字去找到Logger 再修改级别,那么是很痛苦的一件事情,但是如果能够把所有Logger 按照功能进行分组,我们一组一组的去修改,一下子就优雅起来了,LoggerGroup就是干这个事情的。

一个LoggerGroup,有三个字段,说明如下。

  1. name 。表示LoggerGroup 的名字,要操作LoggerGroup 时,就通过name 来唯一确定一个LoggerGroup ,假如有一个LoggerGroup 名字为login ,那么我们可以通过logging.level.loggin=debug ,将这个LoggerGroup 下所有的Logger 的级别设置为debug
  2. members 。是当前LoggerGroup 里所有Logger的名字的集合;
  3. configuredLevel 。表示最近一次给LoggerGroup设置的级别。

Springboot 中,通过logging.group 可以配置LoggerGroup,示例如下。

yaml 复制代码
logging:
  group:
    login:
      - com.lee.controller.LoginController
      - com.lee.service.LoginService
      - com.lee.dao.LoginDao
    common:
      - com.lee.util
      - com.lee.config

结合logging.level 可以直接给一组Logger设置级别,示例如下。

yaml 复制代码
logging:
  level:
    login: info
    common: debug
  group:
    login:
      - com.lee.controller.LoginController
      - com.lee.service.LoginService
      - com.lee.dao.LoginDao
    common:
      - com.lee.util
      - com.lee.config

那么此时名称为loginLoggerGroup表示如下。

json 复制代码
{
    "name": "login",
    "members": [
        "com.lee.controller.LoginController",
        "com.lee.service.LoginService",
        "com.lee.dao.LoginDao"
    ],
    "configuredLevel": "INFO"
}

名称为commonLoggerGroup表示如下。

json 复制代码
{
    "name": "common",
    "members": [
        "com.lee.util",
        "com.lee.config"
    ],
    "configuredLevel": "DEBUG"
}

最后再看一下Springboot 中预置的LoggerGroup ,有两个,名字分别为websql,如下所示。

json 复制代码
{
    "name": "web",
    "members": [
        "org.springframework.core.codec",
        "org.springframework.http",
        "org.springframework.web",
        "org.springframework.boot.actuate.endpoint.web",
        "org.springframework.boot.web.servlet.ServletContextInitializerBeans"
    ],
    "configuredLevel": ""
}
json 复制代码
{
    "name": "sql",
    "members": [
        "org.springframework.jdbc.core",
        "org.hibernate.SQL",
        "org.jooq.tools.LoggerListener"
    ],
    "configuredLevel": ""
}

至于websql 这两个LoggerGroup 的级别是什么,有两种手段来指定,第一种是通过配置debug=true 来将websql 这两个LoggerGroup 的级别指定为DEBUG ,第二种是通过logging.level.weblogging.level.sql 来指定websql 这两个LoggerGroup的级别,其中第二种优先级高于第一种。

上面最后讲的这一点,其实就是告诉我们怎么来控制Springboot 自己的相关的日志的打印级别,如果配置debug=true ,那么如下的Springboot 自己的LoggerGroupLogger 级别会设置为debug

txt 复制代码
sql
web
org.springframework.boot

如果配置trace=true ,那么如下的Springboot 自己的Logger 级别会设置为trace

txt 复制代码
org.springframework
org.apache.tomcat
org.apache.catalina
org.eclipse.jetty
org.hibernate.tool.hbm2ddl

现在小结一下,监听到ApplicationEnvironmentPreparedEvent 事件后,Springboot主要完成三件事情。

  1. 把通过配置文件配置的日志相关属性设置为系统属性;
  2. 实际完成日志框架的初始化;
  3. 设置Springboot 和用户自定义的LoggerGroupLogger级别。

3. 监听到ApplicationPreparedEvent

LoggingApplicationListeneronApplicationPreparedEvent() 方法如下所示。

java 复制代码
private void onApplicationPreparedEvent(ApplicationPreparedEvent event) {
    ConfigurableListableBeanFactory beanFactory = event.getApplicationContext().getBeanFactory();
    if (!beanFactory.containsBean(LOGGING_SYSTEM_BEAN_NAME)) {
        // 把实际加载的LoggingSystem注册到容器中
        beanFactory.registerSingleton(LOGGING_SYSTEM_BEAN_NAME, this.loggingSystem);
    }
    if (this.logFile != null && !beanFactory.containsBean(LOG_FILE_BEAN_NAME)) {
        // 把实际使用的LogFile注册到容器中
        beanFactory.registerSingleton(LOG_FILE_BEAN_NAME, this.logFile);
    }
    if (this.loggerGroups != null && !beanFactory.containsBean(LOGGER_GROUPS_BEAN_NAME)) {
        // 把保存着所有LoggerGroup的LoggerGroups注册到容器中
        beanFactory.registerSingleton(LOGGER_GROUPS_BEAN_NAME, this.loggerGroups);
    }
}

主要就是把之前加载的LoggingSystemLogFileLoggerGroups 添加到Spring 容器中,进行到这里,其实整个日志框架已经完成初始化了,这里只是把一些和日志密切相关的一些对象注册为容器中的bean

最后,本节以下图对Springboot日志启动流程做一个总结。

四. Springboot集成Log4j2原理说明

Springboot 中使用Log4j2 时,我们不提供Log4j2 的配置文件也能打印日志,而我们提供了Log4j2 的配置文件后日志打印行为又会以我们提供的配置文件为准,这里面其实Springboot 为我们做了很多事情,当我们不提供Log4j2 配置文件时,Springboot 会加载其预置的配置文件,并且会根据我们是否配置了logging.file.xxx 自动决定是加载预置的log4j2.xml 还是log4j2-file.xml ,而与此同时Springboot 也会尽可能的去搜索我们提供的配置文件,无论我们在classpath 下提供的配置文件名字是Log4j2.xml 还是Log4j2-spring.xml ,都是能够被Springboot搜索到并加载的。

上述的Springboot 集成Log4j2 的行为,全部发生在Log4J2LoggingSystem中,本节将对这里面的流程和原理进行说明。

在第三节中已经知道,Springboot 启动时,当LoggingApplicationListener 监听到ApplicationEnvironmentPreparedEvent 事件后,最终会调用到LoggingApplicationListenerinitializeSystem() 方法来完成日志框架的初始化,所以我们先看一下这里的逻辑是什么,源码实现如下。

java 复制代码
private void initializeSystem(ConfigurableEnvironment environment, LoggingSystem system, LogFile logFile) {
    // 读取环境变量中的logging.config作为用户提供的配置文件路径
    String logConfig = StringUtils.trimWhitespace(environment.getProperty(CONFIG_PROPERTY));
    try {
        // 创建LoggingInitializationContext用于传递Environment对象
        LoggingInitializationContext initializationContext = new LoggingInitializationContext(environment);
        if (ignoreLogConfig(logConfig)) {
            // 1. 没有配置logging.config
            system.initialize(initializationContext, null, logFile);
        } else {
            // 2. 配置了logging.config
            system.initialize(initializationContext, logConfig, logFile);
        }
    } catch (Exception ex) {
        // 省略异常处理
    }
}

LoggingApplicationListenerinitializeSystem() 方法会读取logging.config 环境变量得到用户提供的配置文件路径,然后带着配置文件路径,调用到Log4J2LoggingSysteminitialize() 方法,所以后续分两种情况讨论,即没配置logging.config 和有配置logging.config

1. 没配置logging.config

Log4J2LoggingSysteminitialize() 方法如下所示。

java 复制代码
@Override
public void initialize(LoggingInitializationContext initializationContext, String configLocation, LogFile logFile) {
    LoggerContext loggerContext = getLoggerContext();
    // 判断LoggerContext的ExternalContext是不是当前LoggingSystem的全限定名
    // 如果是则表明当前LoggingSystem已经执行过初始化逻辑
    if (isAlreadyInitialized(loggerContext)) {
        return;
    }
    // 移除之前添加的防噪过滤器
    loggerContext.getConfiguration().removeFilter(FILTER);
    // 调用到父类AbstractLoggingSystem的initialize()方法
    // 注意因为没有配置logging.config所以这里configLocation为null
    super.initialize(initializationContext, configLocation, logFile);
    // 将当前LoggingSystem的全限定名设置给LoggerContext的ExternalContext
    // 表明当前LoggingSystem已经对LoggerContext执行过初始化逻辑
    markAsInitialized(loggerContext);
}

上述方法会继续调用到AbstractLoggingSysteminitialize() 方法,并且因为没有配置logging.config ,所以传递过去的configLocation 参数为null ,下面看一下AbstractLoggingSysteminitialize() 方法的实现,如下所示。

java 复制代码
@Override
public void initialize(LoggingInitializationContext initializationContext, String configLocation, LogFile logFile) {
    if (StringUtils.hasLength(configLocation)) {
        initializeWithSpecificConfig(initializationContext, configLocation, logFile);
        return;
    }
    // 基于约定寻找配置文件并完成初始化
    initializeWithConventions(initializationContext, logFile);
}

因为configLocationnull ,所以会继续调用到initializeWithConventions() 方法完成初始化,并且初始化使用到的配置文件,Springboot 会按照约定的名字去classpath 寻找,下面看一下initializeWithConventions() 方法的实现。

java 复制代码
private void initializeWithConventions(LoggingInitializationContext initializationContext, LogFile logFile) {
    // 搜索标准日志配置文件路径
    String config = getSelfInitializationConfig();
    if (config != null && logFile == null) {
        reinitialize(initializationContext);
        return;
    }
    if (config == null) {
        // 搜索Spring日志配置文件路径
        config = getSpringInitializationConfig();
    }
    if (config != null) {
        // 如果搜索到约定的配置文件则进行配置文件加载
        loadConfiguration(initializationContext, config, logFile);
        return;
    }
    // 如果搜索不到则使用LoggingSystem同目录下的配置文件
    loadDefaults(initializationContext, logFile);
}

上述方法中,首先会去搜索标准日志配置文件路径,其实就是判断classpath下是否存在如下名字的配置文件。

txt 复制代码
log4j2-test.properties
log4j2-test.json
log4j2-test.jsn
log4j2-test.xml
log4j2.properties
log4j2.json
log4j2.jsn
log4j2.xml

如果不存在,则再去搜索Spring 日志配置文件路径,也就是判断classpath下是否存在如下名字的配置文件。

txt 复制代码
log4j2-test-spring.properties
log4j2-test-spring.json
log4j2-test-spring.jsn
log4j2-test-spring.xml
log4j2-spring.properties
log4j2-spring.json
log4j2-spring.jsn
log4j2-spring.xml

如果都找不到,此时Springboot 就会将Log4J2LoggingSystem 同目录下的log4j2.xml (无LogFile )或log4j2-file.xml (有LogFile )作为日志配置文件,所以不用担心找不到配置文件,有Springboot 为我们进行兜底。在获取到配置文件路径后,最终会调用到Log4J2LoggingSystem如下的加载配置的方法。

java 复制代码
protected void loadConfiguration(String location, LogFile logFile, List<String> overrides) {
    Assert.notNull(location, "Location must not be null");
    try {
        List<Configuration> configurations = new ArrayList<>();
        LoggerContext context = getLoggerContext();
        // 根据配置文件路径加载得到Configuration并添加到集合中
        configurations.add(load(location, context));
        // 加载logging.log4j2.config.override配置的配置文件为Configuration
        // 所有加载的Configuration都要添加到configurations集合中
        for (String override : overrides) {
            configurations.add(load(override, context));
        }
        // 如果得到了大于1个的Configuration则基于所有Configuration创建CompositeConfiguration
        Configuration configuration = (configurations.size() > 1) ? createComposite(configurations)
                : configurations.iterator().next();
        // 将加载得到的Configuration启动并设置给LoggerContext
        // 这里会将加载得到的Configuration覆盖LoggerContext持有的老的Configuration
        context.start(configuration);
    } catch (Exception ex) {
        throw new IllegalStateException("Could not initialize Log4J2 logging from " + location, ex);
    }
}

上述方法中实际就会拿着配置文件的路径去加载得到Configuration ,与此同时还会拿到所有通过logging.log4j2.config.override 配置的路径,去加载得到Configuration ,最终如果得到大于1个的Configuration ,则将这些Configuration 创建为CompositeConfiguration 。这里可能会有疑问,logging.log4j2.config.override 到底是一个什么东西,其实不难发现,无论是通过logging.config 指定了配置文件路径,还是按照Springboot 约定提供了配置文件,亦或者使用了Springboot 预置的配置文件,其实最终都只能得到一个配置文件路径然后得到一个Configuration ,那么怎么才能加载多份配置文件呢,那就要通过logging.log4j2.config.override来指定多个配置文件路径,使用示例如下。

yaml 复制代码
logging:
  config: classpath:Log4j2.xml
  log4j2:
    config:
      override:
        - classpath:Log4j2-custom1.xml
        - classpath:Log4j2-custom2.xml

如果按照上面这样配置,那么最终就会加载得到三个Configuration ,然后再基于这三个Configuration 创建得到一个CompositeConfiguration

在加载得到Configuration 之后,就会调用到LoggerContextstart() 方法完成Log4j2框架的初始化,那么这里其实会做如下三件事情。

  1. 调用Configurationstart() 方法完成配置对象的初始化。这里其实就是将我们在配置文件中定义的各种AppednerLoggerConfig等都创建出来并完成启动;
  2. 将启动完毕的Configuration 设置给LoggerContext 。这里会把LoggerContext 持有的老的Configuration 覆盖掉,所以如果LoggerContext 之前持有其它的Configuration ,那么其实在Springboot 日志初始化完毕后老的Configuration会被丢弃掉;
  3. 更新Logger 。如果之前有已经创建好的Logger ,那么就基于新的Configuration 替换掉这些Logger 持有的LoggerConfig

至此,没配置logging.config时的初始化逻辑就分析完毕。

2. 有配置logging.config

有配置logging.config 时,情况就变得简单了。还是从Log4J2LoggingSysteminitialize() 方法出发,跟一下源码。

java 复制代码
@Override
public void initialize(LoggingInitializationContext initializationContext, String configLocation, LogFile logFile) {
    LoggerContext loggerContext = getLoggerContext();
    if (isAlreadyInitialized(loggerContext)) {
        return;
    }
    loggerContext.getConfiguration().removeFilter(FILTER);
    // 调用到父类AbstractLoggingSystem的initialize()方法
    // 注意因为配置了logging.config所以这里configLocation不为null
    super.initialize(initializationContext, configLocation, logFile);
    markAsInitialized(loggerContext);
}

继续跟进AbstractLoggingSysteminitialize() 方法,如下所示。

java 复制代码
@Override
public void initialize(LoggingInitializationContext initializationContext, String configLocation, LogFile logFile) {
    if (StringUtils.hasLength(configLocation)) {
        // 基于指定的配置文件完成初始化
        initializeWithSpecificConfig(initializationContext, configLocation, logFile);
        return;
    }
    initializeWithConventions(initializationContext, logFile);
}

由于指定了配置文件,所以会调用到AbstractLoggingSysteminitializeWithSpecificConfig() 方法,该方法没有什么额外逻辑,最终会执行到和没配置logging.config 时一样的Log4J2LoggingSystem的加载配置的方法,如下所示。

java 复制代码
protected void loadConfiguration(String location, LogFile logFile, List<String> overrides) {
    Assert.notNull(location, "Location must not be null");
    try {
        List<Configuration> configurations = new ArrayList<>();
        LoggerContext context = getLoggerContext();
        // 根据配置文件路径加载得到Configuration并添加到集合中
        configurations.add(load(location, context));
        // 加载logging.log4j2.config.override配置的配置文件为Configuration
        // 所有加载的Configuration都要添加到configurations集合中
        for (String override : overrides) {
            configurations.add(load(override, context));
        }
        // 如果得到了大于1个的Configuration则基于所有Configuration创建CompositeConfiguration
        Configuration configuration = (configurations.size() > 1) ? createComposite(configurations)
                : configurations.iterator().next();
        // 将加载得到的Configuration启动并设置给LoggerContext
        // 这里会将加载得到的Configuration覆盖LoggerContext持有的老的Configuration
        context.start(configuration);
    } catch (Exception ex) {
        throw new IllegalStateException("Could not initialize Log4J2 logging from " + location, ex);
    }
}

所以配置了logging.config 时,就会以logging.config 指定的配置文件作为最终使用的配置文件,而不会去基于约定搜索配置文件,同时也不会去使用LoggingSystem同目录下预置的配置文件。

小结一下,Springboot 集成Log4j2日志框架时,主要分为两种情况:

  1. 没配置logging.config 。这种情况下,Springboot 会基于约定努力去寻找符合的配置文件,如果找不到则会使用预置的配置文件且预置的配置文件需要在LoggingSystem 的同目录下,拿到配置文件后就会加载为Configuration 然后替换掉LoggerContext 里的旧的Configuration,此时就完成日志框架初始化;
  2. 有配置logging.config 。这种情况下,会将logging.config 指定的配置文件加载为Configuration ,然后替换掉LoggerContext 里的旧的Configuration,此时就完成日志框架初始化。

无论有没有配置logging.config ,都只能加载一个配置文件为Configuration ,如果想加载多个Configuration ,那么需要通过logging.log4j2.config.override 配置多个配置文件路径,此时就能加载多个Configuration 来初始化Log4j2日志框架了。

Springboot 集成Log4j2日志框架的流程图如下所示。

五. Springboot日志打印器级别热更新

在日志打印中,一条日志在发起打印时,会根据我们的指定携带一个日志级别,同时打印日志的日志打印器,也有一个级别,日志打印器只能打印级别高于或等于自身的日志。

由于日志打印时,日志级别是由代码决定的,所以日志级别除非改代码,否则无法改变,但是日志打印器的级别是可以随时更改的,最简单的方式就是通过配置环境变量来更改logging.level ,此时我们的应用进程所处的容器就会重启,就可以读取到我们更改后的logging.level ,最终完成日志打印器级别的修改。但是这种方式会使应用重启,导致流量受损,我们更希望的是通过一种热更新的方式来修改日志打印器的级别,spring-boot-actuator 包中提供了LoggersEndpoint 来完成日志打印器级别热更新,所以本节将结合LoggersEndpoint 的简单使用和实现原理,说明一下Springboot中,如何热更新日志打印器级别。

1. LoggersEndpoint简单使用

LoggersEndpointspring-boot-actuator 提供,可以暴露一些端点用于获取Springboot 应用中的所有日志打印器信息及其级别信息以及热更新日志打印器级别,由于默认情况下,LoggersEndpoint 暴露的端点只能通过JMX 的方式访问,所以想要通过HTTP 请求的方式访问到LoggersEndpoint,需要做如下配置。

yaml 复制代码
management:
  server:
    address: 127.0.0.1
    port: 10999
  endpoints:
    web:
      base-path: /actuator
      exposure:
        include: loggers    # 设置LoggersEndpoint可以通过HTTP方式访问
  endpoint:
    loggers:
      enabled: true     # 打开LoggersEndpoint

按照上述这么配置,我们可以通过GET调用如下接口拿到当前所有的日志打印器的相关数据。

txt 复制代码
http://localhost:10999/actuator/loggers

获取数据如下所示。

json 复制代码
{
    "levels": [
        "OFF",
        "FATAL",
        "ERROR",
        "WARN",
        "INFO",
        "DEBUG",
        "TRACE"
    ],
    "loggers": {
        "ROOT": {
            "configuredLevel": null,
            "effectiveLevel": "INFO"
        },
        "org.springframework.boot.actuate.autoconfigure.web.server": {
            "configuredLevel": null,
            "effectiveLevel": "DEBUG"
        },
        "org.springframework.http.converter.ResourceRegionHttpMessageConverter": {
            "configuredLevel": null,
            "effectiveLevel": "ERROR"
        }
    },
    "groups": {
        "web": {
            "configuredLevel": null,
            "members": [
                "org.springframework.core.codec",
                "org.springframework.http",
                "org.springframework.web",
                "org.springframework.boot.actuate.endpoint.web",
                "org.springframework.boot.web.servlet.ServletContextInitializerBeans"
            ]
        },
        "login": {
            "configuredLevel": "INFO",
            "members": [
                "com.lee.controller.LoginController",
                "com.lee.service.LoginService",
                "com.lee.dao.LoginDao"
            ]
        },
        "common": {
            "configuredLevel": "DEBUG",
            "members": [
                "com.lee.util",
                "com.lee.config"
            ]
        },
        "sql": {
            "configuredLevel": null,
            "members": [
                "org.springframework.jdbc.core",
                "org.hibernate.SQL",
                "org.jooq.tools.LoggerListener"
            ]
        }
    }
}

上述内容中,返回的levels 表示当前支持的日志级别,返回的loggers 表示当前所有日志打印器的级别信息,返回的groups 表示当前所有日志打印器组的级别信息,但是请注意,上述示例中的loggers 其实做了大量的删减,实际调用接口时得到的loggers 里面的内容会非常非常多,因为所有的日志打印器的信息都会被输出出来。此外,上述内容中出现的configuredLevel 字段表示当前日志打印器或日志打印器组被设置过的级别,也就是只要通过LoggersEndpoint 给某个日志打印器或日志打印器组设置过级别,那么对应的configuredLevel 字段就有值,最后上述内容中出现的effectiveLevel字段表示当前日志打印器正在生效的级别。

如果只想看某个日志打印器或日志打印器组的级别信息,可以调用如下的GET接口。

txt 复制代码
http://localhost:10999/actuator/loggers/{日志打印器名或日志打印器组名}

如果pathVariable是日志打印器名,那么会得到如下结果。

json 复制代码
{
    "configuredLevel": null,
    "effectiveLevel": "INFO"
}

如果pathVariable是日志打印器组名,那么会得到如下结果。

json 复制代码
{
    "configuredLevel": null,
    "members": [
        "org.springframework.core.codec",
        "org.springframework.http",
        "org.springframework.web",
        "org.springframework.boot.actuate.endpoint.web",
        "org.springframework.boot.web.servlet.ServletContextInitializerBeans"
    ]
}

除了查询日志打印器或日志打印器组的级别信息,LoggersEndpoint 更重要的功能是设置级别,比如可以通过如下POST接口来设置级别。

txt 复制代码
http://localhost:10999/actuator/loggers/{日志打印器名或日志打印器组名}
json 复制代码
{
	"configuredLevel": "DEBUG"
}

此时对应的日志打印器日志打印器组 的级别就会更新为设置的级别,并且其configuredLevel也会更新为设置的级别。

2. LoggersEndpoint原理分析

这里主要关注LoggersEndpoint 如何实现日志打印器级别的热更新。LoggersEndpoint实现日志打印器级别的热更新对应的端点方法如下所示。

java 复制代码
@WriteOperation
public void configureLogLevel(@Selector String name, @Nullable LogLevel configuredLevel) {
    Assert.notNull(name, "Name must not be empty");
    // 先尝试获取到LoggerGroup
    LoggerGroup group = this.loggerGroups.get(name);
    if (group != null && group.hasMembers()) {
        // 如果能获取到LoggerGroup则对组下每个Logger热更新级别
        group.configureLogLevel(configuredLevel, this.loggingSystem::setLogLevel);
        return;
    }
    // 获取不到LoggerGroup则按照Logger来处理
    this.loggingSystem.setLogLevel(name, configuredLevel);
}

上述方法的name 即可以是Logger 的名称,也可以是LoggerGroup 的名称,如果是Logger 的名称,那么就基于LoggingSystemsetLogLevel() 方法来设置这个Logger 的级别,如果是LoggerGroup 的名称,那么就遍历这个组下所有的Logger ,每个遍历到的Logger 都基于LoggingSystemsetLogLevel() 方法来设置级别。

所以实际上LoggersEndpoint 热更新日志打印器级别,还是依赖的对应日志框架的LoggingSystem

3. Log4J2LoggingSystem热更新原理

由于本文是基于Log4j2 日志框架进行讨论,所以这里选择分析Log4J2LoggingSystemsetLogLevel() 方法,来探究Logger级别如何热更新。

在开始分析前,有一点需要重申,那就是对于Log4j2 来说,Logger 只是壳子,灵魂是Logger 持有的LoggerConfig ,所以更新Log4j2 里面的Logger 的级别,其实就是要去更新其持有的LoggerConfig的级别。

Log4J2LoggingSystemsetLogLevel() 方法如下所示。

java 复制代码
@Override
public void setLogLevel(String loggerName, LogLevel logLevel) {
    // 将LogLevel转换为Level
    setLogLevel(loggerName, LEVELS.convertSystemToNative(logLevel));
}

LogLevelSpringboot 中的日志级别对象,LevelLog4j2 的日志级别对象,所以需要先将LogLevel 转换为Level,然后继续调用如下方法。

java 复制代码
private void setLogLevel(String loggerName, Level level) {
    // 从Configuration中根据loggerName获取到对应的LoggerConfig
    LoggerConfig logger = getLogger(loggerName);
    if (level == null) {
        // 2. 移除LoggerConfig或设置LoggerConfig级别为null
        clearLogLevel(loggerName, logger);
    } else {
        // 1. 添加LoggerConfig或设置LoggerConfig级别
        setLogLevel(loggerName, logger, level);
    }
    // 3. 更新Logger
    getLoggerContext().updateLoggers();
}

通过第一节知道,Log4j2Configuration 对象有一个字段叫做loggerConfigs ,所以上面首先就是通过loggerNameloggerConfigs 中匹配对应的LoggerConfig ,那么这里就会存在一个问题,那就是配置文件里面每配一个LoggerloggerConfigs 才会增加一个LoggerConfig ,所以实际上loggerConfigs 里面的LoggerConfig 并不会很多,比如我们提供了如下一个Log4j2.xml文件。

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="INFO">
    <Appenders>
        <Console name="MyConsole"/>
    </Appenders>

    <Loggers>
        <Root level="INFO">
            <Appender-ref ref="MyConsole"/>
        </Root>
        <Logger name="com.honey" level="WARN">
            <Appender-ref ref="MyConsole"/>
        </Logger>
        <Logger name="com.honey.auth.Login" level="DEBUG">
            <Appender-ref ref="MyConsole"/>
        </Logger>
    </Loggers>
</Configuration>

那么实际加载得到的ConfigurationloggerConfigs 只有下面这几个名字的LoggerConfig

txt 复制代码
""
com.honey
com.honey.auth.Login

其中空字符串是根日志打印器(rootLogger )的名字。此时如果在调用Log4J2LoggingSystemsetLogLevel() 方法时传入的loggerNamecom.honey.auth.Login ,我们可以很顺利的从ConfigurationloggerConfigs 中拿到名字是com.honey.auth.LoginLoggerConfig ,可要是传入的loggerNamecom.honey.auth.Logout 呢,那么获取出来的LoggerConfig 肯定是null ,此时该怎么处理呢,难道就不设置日志打印器的级别了吗,当然不是的,Springboot 在这里做了一个巨巧妙的设计,就是如果热更新Log4j2 时通过loggerName 没有获取到LoggerConfig ,那么Springboot 就会创建一个LevelSetLoggerConfigLoggerConfig 的子类)然后添加到ConfigurationloggerConfigs 中。下面先看一下LevelSetLoggerConfig长什么样。

java 复制代码
private static class LevelSetLoggerConfig extends LoggerConfig {

    LevelSetLoggerConfig(String name, Level level, boolean additive) {
        super(name, level, additive);
    }

}

既然我们往ConfigurationloggerConfigs 中添加了一个名字是com.honey.auth.LogoutLevelSetLoggerConfig ,那么名字是com.honey.auth.LogoutLogger 理所应当的就会持有名字是com.honey.auth.LogoutLevelSetLoggerConfig ,但是聪明的人就发现了,这个新创建出来的LevelSetLoggerConfig 也是没有灵魂的,为什么呢,因为LevelSetLoggerConfig 不引用任何的Appedner ,没有Appedner 怎么打日志嘛,不过不用担心,只要在创建LevelSetLoggerConfig 时,将additive 指定为true,这个问题就解决了。

Log4j2 中,LoggerConfig 之间是有父子关系的,假如ConfigurationloggerConfigs 有下面这几个名字的LoggerConfig

txt 复制代码
""
com.honey
com.honey.auth.Login

那么名字是com.honey.auth.LoginLoggerConfig 会依次按照com.honey.authcom.honeycom"" 去寻找自己的父LoggerConfig ,所以每个LoggerConfig 都有自己的父LoggerConfig ,而additive 参数的含义就是,当前日志是否还需要由父LoggerConfig 打印,如果某个LoggerConfigadditivetrue ,那么一条日志除了让自己的所有Appedner 打印,还会让父LoggerConfig 的所有Appender来打印。

所以只要在创建LevelSetLoggerConfig 时,将additive 指定为true ,就算LevelSetLoggerConfig 自己没有Appender ,父亲也是可以打印日志的。下面举个例子来加深理解,还是假如ConfigurationloggerConfigs 有下面这几个名字的LoggerConfig

txt 复制代码
""
com.honey
com.honey.auth.Login

我们已经有一个名字为com.honey.auth.LogoutLogger ,并且按照Logger 寻找LoggerConfig 的规则,我们知道名字为com.honey.auth.LogoutLogger 会持有名字为com.honeyLoggerConfig ,那么现在我们要热更新名字为com.honey.auth.LogoutLogger 的级别,此时拿着com.honey.auth.LogoutConfigurationloggerConfigs 中获取出来的LoggerConfig 肯定为null ,所以我们会创建一个名字为com.honey.auth.LogoutLevelSetLoggerConfig ,并且这个LevelSetLoggerConfigadditivetrue ,此时ConfigurationloggerConfigs 有下面这几个名字的LoggerConfig

txt 复制代码
""
com.honey
com.honey.auth.Login
com.honey.auth.Logout

此时我们重新让名字为com.honey.auth.LogoutLogger 去寻找自己应该持有的LoggerConfig ,那么肯定就会找到名字为com.honey.auth.LogoutLevelSetLoggerConfig ,由于Log4j2 中,Logger 的级别跟着LoggerConfig 走,所以名字为com.honey.auth.LogoutLogger 的级别就更新了,现在使用名字为com.honey.auth.LogoutLogger 打印日志,首先会让其持有的LoggerConfig 引用的Appedner 来打印,由于没有引用Appedner ,所以不会打印日志,然后再让其父LoggerConfig 引用的Appedner 来打印日志,而名字为com.honey.auth.LogoutLevelSetLoggerConfig 的父亲其实就是名字为com.honeyLoggerConfig ,所以最终还是让名字为com.honeyLoggerConfig 引用的Appedner完成了日志打印。

到这里仿佛好像逐渐偏离了本小节的主题,其实不是的,我们现在再回看Log4J2LoggingSystemsetLogLevel() 方法,如下所示。

java 复制代码
private void setLogLevel(String loggerName, Level level) {
    // 从Configuration中根据loggerName获取到对应的LoggerConfig
    LoggerConfig logger = getLogger(loggerName);
    if (level == null) {
        // 2. 移除LoggerConfig或设置LoggerConfig级别为null
        clearLogLevel(loggerName, logger);
    } else {
        // 1. 添加LoggerConfig或设置LoggerConfig级别
        setLogLevel(loggerName, logger, level);
    }
    // 3. 更新Logger
    getLoggerContext().updateLoggers();
}

首先是第1点,在传入的level 不为空时,我们就会去设置对应的LoggerConfig 的级别,如果获取到的LoggerConfig 为空,那么就会创建一个名字为loggerName ,级别为levelLevelSetLoggerConfig 并加到ConfigurationloggerConfigs 中,如果获取到的LoggerConfig 不为空,则直接修改LoggerConfiglevel字段。

其次是第2点,传入level 为空时,此时要求能通过loggerName 找到LoggerConfig ,否则抛空指针异常。如果通过loggerName 找到的LoggerConfig 不为空,此时需要判断一下LoggerConfig 的类型,如果LoggerConfig 实际类型是LevelSetLoggerConfig ,那么就从ConfigurationloggerConfigs 中将其移除,如果LoggerConfig 实际类型就是LoggerConfig ,那么就设置LoggerConfiglevel 字段为null

最后是第3点,在前面第1和第2点,我们已经让目标LoggerConfig 的级别完成了更新,此时就需要让LoggerContext 里面所有的Logger 重新去匹配一次自己的LoggerConfig ,至此就完成了Logger的级别的更新。

相信到这里,Log4J2LoggingSystem 热更新原理就阐释清楚了,小结一下就是通过loggerNameLoggerConfig ,找到了就更新其level ,找不到就创建一个名字为loggerNameLevelSetLoggerConfig ,最后让所有Logger 去重新匹配一下自己的LoggerConfig ,此时我们的目标Logger 就会持有更新过级别的LoggerConfig了。

最后给出基于LoggersEndpoint 热更新Log4j2日志打印器的流程图,如下所示。

六. 自定义Springboot下日志打印器级别热更新

有些时候,使用spring-boot-actuator 包提供的LoggersEndpoint 来热更新日志打印器级别,是有点不方便的,因为想要热更新日志级别而引入spring-boot-actuator 包,大部分时候这个操作都有点重,而通过上面的分析,我们发现其实热更新日志打印器级别的原理特别简单,就是通过LoggingSystem 来操作Logger ,所以我们可以自己提供一个接口,通过这个接口来操作Logger的级别。

java 复制代码
@RestController
public class HotModificationLevel {

    private final LoggingSystem loggingSystem;

    public HotModificationLevel(LoggingSystem loggingSystem) {
        this.loggingSystem = loggingSystem;
    }

    @PostMapping("/logger/level")
    public void setLoggerLevel(@RequestBody SetLoggerLevelParam levelParam) {
        loggingSystem.setLogLevel(levelParam.getLoggerName(), levelParam.getLoggerLevel());
    }

    public static class SetLoggerLevelParam {
        private String loggerName;
        private LogLevel loggerLevel;

        // 省略getter和setter
    }

}

通过调用上述接口使用LoggingSystem就能够完成指定日志打印器的级别热更新。

总结

对于Log4j2 日志框架,我们需要知道Logger 只是一个壳子,灵魂是Logger 持有的LoggerConfig

Springboot 框架启动时,日志的初始化的发起点是LoggingApplicationListener ,但是实际去寻找日志框架的配置文件并完成日志框架初始化是LoggingSystem

Springboot 中提供日志框架的配置文件时,我们可以将配置文件命名为约定的名字然后放在classpath 下,也可以通过logging.config 显示的指定要使用的配置文件的路径,甚至可以完全不自己提供配置文件而使用Springboot 预置的配置文件,因此使用Springboot框架,想打印日志是十分容易的。

Springboot 框架中,为了统一的管理一组Logger ,定义了一个日志打印器组LoggerGroup ,通过操作LoggerGroup ,可以方便的操作一组Logger ,我们可以使用logging.group.xxx 来定义LoggerGroup ,而xxx 就是组名,后续拿着组名就可以找到LoggerGroup并操作。

所谓日志打印器级别热更新,其实就是不重启应用的情况下修改日志打印器的级别,核心思路就是通过LoggingSystem 去操作底层的日志框架,因为LoggingSystem 可以为我们屏蔽底层的日志框架的细节,所以通过LoggingSystem修改日志打印器级别,是十分容易的。

相关推荐
弗拉唐6 小时前
springBoot,mp,ssm整合案例
java·spring boot·mybatis
2401_857610037 小时前
SpringBoot社团管理:安全与维护
spring boot·后端·安全
凌冰_8 小时前
IDEA2023 SpringBoot整合MyBatis(三)
spring boot·后端·mybatis
天天进步20158 小时前
Vue+Springboot用Websocket实现协同编辑
vue.js·spring boot·websocket
草莓base9 小时前
【手写一个spring】spring源码的简单实现--bean对象的创建
java·spring·rpc
乌啼霜满天2499 小时前
Spring 与 Spring MVC 与 Spring Boot三者之间的区别与联系
java·spring boot·spring·mvc
tangliang_cn9 小时前
java入门 自定义springboot starter
java·开发语言·spring boot
Grey_fantasy9 小时前
高级编程之结构化代码
java·spring boot·spring cloud
Elaine2023919 小时前
零碎04 MybatisPlus自定义模版生成代码
java·spring·mybatis