前言
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更优雅!