起因
你是否碰到過如下場(chǎng)景:
- 在測(cè)試環(huán)境未發(fā)現(xiàn)的BUG,上了生產(chǎn)環(huán)境之后偶現(xiàn)歇拆,但同樣由于缺少調(diào)試信息,無法定位問題
- 調(diào)用內(nèi)部服務(wù)础嫡、第三方服務(wù)氛谜,在某些case下系統(tǒng)未按預(yù)期運(yùn)行掏觉,排查代碼后懷疑是被依賴方返回了錯(cuò)誤的數(shù)據(jù)導(dǎo)致,但苦于打印Response的日志為
DEBUG
值漫,沒有證據(jù)
在以前的解決方案是澳腹,將日志級(jí)別改成DEBUG
并上個(gè)線,排查完問題之后杨何,再將日志級(jí)別改回INFO
酱塔,再上一次線,整個(gè)生命周期很長(zhǎng)危虱;又或者為了省事羊娃,直接將調(diào)試日志級(jí)別定為INFO
,避免上線埃跷。這兩種方式蕊玷,無論哪種都不夠優(yōu)雅,如果有一種方式弥雹,能夠動(dòng)態(tài)修改日志級(jí)別垃帅,在需要排查問題的時(shí)候改成DEBUG
,不需要的時(shí)候就恢復(fù)INFO
剪勿,豈不妙哉贸诚?
因此,我們期望實(shí)現(xiàn)的目標(biāo)是:
- 能夠動(dòng)態(tài)修改日志級(jí)別并及時(shí)生效(主要目標(biāo))
- 在使用上厕吉,與
Spring Boot
提供的設(shè)置日志級(jí)別方式兼容(不另造輪子酱固,降低學(xué)習(xí)成本) - 問題排查完之后,能夠簡(jiǎn)單還原日志級(jí)別(移除之前的修改)
解決方案
針對(duì)需要實(shí)現(xiàn)的目標(biāo)头朱,逐條分析:
- 借助一些工具(如
Arthas
)媒怯,直接修改運(yùn)行時(shí)內(nèi)存中的值,但是在集群環(huán)境下需要逐臺(tái)修改髓窜,實(shí)施成本較高扇苞;借助配置中心(如Apollo
),修改之后由配置中心把數(shù)據(jù)推給應(yīng)用服務(wù)器寄纵,時(shí)延取決于配置中心推送的能力 -
Spring Boot
提供的設(shè)置日志級(jí)別的方式是鳖敷,在application.properties/application.xml里配置logging.level.{loggerName} = DEBUG
,希望仍然沿用這種方式 - 為了排查問題程拭,將某個(gè)類的日志級(jí)別設(shè)置為
DEBUG
定踱,完事之后,通過將本Logger
日志級(jí)別設(shè)置為父Logger
的日志級(jí)別進(jìn)行還原
Spring Boot提供了抽象日志系統(tǒng)(org.springframework.boot.logging.LoggingSystem
)恃鞋,通過借助LoggingSystem
可以實(shí)現(xiàn)修改日志級(jí)別的目的崖媚,而Apollo
則為動(dòng)態(tài)修改提供了可能性
因此亦歉,最終采用的方案為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();
}
/**
* 獲取當(dāng)前類的Logger對(duì)象有效日志級(jí)別對(duì)應(yīng)的方法肴楷,進(jìn)行日志輸出。舉例:
* 如果當(dāng)前類的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);
}
}
}
能夠?qū)崿F(xiàn)的效果如下:
- 在
Apollo
配置logging.level.com.example.web = DEBUG
赛蔫,能夠?qū)oggerName為com.example.web
的日志級(jí)別改成DEBUG
, 一般情況下,loggerName也等同于包名泥张,也即是該包下的類日志級(jí)別都會(huì)被改成DEBUG
(使用方式同等于在application.properties里的配置) - 在
Apollo
里刪掉logging.level.com.example.web
的配置項(xiàng)呵恢,系統(tǒng)會(huì)將com.example.web
的日志級(jí)別設(shè)置為等于同com.example
的日志級(jí)別,默認(rèn)情況下com.example
等同于ROOT
的日志級(jí)別媚创,也就是INFO
渗钉,就達(dá)到了恢復(fù)的目的
原理分析
源碼基于
Spring Boot 2.1.10.RELEASE
- 在
Spring Boot
應(yīng)用啟動(dòng),運(yùn)行到Spring容器的生命周期節(jié)點(diǎn)(擴(kuò)展點(diǎn))時(shí)钞钙,Spring會(huì)發(fā)出一些通知事件鳄橘,例如ApplicationStartingEvent
、ApplicationEnvironmentPreparedEvent
歇竟、ApplicationPreparedEvent
等等挥唠,讓我們可以有機(jī)會(huì)監(jiān)聽這些事件抵恋,并且搞事情焕议。Spring 內(nèi)部也定義了一系列監(jiān)聽器,用于監(jiān)聽生命周期事件弧关,來進(jìn)行擴(kuò)展(思想:微內(nèi)核 + 插件)盅安。
如下圖所示,Spring Boot
內(nèi)部定義了org.springframework.boot.context.logging.LoggingApplicationListener
世囊,并且監(jiān)聽了ApplicationStartingEvent
事件别瞭,在事件中,構(gòu)造了日志系統(tǒng)loggingSystem
株憾,并且執(zhí)行初始化之前的回調(diào)蝙寨,為初始化做準(zhǔn)備
我們接著看org.springframework.boot.logging.LoggingSystem#get(java.lang.ClassLoader)
通過代碼,我們知道有兩種方式指定底層日志組件
- 通過環(huán)境變量指定嗤瞎。例如下述方式指定了
Logback
做為底層日志組件
- 通過環(huán)境變量指定嗤瞎。例如下述方式指定了
-Dorg.springframework.boot.logging.LoggingSystem=org.springframework.boot.logging.logback.LogbackLoggingSystem
- 按
Spring Boot
預(yù)定義的日志系統(tǒng)順序查找墙歪,排在前面的日志組件優(yōu)先級(jí)高
- 按
可以看到,LoggingSystem
支持的日志組件贝奇,按順序有如下三種
- Logback
- Log4j2
- jul(java.util.logging)
一般情況下我們不會(huì)手動(dòng)指定環(huán)境變量虹菲,而是采用一種約定優(yōu)于配置的思想,交由Spring Boot
判斷:只要存在Logback
相關(guān)類掉瞳,就認(rèn)為Logback
應(yīng)該生效作為底層的日志組件毕源,其它的依此類推浪漠。
源碼從側(cè)面也透漏著一個(gè)信息:Spring Boot
偏愛Logback
我們這里就以Logback
為例,因此霎褐,此時(shí)應(yīng)用激活的是org.springframework.boot.logging.logback.LogbackLoggingSystem
- 系統(tǒng)繼續(xù)運(yùn)行址愿,
Spring
會(huì)發(fā)出ApplicationEnvironmentPreparedEvent
事件,并且仍由LoggingApplicationListener
進(jìn)行監(jiān)聽瘩欺,在監(jiān)聽時(shí)進(jìn)行了日志組件的初始化必盖,如此,一個(gè)日志系統(tǒng)(LoggingSystem
)便構(gòu)造完畢
- 在
Apollo
添加logging.level.{loggerName} = DEBUG
的配置項(xiàng)俱饿,會(huì)觸發(fā)應(yīng)用去Apollo
拉取最新的配置信息歌粥,并且將變更內(nèi)容進(jìn)行回調(diào)。在回調(diào)事件中拍埠,通過獲取配置的日志級(jí)別失驶,調(diào)用LoggingSystem#setLogLevel
方法調(diào)整對(duì)應(yīng)logger的日志級(jí)別;刪除該配置項(xiàng)枣购,同樣會(huì)觸發(fā)應(yīng)用去Apollo
拉取最新的配置信息嬉探,changedKeys
包含刪掉的配置項(xiàng),此時(shí)調(diào)用Config#getProperty
必然獲取不到配置項(xiàng)的信息(因?yàn)橐呀?jīng)刪除)棉圈,因此getProperty
第二個(gè)參數(shù)就是用于指定當(dāng)獲取的配置項(xiàng)值為null
時(shí)的默認(rèn)值涩堤。此處,我們獲取了父Logger
的Level作為默認(rèn)值分瘾,便達(dá)到了恢復(fù)的目的胎围。此處需要注意的一點(diǎn)是,如果照搬源碼德召,使用的日志組件一定得Logback
白魂,緣由是在獲取父Logger的EffectiveLevel
實(shí)現(xiàn)方式上取了巧,如果使用的是Log4j2
上岗,會(huì)出現(xiàn)空指針異常---->究其原因福荸,日志組件底層實(shí)現(xiàn)機(jī)制不同,行為也就不一樣
總結(jié)
-
Spring Boot
在構(gòu)建Spring
容器的生命過程中肴掷,初始化了日志系統(tǒng)LoggingSystem
敬锐,并和某種日志組件如Logback
進(jìn)行了綁定。如此呆瞻,通過LoggingSystem
暴露出來的setLogLevel
接口台夺,屏蔽了不同日志組件之間的差異,忽略底層日志組件存在的同時(shí)栋烤,又能在需要時(shí)刻調(diào)用接口修改日志級(jí)別(抽象的魅力) - 借助配置中心(如
Apollo
)的推送能力谒养,應(yīng)用能夠準(zhǔn)實(shí)時(shí)獲取所配置的Logger
日志級(jí)別,并調(diào)用LoggingSystem#setLogLevel
進(jìn)行日志級(jí)別的設(shè)置
題外話
- 本文雖借助
Spring Boot
的日志系統(tǒng)機(jī)制,但本質(zhì)上也是委托給底層的日志組件來實(shí)現(xiàn)的买窟,也就是說丰泊,即便非Spring Boot
應(yīng)用,同樣能夠修改日志級(jí)別始绍。我們需要具備發(fā)散思維的能力瞳购,知其然,并知其所以然亏推。另一方面学赛,即便擁有這樣的能力,在Spring Boot
環(huán)境下吞杭,仍然不建議直接訪問日志組件設(shè)置日志級(jí)別的API盏浇,應(yīng)該擁抱Spring Boot
的生態(tài),借助其對(duì)日志的抽象能力芽狗,面向接口編程绢掰,而不是面向?qū)崿F(xiàn)編程 - 本文雖借助
Apollo
來實(shí)現(xiàn)動(dòng)態(tài)修改的能力,但實(shí)際上童擎,能實(shí)現(xiàn)此能力的組件依然很多滴劲,例如ZK
、Nacos
顾复、Spring Cloud Config + Spring Cloud Bus
等等班挖,在業(yè)務(wù)開發(fā)中,依賴這類基礎(chǔ)組件是很正常的事情芯砸,這取決于公司的技術(shù)選型萧芙,相應(yīng)改造一下方案即可 - 本文雖以
Logback
為日志組件貫徹全文,但對(duì)于Log4j2
以及jul
仍然適用乙嘀,對(duì)于一個(gè)日志組件而言末购,設(shè)置日志級(jí)別是最基本的功能之一破喻。因此虎谢,可以根據(jù)公司的技術(shù)規(guī)范來確定日志組件,如無統(tǒng)一標(biāo)準(zhǔn)曹质,建議跟著Spring Boot
走婴噩,畢竟Log4j2
出道6年,Spring Boot
也從1.x到2.x羽德,但仍然偏愛Logback
几莽,心里就沒點(diǎn)數(shù)么(雖說log4j2快,但fastjson也很快宅静,敢用否)