转载:https://mp.weixin.qq.com/s/eIiu08fVk194E0BgGL5gow
一、 日志体系
日志发展到今天,被抽象成了三层:接口层、实现层、适配层:
- 接口层:或者叫日志门面(facade),就是interface,只定义接口,等着别人实现。
- 实现层:真正干活的、能够把日志内容记录下来的工具。但请注意它不是上边接口实现,因为它不感知也不直接实现接口,仅仅是独立的实现。
- 适配层:一般称为Adapter,它才是上边接口的implements。因为接口层和适配层并非都出自一家之手,它们之间无法直接匹配。而鲁迅曾经说过:「计算机科学领域的任何问题都可以通过增加一个中间层来解决」(All problems in computer science can be solved by another level of indirection. – David Wheeler[1]),所以就有了适配层。
适配层又可以分为绑定(Binding)和桥接(Bridging)两种能力:
- 绑定(Binding):将接口层绑定到某个实现层(实现一个接口层,并调用实现层的方法)
- 桥接(Bridging):将接口层桥接到另一个接口层(实现一个接口层,并调用另一个接口层的接口),主要作用是方便用户低成本的在各接口层和适配层之间迁移
二、日志框架与他们之间关系
Log4j
Log4j相比于System.out提供了更强大的能力,甚至很多思想到现在仍被广泛接受:
- 日志可以输出到控制台、文件、数据库,甚至远程服务器和电子邮件(被称做 Appender);
- 日志输出格式(被称做 Layout)允许定制,比如错误日志和普通日志使用不同的展现形式;
- 日志被分为5个级别(被称作Level),从低到高依次是debug, info, warn, error, fatal,输出前会校验配置的允许级别,小于此级别的日志将被忽略。除此之外还有all, off两个特殊级别,表示完全放开和完全关闭日志输出;
- 可以在工程中随时指定不同的记录器(被称做Logger),可以为之配置独立的记录位置、日志级别;
- 支持通过properties或者xml文件进行配置;
不过Log4j有比较明显的性能短板,在Logback和Log4j 2推出后逐渐式微,最终Apache在2015年宣布终止开发Log4j并全面迁移至Log4j 2[10](可参考【2.7 Log4j 2 (2012)】)。
JUL
ava官方的日志系统才随Java 1.4发布。这套系统称做Java Logging API,包路径是java.util.logging,简称JUL。它在Log4j面前仍无太多亮点,广大开发者并没有迁移的动力,导致JUL始终未成气候
JCL (接口层)
对于独立且轻量的项目来说,开发者可以根据喜好使用某个日志方案即可。但更多情况是一套业务系统依赖了大量的三方工具,而众多三方工具会各自使用不同的日志实现,当它们被集成在一起时,必然导致日志记录混乱。
为此Apache在2002年推出了一套接口Jakarta Commons Logging[15],简称 JCL。这套接口主动支持了Log4j、JUL、Apache Avalon、Lumberjack等众多日志工具。开发者如果想打印日志,只需调用JCL的接口即可,至于最终使用的日志实现则由最上层的业务系统决定。我们可以看到,这其实就是典型的接口与实现分离设计;
但因为是先有的实现(Log4j、JUL)后有的接口(JCL),所以JCL配套提供了接口与实现的适配层(没有使用它的最新版,原因会在【1.2.7 Log4j2 (2012)】提到):
简单介绍一下JCL自带的几个适配层/实现层:
-
AvalonLogger/LogKitLogger:用于绑定Apache Avalon的适配层,因为Avalon 不同时期的日志包名不同,适配层也对应有两个
-
Jdk13LumberjackLogger:用于绑定Lumberjack的适配层
-
Jdk14Logger:用于绑定JUL(因为JUL从JDK 1.4开始提供)的适配层
-
Log4JLogger:用于绑定Log4j的适配层
-
NoOpLog:JCL自带的日志实现,但它是空实现,不做任何事情
-
SimpleLog:JCL自带的日志实现 ,让用户哪怕不依赖其他工具也能打印出日志来,只是功能非常简单
现在JCL作为Apache Commons[18]的子项目,叫 Apache Commons Logging,与我们常用的Commons Lang[19]、Commons Collections [20]等是师兄弟。但JCL的简写命名被保留了下来,并没有改为ACL;
Slf4j(接口层)
Slf4j也是一个接口层,接口设计与JCL非常接近(毕竟有师承关系)。相比JCL有一个重要的区别是日志实现层的绑定方式:JCL是动态绑定,即在运行时执行日志记录时判定合适的日志实现;而Slf4j选择的是静态绑定,应用编译时已经确定日志实现,性能自然更好。这就是常被提到的classloader问题,更详细地讨论可以参考What is the issue with the runtime discovery algorithm of Apache Commons Logging[24]以及Ceki自己写的文章Taxonomy of class loader problems encountered when using Jakarta Commons Logging[25]。
在推出Slf4j的时候,市面上已经有了另一套接口层JCL,为了将选择权交给用户(我猜也为了挖JCL的墙角),Slf4j推出了两个桥接层:
- jcl-over-slf4j:作用是让已经在使用JCL的用户方便的迁移到Slf4j 上来,你以为调的是JCL接口,背后却又转到了Slf4j接口。我说这是在挖JCL的墙角不过分吧?
- slf4j-jcl:让在使用Slf4j的用户方便的迁移到JCL上,自己的墙角也挖,主打的就是一个公平公正公开。
Slf4j通过推出各种适配层,基本满足了用户的所有场景,我们来看一下它的全家桶:
Logback(适配+实现层)
Logback。无论是易用度、功能、还是性能,Logback 都要优于Log4j,再加上天然支持Slf4j而不需要额外的适配层,自然拥趸者众。目前Logback已经成为Java社区最被广泛接受的日志实现层(Logback自己在2021年的统计是48%的市占率;
相比于Log4j,Logback提供了很多我们现在看起来理所当然的新特性:
- 支持日志文件切割滚动记录、支持异步写入
- 针对历史日志,既支持按时间或按硬盘占用自动清理,也支持自动压缩以节省硬盘空间
- 支持分支语法,通过, , 可以按条件配置不同的日志输出逻辑,比如判断仅在开发环境输出更详细的日志信息
- 大量的日志过滤器,甚至可以做到通过登录用户Session识别每一位用户并输出独立的日志文件
- 异常堆栈支持打印jar包信息,让我们不但知道调用出自哪个文件哪一行,还可以知道这个文件来自哪个jar包
Logback主要由三部分组成(网上各种文章在介绍classic和access时都描述的语焉不详,我不得不直接翻官网文档找更明确的解释):
- logback-core:记录/输出日志的核心实现
- logback-classic:适配层,完整实现了Slf4j接口
- logback-access[28]:用于将Logback集成到Servlet容器(Tomcat、Jetty)中,让这些容器的HTTP访问日志也可以经由强大的Logback输出
Log4j 2(接口+实现)
看着Slf4j + Logback搞的风生水起,Apache自然不会坐视不理,终于在2012年憋出一记大招:Apache Log4j 2[29],它自然也有不少亮点:
- 插件化结构[30],用户可以自己开发插件,实现Appender、Logger、Filter完成扩展
- 基于LMAX Disruptor的异步化输出[31],在多线程场景下相比Logback有10倍左右的性能提升,Apache官方也把这部分作为主要卖点加以宣传,详细可以看Log4j 2 Performance[32]。
Log4j 2主要由两部分组成:
- log4j-core:核心实现,功能类似于logback-core
- log4j-api:接口层,功能类似于Slf4j,里面只包含Log4j 2的接口定义
你会发现Log4j 2的设计别具一格,提供JCL和Slf4j之外的第三个接口层(log4j-api,虽然只是自己的接口),它在官网API Separation[33]一节中解释说,这样设计可以允许用户在一个项目中同时使用不同的接口层与实现层。
不过目前大家一般把Log4j 2作为实现层看待,并引入JCL或Slf4j作为接口层。特别是JCL,在时隔近十年后,于2023年底推出了1.3.0 版[34],增加了针对Log4j 2的适配
- Log4j 2虽然顶着Log4j的名号,但却是一套完全重写的日志系统,无法只通过修改Log4j版本号完成升级,历史用户升级意愿低
- Log4j 2比Logback晚面世6年,却没有提供足够亮眼及差异化的能力(前边介绍的两个亮点对普通用户并没有足够吸引力),而Slf4j+Logback这套组合已经非常优秀,先发优势明显
spring-jcl(适配层)
Spring/Spring Boot搭建,所以我额外介绍一下spring-jcl [38]这个包,目前Spring Boot用的就是spring-jcl + Logback这套方案。
现在Spring又想支持Slf4j,又要保证向前兼容以支持JCL,于是从5.0(Spring Boot 2.0)开始提供了spring-jcl这个包。它顶着Spring的名号,代码中包名却与JCL 一致(org.apache.commons.logging),作用自然也与JCL一致,但它额外适配了Slf4j,并将Slf4j放在查找的第一顺位,从而做到了「既要又要」(你可以回到【1.2.4 JCL (2002.8)】节做一下对比)。
如果你是基于Spring Initialize [40]新创建的应用,可以不必管这个包,它已经在背后默默工作了;如果你在项目开发过程中遇到包冲突,或者需要自己选择日志接口和实现,则可以把spring-jcl当作JCL对待,大胆排除即可。
其他
- Flogger[41]:由Google在2018年推出的日志接口层。首字母F的含义是Fluent,这也正是它的最大特点:链式调用(或者叫流式API,Slf4j 2.0也支持Fluent API 了,我们会在后续系列文章中介绍)
- JBoss Logging[42]:由RedHat在约2010年推出,包含完整的接口层、实现层、适配层
- slf4j-reload4j[43]:Ceki基于Log4j 1.2.7 fork出的版本,旨在解决Log4j的安全问题,如果你的项目还在使用Log4j且不想迁移,建议平替为此版本。(但也不是所有安全问题都能解决,具体可以参考上边的链接)
因为这些日志框架我们在实际开发中用的很少,此文也不再赘述了(主要是我也不会)。
三、总结
历史介绍完了,但故事并没有结束。两个接口(JCL、Slf4j)四个实现(Log4j、JUL、Logback、Log4j2),再加上无数的适配层,它们之间串联成了一个网,我专门画了一张图:
解释/补充一下这张图:
- 相同颜色的模块拥有相同的groupId,可以参考图例中给出的具体值。
- JCL的适配层是直接在它自己的包中提供的,详情我们在前边已经介绍过,可以回【1.2.4 JCL (2002.8)】查看。
- 要想使用Logback,就一定绕不开Slf4j(引用它的适配层也算);同样的,要想使用 Log4j 2,那它的log4j-api也绕不开。
如果你之前在看「1.1 前言」时觉得过于抽象,那么此时建议你再回头看一下,相信会有更多体会。
从这段历史,我也发现了几个有趣的细节:
- 在Log4j 2面世前后的很长一段时间,Slf4j及Logback因为没有竞争对手而更新缓慢。英雄没有对手只能慢慢垂暮,只有棋逢对手才能笑傲江湖。
- 技术人的善良与倔强:面世晚的产品都针对前辈产品提供支持;面世早的产品都不搭理它的「后辈」。
- 计算机科学领域的任何问题都可以通过增加一个中间层来解决,如果不行就两个(桥接层干的事儿)。
- Ceki一人肩挑Java日志半壁江山25年(还在增长ing),真神人也。(当然在代码界有很多这样的神人,比如Linus Torvalds[44]维护Linux至今已有33年,虽然后期主要作为产品经理参与,再比如已故的Bram Moolenaar[45]老爷子持续维护 Vim 32年之久)。
四、最佳实践 Slf4j+Logback
Slf4j+Logback,选择它们的原因如下:
- Slf4j的API相比JCL更丰富,且得到Intellij IDEA编辑器的完整支持。这是核心优势,我们会在《Java日志通关(三) - Slf4介绍》中详细讲解;
- Slf4j支持日志内容惰性求值,相比JCL性能更好(性能其实也没差多少[1],但码农总是追求极致);
- 在前边选定Slf4j的前提下,同一厂牌且表现优异的Logback自然中标(并无暗箱操作,举贤不避亲);
- Slf4j+Logback 是目前大部分开发者的选择(2021年Slf4j 76%、Logback 48%[2]),万一遇到问题参考文档会多一些;
4.1 基础依赖项
据前边的知识,我们可以很容易的知道以下三个包是必须的:
- Slf4j是基本的日志门面,它的核心API在org.slf4j:slf4j-api中;
- Logback的核心实现层在ch.qos.logback:logback-core中;
- Logback针对Slf4j的适配层在ch.qos.logback:logback-classic中;
其中logback-classic会直接依赖另外两项,而且它依赖的一定是它能够支持的最合适版本,所以为了避免歧义,我们可以在项目中仅显式依赖logback-classic即可。当然你想提升版本权重,单拎出来也可以。
另外要注意,Slf4j和Logback的版本并不完全向前兼容,它们之间也有对应关系,下边我们逐一介绍。
4.1.1 Slf4j 版本兼容性
Slf4j 2.0.x有不小的改动[3],不再主动查找org.slf4j.impl.StaticLoggerBinder,而是改用JDK ServiceLoader[4](也就是SPI,Service Provider Interface) 的方式来加载实现。这是JDK 8中的特性,所以Slf4j对JDK的依赖显而易见:
4.1.2 Logback 版本兼容性
从前边的版本兼容性我们可以知道:
- 如果使用JDK 8,建议选择Slf4j 2.0 + Logback 1.3;
- 如果使用JDK 11及以上,建议选择Slf4j 2.0 + Logback 1.5;
但还没完,Spring Boot的日志系统[8]对Slf4j和Logback又有额外的版本要求。我们放在下一节讨论这个问题。
适配 Spring Boot
Spring Boot通过spring-boot-starter-logging[9]包直接依赖了Logback(然后再间接依赖了 Slf4j),它通过org.springframework.boot.logging.LoggingSystem[10]查找日志接口并自动适配,所以我们使用Spring Boot时一般并不需要关心日志依赖,只管使用即可。但因为Slf4j 2.0.x与Slf4j 1.7.x实现不一致,导致Spring Boot也会挑版本:
4.2 桥接其他实现层
我们还要保证项目中依赖的二方、三方包能够正常打印出日志,而它们可能依赖的是 JCL/Log4j/Log4j2/JUL,我们可以统一引入适配层做好桥接:
- 通过org.slf4j:jcl-over-slf4j 将JCL桥接到Slf4j 上;
- 通过org.slf4j:log4j-over-slf4j 将Log4j桥接到Slf4j 上;
- 通过org.slf4j:jul-to-slf4j 将JUL桥接到Slf4j上;
通过org.apache.logging.log4j:log4j-to-slf4j 将Log4j 2桥接到Slf4j上;
注意,所有org.slf4j的包版本要完全一致,所以如果引入这些桥接包,要保证它们的版本与前边选择的slf4j-api版本对应。为此Slf4j从2.0.8开始提供了bom包,省去了维护每个包版本的烦恼(至于低版本就只能人肉保证版本一致性了):
<dependencyManagement>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-bom</artifactId>
<version>2.0.9</version>
<type>pom</type>
</dependency>
</dependencyManagement>
让我比较意外的是log4j-to-slf4j这个包,它很「健壮」,对Slf4j 1和Slf4j 2都能够支持,棒棒的。
4.2.1去除无用依赖
其实桥接层就是个「李鬼」,使用与被桥接包一样的包结构,再暗渡陈仓将调用转到另一个接口上。所以如果同时引入桥接层以及被桥接的包,大概率会引起包冲突。
由于很多工具会在不经意间引入日志接口层/实现层,所以我们有必要从整个应用级别着眼,把那些无用的接口层/实现层排除掉,包括JCL、Log4j和Log4j 2:
- 排掉JCL:commons-logging:commons-logging
- 排掉Log4j:log4j:log4j
- 排掉Log4j 2:org.apache.logging.log4j:log4j-core
以及,如果项目间接引入了其他的桥接包,也可能会引起冲突,需要排掉。或者你也可以对照【1.3 总结】中所列的包关系,自行判定是否真的需要它。真实项目环境复杂,我们就不在这里一个个枚举了。
Gradle 统一排包方案
如果你使用的是Gradle,可以使用 all*.exclude[13]全局排除对应的包。
Maven 统一排包方案
-
方案一:将要排掉的包通过引入一个占位的空包(版本号一般比较特殊,比如999-not-exist),从而达到排包的目的。但这种特殊版本的空包一般在Mvnrepository Central仓库是没有的(各厂的私有仓库一般会有这种包),你可以自己搭建私有仓库并上传这个版本,或者使用Version 99 Does Not Exist [16]也行。这是最完美的方案,无论本地运行还是远程编译都不会有问题。
-
方案二:将需要排掉的包使用provided标识,这样这个包在编译时会被跳过,从而达到排包的目的,但此包在本地运行时仍会被引入,导致本地运行与远程机器环境差异,不利于调试。
-
方案三:使用maven-enforcer-plugin[17]插件标识哪些包是要被排掉的,它只是一个校验,实际上你仍然需要在每个引入了错误包的依赖中进行排除。
4.3 最终依赖
- JDK 8/11 + Spring Boot 1.5/2
基础
- org.slf4j:slf4j-api:1.7.36
- ch.qos.logback:logback-core:1.2.13
- ch.qos.logback:logback-classic:1.2.13
桥接包
- org.slf4j:jcl-over-slf4j:1.7.36
- org.slf4j:log4j-over-slf4j:1.7.36
- org.slf4j:jul-to-slf4j:1.7.36
- org.apache.logging.log4j:log4j-to-slf4j:2.23.1
排包
- commons-logging:commons-logging:99.0-does-not-exist
- log4j:log4j:99.0-does-not-exist
- org.apache.logging.log4j:log4j-core:99.0-does-not-exist
6.2 JDK 17/21 + Spring Boot 3
基础
- org.slf4j:slf4j-bom:2.0.12 通过 BOM 包统一管理依赖
- ch.qos.logback:logback-core:1.4.14
- ch.qos.logback:logback-classic:1.4.14
桥接包
- org.slf4j:jcl-over-slf4j 参考【七、注意事项】
- org.slf4j:log4j-over-slf4j 参考【七、注意事项】
- org.slf4j:jul-to-slf4j 参考【七、注意事项】
- org.apache.logging.log4j:log4j-to-slf4j:2.23.1
排包
- commons-logging:commons-logging:99.0-does-not-exist
- log4j:log4j:99.0-does-not-exist
- org.apache.logging.log4j:log4j-core:99.0-does-not-exist
注意事项
我们在实际项目中将主要做两类事情:
- 引入期望的包,并指定版本;
- 排除一些包(指定空版本);
上边这两个动作,一般我们都是在父POM的中完成的,但这只是管理包版本,在项目没有实际引用之前,并不会真的加载。
在实际项目中,我们一般会按照这个思路来处理:
- 有一个模块A,依赖Log4j打印日志,所以它依赖了log4j:log4j包;
- 我们在父POM中把log4j:log4j排掉了,此时模块A调用Log4j时会报错;
- 我们在父POM中引入log4j-over-slf4j,目标是把Log4j切到Slf4j,让模块A不报错;
看起来很完美,项目也能正常启动。但当模块A需要打印日志时,我们却还是得到了一个错误log4j:WARN No appenders could be found for logger (xxx.xxx.xxx)。这是因为log4j-over-slf4j并没有真的被引入我们的项目中(很少有哪个二方包会引这种东西,会被骂的)。
解决方案也很简单,将log4j-over-slf4j通过引入即可,在父POM做这个事也行,在实际有依赖的子POM也行。
4.4 常见问题
其实按照上边我们介绍的接入方案操作,你已经不太会遇到下边这些问题了。不过大多数同学是在维护项目中突然踩了坑,灭火要紧,所以我还是收集了一些日志相关的常见报错,并且尝试给出原因及修复方案(我猜测这可能会是本系列阅读量最高的一篇)。
在编写这部分内容时,我从Slf4j官网和Logback官网获得了大量有用信息,主要有(推荐你也看看):
- SLF4J warning or error messages and their meanings[18]
- Frequently Asked Questions about SLF4J[19]
- Bridging legacy APIs[20]
- Logback error messages and their meanings[21]
- Frequently Asked Questions (Logback)[22]
Q1: LoggerFactory is not a Logback LoggerContext but Logback is on the classpath. Either remove Logback or the competing implementation
包冲突,排掉不需要的 Slf4j 适配层即可,一般是logback-classic和slf4j-log4j12冲突,根据你使用的是Logback还是Log4j 2,把另一个排掉。
深究的话,是因为Spring Boot在启动时会通过LoggingSystem获取当前有效的日志系统(参考【三、适配Spring Boot】),默认支持Slf4j、Logback、Log4j 2、JUL:
在获取Logback时,因为它和其他Slf4j适配层(如slf4j-log4j12)都有名为StaticLoggerBinder的实现,如果命中的不是Logback实现,就会报这个错。
Q2: java.lang.NoClassDefFoundError: org/slf4j/impl/StaticLoggerBinder
版本不匹配,因为Slf4j 2.0.x改用SPI方式加载实现(参考【2.2.1 Slf4j 版本兼容性】),当你引入的Slf4j和Logback(或Log4j 2)版本不匹配时,就会导致这个报错。
Q3:java.lang.ClassNotFoundException: org.slf4j.impl.StaticLoggerBinder
与Q2原因相同。
Q4:java.lang.ClassCastException:org.apache.logging.slf4j.SLF4JLoggerContext cannot be cast to org.apache.logging.log4j.core.LoggerContext
包冲突,大概率同时引入了Log4j 2和针对它的桥接层。原因可以参考【五、去除无用依赖】,但具体排包方案要看你希望使用哪个日志系统了,这里无法明确给出答案,但指导思想就是只保留一个。
Q5:java.lang.ClassCastException:org.slf4j.impl.Log4jLoggerFactory cannot be cast to ch.qos.logback.classic.LoggerContext
包冲突,大概率同时引入了多个 Slf4j 的适配层,很可能是 logback-classic 和 slf4j-log4j12。原因和解法都与 Q4 类似。
Q6: SLF4J: No SLF4J providers were found.
只有 slf4j-api 接口,没有适配层。一般添加 logback-classic 或者 slf4j-log4j12 即可解决。
Q7: SLF4J: Failed to load class “org.slf4j.impl.StaticLoggerBinder”.
只有 slf4j-api 接口,没有适配层。一般添加 logback-classic 或者 slf4j-log4j12 即可解决。
不过我在自测时,发现明明有 logback-classic,明明项目启动正常,明明日志输出正常,但还可能会报这个错,我还没查到原因,期待高手解惑。
Q8: SLF4J: Class path contains multiple SLF4J bindings.
Slf4j 发现了多个适配层,一般在这条错误日志后,会列出所有的适配层包路径,把不需要的包排掉即可。
当然 Slf4j 会自动选择第一项,所以如果只出现这一个错误,系统很可能正常启动、正常打日志,从表现来看一切正常。
Q9: log4j:WARN No appenders could be found for logger (xxx.xxx.xxx)
一般将 log4j-over-slf4j 引到项目中可以解决。具体原因请参考【2.7 注意事项】节。
Q10:java.lang.UnsupportedClassVersionError:ch/qos/logback/classic/spi/LogbackServiceProvider has been compiled by a more recent version of the Java Runtime (class file version 55.0), this version of the Java Runtime only recognizes class file versions up to 52.0
你在用JDK 8(52.0),但引入的Logback版本 >=1.3(它只支持JDK 11+,即 55.0)。具体兼容性请参考【2.2 Logback 版本兼容性】
Q11: Failed to load class org.slf4j.impl.StaticLoggerBinder
我在某工程中遇到了这个报错,但项目一切正常,还没找到原因。