Spring Boot + Apollo 動態修改日誌級別

起因

你是否碰到過如下場景:

  1. 在測試環境未發現的BUG,上了生產環境之後偶現,但同樣由於缺少調試信息,無法定位問題
  2. 調用內部服務、第三方服務,在某些case下系統未按預期運行,排查代碼後懷疑是被依賴方返回了錯誤的數據導致,但苦於打印Response的日誌爲DEBUG,沒有證據

在以前的解決方案是,將日誌級別改成DEBUG並上個線,排查完問題之後,再將日誌級別改回INFO,再上一次線,整個生命週期很長;又或者爲了省事,直接將調試日誌級別定爲INFO,避免上線。這兩種方式,無論哪種都不夠優雅,如果有一種方式,能夠動態修改日誌級別,在需要排查問題的時候改成DEBUG,不需要的時候就恢復INFO,豈不妙哉?

因此,我們期望實現的目標是:

  1. 能夠動態修改日誌級別並及時生效(主要目標)
  2. 在使用上,與Spring Boot提供的設置日誌級別方式兼容(不另造輪子,降低學習成本)
  3. 問題排查完之後,能夠簡單還原日誌級別(移除之前的修改)

解決方案

針對需要實現的目標,逐條分析:

  1. 藉助一些工具(如Arthas),直接修改運行時內存中的值,但是在集羣環境下需要逐臺修改,實施成本較高;藉助配置中心(如Apollo),修改之後由配置中心把數據推給應用服務器,時延取決於配置中心推送的能力
  2. Spring Boot提供的設置日誌級別的方式是,在application.properties/application.xml裏配置logging.level.{loggerName} = DEBUG,希望仍然沿用這種方式
  3. 爲了排查問題,將某個類的日誌級別設置爲DEBUG,完事之後,通過將本Logger日誌級別設置爲父Logger的日誌級別進行還原

Spring Boot提供了抽象日誌系統(org.springframework.boot.logging.LoggingSystem),通過藉助LoggingSystem可以實現修改日誌級別的目的,而Apollo則爲動態修改提供了可能性

因此,最終採用的方案爲Spring Boot(LoggingSystem) + Apollo,選用的日誌組件爲Logback爲例,源碼如下:

public class LoggingLevelRefresher {
    private final static Logger log = LoggerFactory.getLogger(LoggingLevelRefresher.class);

    private static final String PREFIX = "logging.level.";
    private static final String ROOT = LoggingSystem.ROOT_LOGGER_NAME;
    private static final String SPLIT = ".";

    @Resource
    private LoggingSystem loggingSystem;

    @ApolloConfig
    private Config config;

    @PostConstruct
    private void init() {
        refreshLoggingLevels(config.getPropertyNames());
    }

    @ApolloConfigChangeListener(interestedKeyPrefixes = PREFIX)
    private void onChange(ConfigChangeEvent changeEvent) {
        refreshLoggingLevels(changeEvent.changedKeys());
    }

    private void refreshLoggingLevels(Set<String> changedKeys) {
        for (String key : changedKeys) {
            // key may be : logging.level.com.example.web
            if (StringUtils.startsWithIgnoreCase(key, PREFIX)) {
                String loggerName = PREFIX.equalsIgnoreCase(key) ? ROOT : key.substring(PREFIX.length());
                String strLevel = config.getProperty(key, parentStrLevel(loggerName));
                LogLevel level = LogLevel.valueOf(strLevel.toUpperCase());
                loggingSystem.setLogLevel(loggerName, level);

                log(loggerName, strLevel);
            }
        }
    }


    private String parentStrLevel(String loggerName) {
        String parentLoggerName = loggerName.contains(SPLIT) ? loggerName.substring(0, loggerName.lastIndexOf(SPLIT)) : ROOT;
        return loggingSystem.getLoggerConfiguration(parentLoggerName).getEffectiveLevel().name();
    }

    /**
     * 獲取當前類的Logger對象有效日誌級別對應的方法,進行日誌輸出。舉例:
     * 如果當前類的EffectiveLevel爲WARN,則獲取的Method爲 `org.slf4j.Logger#warn(java.lang.String, java.lang.Object, java.lang.Object)`
     * 目的是爲了輸出`changed {} log level to:{}`這一行日誌
     */
    private void log(String loggerName, String strLevel) {
        try {
            LoggerConfiguration loggerConfiguration = loggingSystem.getLoggerConfiguration(log.getName());
            Method method = log.getClass().getMethod(loggerConfiguration.getEffectiveLevel().name().toLowerCase(), String.class, Object.class, Object.class);
            method.invoke(log, "changed {} log level to:{}", loggerName, strLevel);
        } catch (Exception e) {
            log.error("changed {} log level to:{} error", loggerName, strLevel, e);
        }
    }
}

能夠實現的效果如下:

  1. Apollo配置logging.level.com.example.web = DEBUG,能夠將loggerName爲com.example.web的日誌級別改成DEBUG, 一般情況下,loggerName也等同於包名,也即是該包下的類日誌級別都會被改成DEBUG(使用方式同等於在application.properties裏的配置)
  2. Apollo裏刪掉logging.level.com.example.web的配置項,系統會將com.example.web的日誌級別設置爲等於同com.example的日誌級別,默認情況下com.example等同於ROOT的日誌級別,也就是INFO,就達到了恢復的目的

原理分析

源碼基於Spring Boot 2.1.10.RELEASE

  1. Spring Boot應用啓動,運行到Spring容器的生命週期節點(擴展點)時,Spring會發出一些通知事件,例如ApplicationStartingEventApplicationEnvironmentPreparedEventApplicationPreparedEvent等等,讓我們可以有機會監聽這些事件,並且搞事情。Spring 內部也定義了一系列監聽器,用於監聽生命週期事件,來進行擴展(思想:微內核 + 插件)。
    如下圖所示,Spring Boot內部定義了org.springframework.boot.context.logging.LoggingApplicationListener,並且監聽了ApplicationStartingEvent事件,在事件中,構造了日誌系統loggingSystem,並且執行初始化之前的回調,爲初始化做準備

image.png

我們接着看org.springframework.boot.logging.LoggingSystem#get(java.lang.ClassLoader)

image.png

通過代碼,我們知道有兩種方式指定底層日誌組件

    1. 通過環境變量指定。例如下述方式指定了Logback做爲底層日誌組件
-Dorg.springframework.boot.logging.LoggingSystem=org.springframework.boot.logging.logback.LogbackLoggingSystem
    1. Spring Boot預定義的日誌系統順序查找,排在前面的日誌組件優先級高

image.png

可以看到,LoggingSystem支持的日誌組件,按順序有如下三種

  • Logback
  • Log4j2
  • jul(java.util.logging)

一般情況下我們不會手動指定環境變量,而是採用一種約定優於配置的思想,交由Spring Boot判斷:只要存在Logback相關類,就認爲Logback應該生效作爲底層的日誌組件,其它的依此類推。

源碼從側面也透漏着一個信息:Spring Boot偏愛Logback

我們這裏就以Logback爲例,因此,此時應用激活的是org.springframework.boot.logging.logback.LogbackLoggingSystem

  1. 系統繼續運行,Spring會發出ApplicationEnvironmentPreparedEvent事件,並且仍由LoggingApplicationListener進行監聽,在監聽時進行了日誌組件的初始化,如此,一個日誌系統(LoggingSystem)便構造完畢

image.png

image.png

  1. Apollo添加logging.level.{loggerName} = DEBUG的配置項,會觸發應用去Apollo拉取最新的配置信息,並且將變更內容進行回調。在回調事件中,通過獲取配置的日誌級別,調用LoggingSystem#setLogLevel方法調整對應logger的日誌級別;刪除該配置項,同樣會觸發應用去Apollo拉取最新的配置信息,changedKeys包含刪掉的配置項,此時調用Config#getProperty必然獲取不到配置項的信息(因爲已經刪除),因此getProperty第二個參數就是用於指定當獲取的配置項值爲null時的默認值。此處,我們獲取了父Logger的Level作爲默認值,便達到了恢復的目的。此處需要注意的一點是,如果照搬源碼,使用的日誌組件一定得Logback,緣由是在獲取父Logger的EffectiveLevel實現方式上取了巧,如果使用的是Log4j2,會出現空指針異常---->究其原因,日誌組件底層實現機制不同,行爲也就不一樣

image.png

總結

  1. Spring Boot 在構建Spring容器的生命過程中,初始化了日誌系統LoggingSystem,並和某種日誌組件如Logback進行了綁定。如此,通過LoggingSystem暴露出來的setLogLevel接口,屏蔽了不同日誌組件之間的差異,忽略底層日誌組件存在的同時,又能在需要時刻調用接口修改日誌級別(抽象的魅力)
  2. 藉助配置中心(如Apollo)的推送能力,應用能夠準實時獲取所配置的Logger日誌級別,並調用LoggingSystem#setLogLevel進行日誌級別的設置

題外話

  1. 本文雖藉助Spring Boot的日誌系統機制,但本質上也是委託給底層的日誌組件來實現的,也就是說,即便非Spring Boot應用,同樣能夠修改日誌級別。我們需要具備發散思維的能力,知其然,並知其所以然。另一方面,即便擁有這樣的能力,在Spring Boot環境下,仍然不建議直接訪問日誌組件設置日誌級別的API,應該擁抱Spring Boot的生態,藉助其對日誌的抽象能力,面向接口編程,而不是面向實現編程
  2. 本文雖藉助Apollo來實現動態修改的能力,但實際上,能實現此能力的組件依然很多,例如ZKNacosSpring Cloud Config + Spring Cloud Bus等等,在業務開發中,依賴這類基礎組件是很正常的事情,這取決於公司的技術選型,相應改造一下方案即可
  3. 本文雖以Logback爲日誌組件貫徹全文,但對於Log4j2以及jul仍然適用,對於一個日誌組件而言,設置日誌級別是最基本的功能之一。因此,可以根據公司的技術規範來確定日誌組件,如無統一標準,建議跟着Spring Boot走,畢竟Log4j2出道6年,Spring Boot也從1.x到2.x,但仍然偏愛Logback,心裏就沒點數麼(雖說log4j2快,但fastjson也很快,敢用否)
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章