前言
SLF4J: Class path contains multiple SLF4J bindings.
SLF4J: Found binding in ...
這段提示是不是很眼熟?好像每次啟動項目都會報一下,但似乎又沒啥影響。
但是,某天多引一個庫后,項目就真的再也起不來了......
好吧,是時候正面Java中混亂的日志系統了。
JVM的是一個開放包容的平臺,正因如此,才造就了今日繁榮的JVM生態,但凡事有利有弊,比如這百花齊放的日志系統,相處的似乎就不那么愉快。
提到日志系統,我們先來羅列一下幾個有名的Java日志框架:
- log4j
- commons-logging
- jdk-logging
- slf4j
- logback
- log4j2
上面這幾個是在眾多日志框架中脫穎而出的,還有一些比較小眾的日志框架,比如jboss-logging,gwt-log先暫時忽略。
這些日志框架可以說是在Java不同階段的代表,比如log4j和log4j2,光從名字看就有千絲萬縷的聯系,明顯后者是一個進化版。
話說回來,框架多那是好事兒啊,我還能比較比較,看看哪個和我口味,選一個用不就得了。但是,相比于其他庫,日志框架是比較特殊的:
日志系統幾乎是所有庫都會用到的一個功能,每個庫由于早期的技術選型和開發者喜好等原因,可能使用了不同的日志框架。我們平時需要什么庫,Maven倉庫搜一波,貼過來就用,那叫一個爽啊,殊不知,這樣間接的引入了多少種不同的日志框架。
日志系統往往會盡可能早的進行初始化,并且由于日志橋接器和日志門面系統的存在,會嘗試做一些綁定和劫持工作(后文會提到),一旦引入多個日志框架,輕則會導致程序中有好幾套日志系統同時工作,日志輸出混亂,重則會導致項目日志系統初始化死鎖,項目無法啟動。
嗯,那么咋辦呢?首先簡單分析一下上面幾個框架,先確定最終要使用的框架。
上面幾個日志框架簡單分為兩類:
- 日志門面 commons-logging,slf4j
- 日志實現 log4j,jdk-logging,logback,log4j2
這也符合Java的面向對象設計理念,將接口與實現相分離。
日志門面系統的出現其實已經很大程度上緩解了日志系統的混亂,很多庫的作者也已經意識到了日志門面系統的重要性,不在庫中直接使用具體的日志實現框架。
PS:其實很多庫都會自己造一個類似slf4j的日志門面系統,并且綁定實現的優先級不一樣。
其實說是在做選擇,但事實上沒得選擇,slf4j作為現代的日志門面系統,已經成為事實的標準,并且為其他日志系統做了十足的兼容工作。
我們能做的就是選一個日志實現框架。
logback,log4j2是現代的高性能日志實現框架,兩者都很給力,看喜好了。
分析
我們這里以統一使用slf4j & logback為例分析。
如果我們直接暴力的排除其他日志框架,可能導致第三方庫在調用日志接口時拋出ClassNotFound異常,這里就需要用到日志系統橋接器。
日志系統橋接器說白了就是一種偷天換日的解決方案。
比如log4j-over-slf4j,即log4j -> slf4j的橋接器,這個庫定義了與log4j一致的接口(包名、類名、方法簽名均一致),但是接口的實現卻是對slf4j日志接口的包裝,即間接調用了slf4j日志接口,實現了對日志的轉發。
但是,jul-to-slf4j是個意外例外,畢竟JDK自帶的logging包排除不掉啊,其實是利用jdk-logging的Handler機制,在root logger上install一個handler,將所有日志劫持到slf4j上。要使得jul-to-slf4j生效,需要執行
SLF4JBridgeHandler.removeHandlersForRootLogger();
SLF4JBridgeHandler.install();
spring boot中的日志初始化模塊已經包括了該邏輯,故無需手動調用。在使用其他框架時,建議在入口類處的static{ }
區執行,確保盡早初始化。
日志系統橋接器是個巧妙的解決方案,有些庫的作者在引用第三方庫的時候,也碰到了日志系統混亂的問題,并順手用橋接器解決了,只不過碰巧跟你橋接的目標不一樣,橋接到了log4j。想想一下:
- log4j -> slf4j,slf4j -> log4j兩個橋接器同時存在會出現什么情況?
互相委托,無限循環,堆棧溢出。 - slf4j -> logback,slf4j -> log4j兩個橋接器同時存在會如何?
兩個橋接器都會被slf4j發現,在slf4j中定義了優先順序,優先使用logback,僅會報警,發現多個日志框架綁定實現;
但有一些框架中封裝了自己的日志facade,如果其對綁定日志實現定義的優先級順序與slf4j不一致,優先使用log4j,那整個程序中就有兩套日志系統在工作。
上面一波分析之后,我們得出結論,為達到統一使用slf4j & logback的目的,必須要做4件事:
- 引入slf4j & logback日志包和slf4j -> logback橋接器;
- 排除common-logging、log4j、log4j2日志包;
- 引入jdk-logging -> slf4j、common-logging -> slf4j、log4j -> slf4j、log4j2 -> slf4j橋接器;
- 排除slf4j -> jdk-logging、slf4j -> common-logging、slf4j -> log4j、slf4j -> log4j2橋接器。
ps:log4j2橋接器由log4j2提供,其他橋接器由slf4j提供。
如果再嚴謹一點,還要排除掉slf4j-simple、slf4j-nop兩個框架,不過這兩個一般沒人用。
下面這幅圖來自slf4j官方文檔,描述了橋接器的工作原理。
來自開源中國的一篇博文,也比較詳細的分析了各個橋接器的工作原理,奉上傳送門:https://my.oschina.net/pingpangkuangmo/blog/410224
上述提到了這么多日志系統的橋接器,但似乎沒有提到logback -> slf4j的橋接器,如果我們日志實現系統選擇log4j2,怎么處理logback?
其實logback在設計上,天生綁定sfl4j,可以認為從根源上避免了直接被使用,自然也不需要logbak -> slf4j的橋接器。
Gradle實戰
Gradle作為更現代的項目管理工具,實現上述步驟只需:
buildscript {
// 定義全局變量
ext {
slf4j_version = '1.7.25'
log4j2_version = '2.11.1'
logback_version = '1.2.3'
}
}
// 全局排除依賴
configurations {
// 支持通過group、module排除,可以同時使用
all*.exclude group: 'commons-logging', module: 'commons-logging' // common-logging
all*.exclude group: 'log4j', module: 'log4j' // log4j
all*.exclude group: 'org.apache.logging.log4j', module: 'log4j-core' // slf4j -> log4j2
all*.exclude group: 'org.apache.logging.log4j', module: 'log4j-slf4j-impl' // log4j2
all*.exclude group: 'org.slf4j', module: 'slf4j-jdk14' // slf4j -> jdk-logging
all*.exclude group: 'org.slf4j', module: 'slf4j-jcl' // slf4j -> common-logging
all*.exclude group: 'org.slf4j', module: 'slf4j-log4j12' // slf4j -> log4j
}
// 引入依賴
dependencies {
// log
compile "org.slf4j:slf4j-api:$slf4j_version"
compile "org.slf4j:jul-to-slf4j:$slf4j_version"
compile "org.slf4j:jcl-over-slf4j:$slf4j_version"
compile "org.slf4j:log4j-over-slf4j:$slf4j_version"
compile "org.apache.logging.log4j:log4j-api:$log4j2_version"
compile "org.apache.logging.log4j:log4j-to-slf4j:$log4j2_version"
compile "ch.qos.logback:logback-classic:$logback_version"
}
如果選擇log4j2作為日志實現框架
buildscript {
// 定義全局變量
ext {
slf4j_version = '1.7.25'
log4j2_version = '2.11.1'
logback_version = '1.2.3'
}
}
// 全局排除依賴
configurations {
// 支持通過group、module排除,可以同時使用
all*.exclude group: 'commons-logging', module: 'commons-logging' // common-logging
all*.exclude group: 'log4j', module: 'log4j' // log4j
all*.exclude group: 'ch.qos.logback', module: 'logback-core' // logback
all*.exclude group: 'ch.qos.logback', module: 'logback-classic' // slf4j -> logback
all*.exclude group: 'org.slf4j', module: 'slf4j-jdk14' // slf4j -> jdk-logging
all*.exclude group: 'org.slf4j', module: 'slf4j-jcl' // slf4j -> common-logging
all*.exclude group: 'org.slf4j', module: 'slf4j-log4j12' // slf4j -> log4j
}
// 引入依賴
dependencies {
// log
compile "org.slf4j:slf4j-api:$slf4j_version"
compile "org.slf4j:jul-to-slf4j:$slf4j_version"
compile "org.slf4j:jcl-over-slf4j:$slf4j_version"
compile "org.slf4j:log4j-over-slf4j:$slf4j_version"
compile "org.apache.logging.log4j:log4j-core:$log4j2_version"
compile "org.apache.logging.log4j:log4j-slf4j-impl:$log4j2_version"
}
Gradle的依賴管理十分靈活,有篇博客介紹了其依賴管理的更多特性,傳送門:
http://www.zhaiqianfeng.com/2017/03/love-of-gradle-dependencies-1.html
Maven實戰
在步驟1、3依賴引入方面Maven沒有什么問題,但是在步驟2、4依賴排除方面,相比Gradle,Maven沒有直接提供全局依賴排除機制,我們需要借助一些方法間接達到目的。
Provided Scope
<project>
[...]
<properties>
<slf4j.version>1.7.25</slf4j.version>
<commons-logging.version>1.2</commons-logging.version>
<log4j.version>1.2.17</log4j.version>
<log4j2.version>2.11.1</log4j2.version>
<logback.version>1.2.3</logback.version>
</properties>
<dependencies>
<dependency>
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId>
<version>${commons-logging.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>log4j</groupId>
<artifactId>log4j</artifactId>
<version>${log4j.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>${log4j.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-slf4j-impl</artifactId>
<version>${log4j.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-jdk14</artifactId>
<version>${slf4j.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-jcl</artifactId>
<version>${slf4j.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-log4j12</artifactId>
<version>${slf4j.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>${slf4j.version}</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>jul-to-slf4j</artifactId>
<version>${slf4j.version}</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>jcl-over-slf4j</artifactId>
<version>${slf4j.version}</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>log4j-over-slf4j</artifactId>
<version>${slf4j.version}</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-api</artifactId>
<version>${log4j2.version}</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-to-slf4j</artifactId>
<version>${log4j2.version}</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>${logback_version}</version>
</dependency>
</dependencies>
[...]
</project>
version99倉庫
我們來分析一下Maven依賴的工作原理,在一個依賴庫被直接或間接引入多次時,并且版本不一致,maven在解析依賴的時候,有兩個仲裁原則:
- 路徑最短優先原則
- 優先聲明原則
首先遵循路徑最短優先原則,即直接引入最優先,傳遞依賴層級越淺,越優先。若依然無法仲裁,則遵循優先聲明原則,在pom中聲明靠前的優先。
既然了解了這個規則,那就可以巧妙的利用一下,如果我們在pom的最開始,引入了一個虛包,則該包其他的依賴全部失效,也就達到了全局排除依賴的目的。
slf4j的文檔中也提到了該方案,并且提供了一個version99倉庫,里面有幾個用于排除其他日志框架的虛包。
<project>
[...]
<repositories>
<--! 首先添加version99倉庫 -->
<repository>
<id>version99</id>
<url>http://version99.qos.ch/</url>
</repository>
</repositories>
<--! 直接引入依賴,放置在最前 -->
<dependencies>
<dependency>
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId>
<version>99-empty</version>
</dependency>
<dependency>
<groupId>commons-logging</groupId>
<artifactId>commons-logging-api</artifactId>
<version>99-empty</version>
</dependency>
<dependency>
<groupId>log4j</groupId>
<artifactId>log4j</artifactId>
<version>99-empty</version>
</dependency>
</dependencies>
<--! 通過dependencyManagement強制指定依賴版本也可達到同樣效果 -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId>
<version>99-empty</version>
</dependency>
<dependency>
<groupId>commons-logging</groupId>
<artifactId>commons-logging-api</artifactId>
<version>99-empty</version>
</dependency>
<dependency>
<groupId>log4j</groupId>
<artifactId>log4j</artifactId>
<version>99-empty</version>
</dependency>
</dependencies>
</dependencyManagement>
[...]
</project>
這個version99倉庫是slf4j提供的一個靜態Maven倉庫,里面只有這3個虛包,是不能滿足其他要求的,我們可以照葫蘆畫瓢,制作其他虛包上傳到Nexus。
當然,發揮一下腦洞,可以分析一下Maven下載依賴的機制,編程實現一個動態的Maven倉庫,請求任何empty版本的依賴包都返回一個虛包。
這里奉上一個傳送門:
https://github.com/erikvanoosten/version99
嗯,還是Gradle更優雅!