起因
你是否碰到過如下場景:
- 在測試環境未發現的BUG,上了生產環境之後偶現,但同樣由於缺少調試信息,無法定位問題
- 調用內部服務、第三方服務,在某些case下系統未按預期運行,排查代碼後懷疑是被依賴方返回了錯誤的數據導致,但苦於打印Response的日誌爲
DEBUG
,沒有證據
在以前的解決方案是,將日誌級別改成DEBUG
並上個線,排查完問題之後,再將日誌級別改回INFO
,再上一次線,整個生命週期很長;又或者爲了省事,直接將調試日誌級別定爲INFO
,避免上線。這兩種方式,無論哪種都不夠優雅,如果有一種方式,能夠動態修改日誌級別,在需要排查問題的時候改成DEBUG
,不需要的時候就恢復INFO
,豈不妙哉?
因此,我們期望實現的目標是:
- 能夠動態修改日誌級別並及時生效(主要目標)
- 在使用上,與
Spring Boot
提供的設置日誌級別方式兼容(不另造輪子,降低學習成本) - 問題排查完之後,能夠簡單還原日誌級別(移除之前的修改)
解決方案
針對需要實現的目標,逐條分析:
- 藉助一些工具(如
Arthas
),直接修改運行時內存中的值,但是在集羣環境下需要逐臺修改,實施成本較高;藉助配置中心(如Apollo
),修改之後由配置中心把數據推給應用服務器,時延取決於配置中心推送的能力 Spring Boot
提供的設置日誌級別的方式是,在application.properties/application.xml裏配置logging.level.{loggerName} = DEBUG
,希望仍然沿用這種方式- 爲了排查問題,將某個類的日誌級別設置爲
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);
}
}
}
能夠實現的效果如下:
- 在
Apollo
配置logging.level.com.example.web = DEBUG
,能夠將loggerName爲com.example.web
的日誌級別改成DEBUG
, 一般情況下,loggerName也等同於包名,也即是該包下的類日誌級別都會被改成DEBUG
(使用方式同等於在application.properties裏的配置) - 在
Apollo
裏刪掉logging.level.com.example.web
的配置項,系統會將com.example.web
的日誌級別設置爲等於同com.example
的日誌級別,默認情況下com.example
等同於ROOT
的日誌級別,也就是INFO
,就達到了恢復的目的
原理分析
源碼基於
Spring Boot 2.1.10.RELEASE
- 在
Spring Boot
應用啓動,運行到Spring容器的生命週期節點(擴展點)時,Spring會發出一些通知事件,例如ApplicationStartingEvent
、ApplicationEnvironmentPreparedEvent
、ApplicationPreparedEvent
等等,讓我們可以有機會監聽這些事件,並且搞事情。Spring 內部也定義了一系列監聽器,用於監聽生命週期事件,來進行擴展(思想:微內核 + 插件)。
如下圖所示,Spring Boot
內部定義了org.springframework.boot.context.logging.LoggingApplicationListener
,並且監聽了ApplicationStartingEvent
事件,在事件中,構造了日誌系統loggingSystem
,並且執行初始化之前的回調,爲初始化做準備
我們接着看org.springframework.boot.logging.LoggingSystem#get(java.lang.ClassLoader)
通過代碼,我們知道有兩種方式指定底層日誌組件
-
- 通過環境變量指定。例如下述方式指定了
Logback
做爲底層日誌組件
- 通過環境變量指定。例如下述方式指定了
-Dorg.springframework.boot.logging.LoggingSystem=org.springframework.boot.logging.logback.LogbackLoggingSystem
-
- 按
Spring Boot
預定義的日誌系統順序查找,排在前面的日誌組件優先級高
- 按
可以看到,LoggingSystem
支持的日誌組件,按順序有如下三種
- Logback
- Log4j2
- jul(java.util.logging)
一般情況下我們不會手動指定環境變量,而是採用一種約定優於配置的思想,交由Spring Boot
判斷:只要存在Logback
相關類,就認爲Logback
應該生效作爲底層的日誌組件,其它的依此類推。
源碼從側面也透漏着一個信息:Spring Boot
偏愛Logback
我們這裏就以Logback
爲例,因此,此時應用激活的是org.springframework.boot.logging.logback.LogbackLoggingSystem
- 系統繼續運行,
Spring
會發出ApplicationEnvironmentPreparedEvent
事件,並且仍由LoggingApplicationListener
進行監聽,在監聽時進行了日誌組件的初始化,如此,一個日誌系統(LoggingSystem
)便構造完畢
- 在
Apollo
添加logging.level.{loggerName} = DEBUG
的配置項,會觸發應用去Apollo
拉取最新的配置信息,並且將變更內容進行回調。在回調事件中,通過獲取配置的日誌級別,調用LoggingSystem#setLogLevel
方法調整對應logger的日誌級別;刪除該配置項,同樣會觸發應用去Apollo
拉取最新的配置信息,changedKeys
包含刪掉的配置項,此時調用Config#getProperty
必然獲取不到配置項的信息(因爲已經刪除),因此getProperty
第二個參數就是用於指定當獲取的配置項值爲null
時的默認值。此處,我們獲取了父Logger
的Level作爲默認值,便達到了恢復的目的。此處需要注意的一點是,如果照搬源碼,使用的日誌組件一定得Logback
,緣由是在獲取父Logger的EffectiveLevel
實現方式上取了巧,如果使用的是Log4j2
,會出現空指針異常---->究其原因,日誌組件底層實現機制不同,行爲也就不一樣
總結
Spring Boot
在構建Spring
容器的生命過程中,初始化了日誌系統LoggingSystem
,並和某種日誌組件如Logback
進行了綁定。如此,通過LoggingSystem
暴露出來的setLogLevel
接口,屏蔽了不同日誌組件之間的差異,忽略底層日誌組件存在的同時,又能在需要時刻調用接口修改日誌級別(抽象的魅力)- 藉助配置中心(如
Apollo
)的推送能力,應用能夠準實時獲取所配置的Logger
日誌級別,並調用LoggingSystem#setLogLevel
進行日誌級別的設置
題外話
- 本文雖藉助
Spring Boot
的日誌系統機制,但本質上也是委託給底層的日誌組件來實現的,也就是說,即便非Spring Boot
應用,同樣能夠修改日誌級別。我們需要具備發散思維的能力,知其然,並知其所以然。另一方面,即便擁有這樣的能力,在Spring Boot
環境下,仍然不建議直接訪問日誌組件設置日誌級別的API,應該擁抱Spring Boot
的生態,藉助其對日誌的抽象能力,面向接口編程,而不是面向實現編程 - 本文雖藉助
Apollo
來實現動態修改的能力,但實際上,能實現此能力的組件依然很多,例如ZK
、Nacos
、Spring Cloud Config + Spring Cloud Bus
等等,在業務開發中,依賴這類基礎組件是很正常的事情,這取決於公司的技術選型,相應改造一下方案即可 - 本文雖以
Logback
爲日誌組件貫徹全文,但對於Log4j2
以及jul
仍然適用,對於一個日誌組件而言,設置日誌級別是最基本的功能之一。因此,可以根據公司的技術規範來確定日誌組件,如無統一標準,建議跟着Spring Boot
走,畢竟Log4j2
出道6年,Spring Boot
也從1.x到2.x,但仍然偏愛Logback
,心裏就沒點數麼(雖說log4j2快,但fastjson也很快,敢用否)