十年經驗竟不懂Springboot日誌

2025年09月10日20:22:03 科技 1585

前言

日誌,是開發中熟悉又陌生的夥伴,熟悉是因為我們經常會在各種場合列印日誌,陌生是因為大部分時候我們都不太關心日誌是怎麼列印出來的,因為列印一條日誌,在我們看來是一件太平常不過的事情了,特別是在宇宙第一框架Springboot的加持下,日誌列印是怎麼工作的就更沒人關注了。

但是了解日誌框架怎麼工作,以及學會Springboot怎麼和Log4j2Logback等日誌框架集成,對我們擴展日誌功能以及優雅列印日誌大有好處,甚至在有些場景,還能通過調整日誌的列印策略來提升我們的系統吞吐量。所以本文將以Springboot集成Log4j2為例,詳細說明Springboot框架下Log4j2是如何工作的,你可能會擔心,如果是使用Logback日誌框架該怎麼辦呢,其實Log4j2Logback極其相似,Springboot在啟動時處理Log4j2和處理Logback也幾乎是一樣的套路,所以學會Springboot框架下Log4j2如何工作,切換成Logback也是輕輕鬆鬆的。

本文遵循一個該深則深,該淺則淺的整體指導方針,全方位的闡述Springboot中日誌怎麼工作,思維導圖如下所示。

十年經驗竟不懂Springboot日誌 - 天天要聞

Springboot版本:2.7.2
Log4j2版本:2.17.2

正文

一. Log4j2簡單工作原理分析

使用Log4j2列印日誌時,我們自己接觸最多的就是Logger對象了,Logger對象叫做日誌列印器,負責列印日誌,一個Logger對象,結構簡單示意如下。

十年經驗竟不懂Springboot日誌 - 天天要聞

實際列印日誌的是Logger對象使用的Appender對象,至於Appender對象怎麼列印日誌,不在我們本文的關注範圍內。特別注意,在Log4j2中,Logger對象實際只是一個殼子,靈魂是其持有的LoggerConfig對象,LoggerConfig決定列印時使用哪些Appender對象,以及Logger的級別。

LoggerConfigAppender通常是在Log4j2的配置文件中定義出來的,配置文件通常命名為Log4j2.xmlLog4j2框架在初始化時,會去載入這個配置文件並解析成一個配置對象Configuration,示意如下。

十年經驗竟不懂Springboot日誌 - 天天要聞

我們每在配置文件的<Appenders>標籤下增加一項,解析得到的Configurationappenders中就多一個Appender,每在<Loggers>標籤下增加一項,解析得到的ConfigurationloggerConfigs中就多一個LoggerConfig,並且LoggerConfig解析出來時,其和Appender的關係也就確認了。

Log4j2中,還有一個LoggerContext對象,這個對象持有上述的Configuration對象,我們使用的每一個Logger,一開始都會先去LoggerContextloggerRegistry中獲取,如果沒有,則會創建一個Logger出來再緩存到LoggerContextloggerRegistry中,同時我們在創建Logger時其實核心就是要為這個創建的Logger找到它對應的LoggerConfig,那麼去哪裡找LoggerConfig呢,當然就是去Configuration中找,所以LoggerLoggerContextConfiguration的關係可以描述成下面這樣子。

十年經驗竟不懂Springboot日誌 - 天天要聞

所以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

假如我們像下面這樣配置。

logging:
  file:
    name: test.log

2. logging.file.path

假如我們像下面這樣配置。

logging:
  file:
    path: /

3. logging.level

假如我們像下面這樣配置。

logging:
  level:
    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() 方法如下所示。

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() 方法實現,如下所示。

@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() 方法如下所示。

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() 方法。

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,示例如下。

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設置級別,示例如下。

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表示如下。

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

名稱為commonLoggerGroup表示如下。

{
    "name": "common",
    "members": [
        "com.lee.util",
        "com.lee.config"
    ],
    "configuredLevel": "DEBUG"
}

最後再看一下Springboot中預置的LoggerGroup,有兩個,名字分別為websql,如下所示。

{
    "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": ""
}
{
    "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

sql
web
org.springframework.boot

如果配置trace=true,那麼如下的Springboot自己的Logger級別會設置為trace

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() 方法如下所示。

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日誌 - 天天要聞

四. 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() 方法來完成日誌框架的初始化,所以我們先看一下這裡的邏輯是什麼,源碼實現如下。

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() 方法如下所示。

@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() 方法的實現,如下所示。

@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() 方法的實現。

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下是否存在如下名字的配置文件。

log4j2-test.properties
log4j2-test.json
log4j2-test.jsn
log4j2-test.xml
log4j2.properties
log4j2.json
log4j2.jsn
log4j2.xml

如果不存在,則再去搜索Spring日誌配置文件路徑,也就是判斷classpath下是否存在如下名字的配置文件。

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如下的載入配置的方法。

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來指定多個配置文件路徑,使用示例如下。

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() 方法出發,跟一下源碼。

@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() 方法,如下所示。

@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的載入配置的方法,如下所示。

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日誌 - 天天要聞

五. Springboot日誌列印器級別熱更新

在日誌列印中,一條日誌在發起列印時,會根據我們的指定攜帶一個日誌級別,同時列印日誌的日誌列印器,也有一個級別,日誌列印器只能列印級別高於或等於自身的日誌。

由於日誌列印時,日誌級別是由代碼決定的,所以日誌級別除非改代碼,否則無法改變,但是日誌列印器的級別是可以隨時更改的,最簡單的方式就是通過配置環境變數來更改logging.level,此時我們的應用進程所處的容器就會重啟,就可以讀取到我們更改後的logging.level,最終完成日誌列印器級別的修改。但是這種方式會使應用重啟,導致流量受損,我們更希望的是通過一種熱更新的方式來修改日誌列印器的級別,spring-boot-actuator包中提供了LoggersEndpoint來完成日誌列印器級別熱更新,所以本節將結合LoggersEndpoint的簡單使用和實現原理,說明一下Springboot中,如何熱更新日誌列印器級別。

1. LoggersEndpoint簡單使用

LoggersEndpointspring-boot-actuator提供,可以暴露一些端點用於獲取Springboot應用中的所有日誌列印器信息及其級別信息以及熱更新日誌列印器級別,由於默認情況下,LoggersEndpoint暴露的端點只能通過JMX的方式訪問,所以想要通過HTTP請求的方式訪問到LoggersEndpoint,需要做如下配置。

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調用如下介面拿到當前所有的日誌列印器的相關數據。

http://localhost:10999/actuator/loggers

獲取數據如下所示。

{
    "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介面。

http://localhost:10999/actuator/loggers/{日誌列印器名或日誌列印器組名}

如果pathVariable是日誌列印器名,那麼會得到如下結果。

{
    "configuredLevel": null,
    "effectiveLevel": "INFO"
}

如果pathVariable是日誌列印器組名,那麼會得到如下結果。

{
    "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介面來設置級別。

http://localhost:10999/actuator/loggers/{日誌列印器名或日誌列印器組名}
{
	"configuredLevel": "DEBUG"
}

此時對應的日誌列印器日誌列印器組的級別就會更新為設置的級別,並且其configuredLevel也會更新為設置的級別。

2. LoggersEndpoint原理分析

這裡主要關注LoggersEndpoint如何實現日誌列印器級別的熱更新。LoggersEndpoint實現日誌列印器級別的熱更新對應的端點方法如下所示。

@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() 方法如下所示。

@Override
public void setLogLevel(String loggerName, LogLevel logLevel) {
    // 將LogLevel轉換為Level
    setLogLevel(loggerName, LEVELS.convertSystemToNative(logLevel));
}

LogLevelSpringboot中的日誌級別對象,LevelLog4j2的日誌級別對象,所以需要先將LogLevel轉換為Level,然後繼續調用如下方法。

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 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

""
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長什麼樣。

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

""
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

""
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

""
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() 方法,如下所示。

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日誌 - 天天要聞

六. 自定義Springboot下日誌列印器級別熱更新

有些時候,使用spring-boot-actuator包提供的LoggersEndpoint來熱更新日誌列印器級別,是有點不方便的,因為想要熱更新日誌級別而引入spring-boot-actuator包,大部分時候這個操作都有點重,而通過上面的分析,我們發現其實熱更新日誌列印器級別的原理特別簡單,就是通過LoggingSystem來操作Logger,所以我們可以自己提供一個介面,通過這個介面來操作Logger的級別。

@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修改日誌列印器級別,是十分容易的。

科技分類資訊推薦

引領科技豪華MPV新風尚 第二代騰勢D9西安車展亮相 - 天天要聞

引領科技豪華MPV新風尚 第二代騰勢D9西安車展亮相

兼具宜商氣度與家用溫情的科技豪華旗艦MPV,第二代騰勢D9迎來西安地區正式亮相。新車依託全球新能源MPV冠軍底蘊,以第二代刀片電池、雙閥雲輦-C、天神之眼5.0智駕等核心技術全面升級,兼顧商務體面與家庭舒適,為西北高端用戶帶來一站式全能出行解決方案。
採購禁入!科華數據材料造假被拒門外 - 天天要聞

採購禁入!科華數據材料造假被拒門外

本報(chinatimes.net.cn)記者胡雅文 北京報道這家趕上AI算力風口的公司,因投標材料造假,被相關採購方列入禁入名單兩年,其此前提出的複議申請也被正式駁回。相關採購平台近日發布公告,明確駁回科華數據股份有限公司(下稱「科華數據」,002335.SZ)此前提交的複議申請。早在一年前,科華數據已被認定在「信息通信樞紐...
快評樂道L80:15萬元級買大五座,這波值得沖? - 天天要聞

快評樂道L80:15萬元級買大五座,這波值得沖?

日前,樂道L80正式發布並開啟預售,其整車購買預售價為24.58萬元起,租電購買預售價則低至15.98萬元起。面對大型SUV市場「細分再細分」之競爭趨勢,這款樂道年度重磅新車都有哪些優勢?又能否成為「大五座SUV革新之作」?下面,圈哥就帶大家全方位感受。
成都直擊凱威德:純電全尺寸SUV的張揚與大氣 - 天天要聞

成都直擊凱威德:純電全尺寸SUV的張揚與大氣

4月22日,凱迪拉克以奧斯卡級盛典規格,將上海保利大劇院點亮為璀璨舞台,在品牌代言人倪妮與全場嘉賓的共同見證下,凱迪拉克全尺寸純電公路旗艦——凱威德耀然上市。新車共推出長續航四驅Pro、高性能四驅Ultra兩款配置,官方售價區間為46.88萬-50.88萬元。