用功能解决Java日志生态系统的复杂性
目录
引言
Gradle 6.0 在依赖管理方面带来了一些改进,我们在一系列博文中进行了介绍。在这篇文章中,我们将通过功能的概念来探讨检测类路径上不兼容依赖项的问题。
为了说明这个概念,我们将审视Java应用程序和库的日志状态。除了提供java.util.logging
(JUL)的Java核心库外,开发者还可以使用许多可用的日志库,例如
- Apache Log4J
- Slf4J,可能与LOGBack结合使用
- Apache Commons Logging
- Apache Log4J 2
- Google的Flogger
日志问题 #
有了如此广泛的选择,不同库使用不同的日志API也就不足为奇了。结合有时质量不高的元数据,在应用程序的运行时类路径上出现多个日志实现是很常见的。
这会导致Slf4J发出众所周知的警告
SLF4J: Class path contains multiple SLF4J bindings.
SLF4J: Found binding in [jar:file:.../slf4j-log4j12-1.7.29.jar!/org/slf4j/impl/StaticLoggerBinder.class]
SLF4J: Found binding in [jar:file:.../logback-classic-1.2.3.jar!/org/slf4j/impl/StaticLoggerBinder.class]
SLF4J: See http://www.slf4j.org/codes.html#multiple_bindings for an explanation.
SLF4J: Actual binding is of type [org.slf4j.impl.Log4jLoggerFactory]
虽然有人可能会说“这只是一个警告”,但它向我们展示了一个问题可能发生的地方。我们的一位用户,Netflix的工程团队,告诉了我们以下故事:通过选择错误的Slf4J日志绑定,他们系统中的日志文件最终被放到了错误的位置——导致一个磁盘被填满,最终导致系统崩溃。他们肯定不是唯一遇到过类似情况的人。
另一个例子:Slf4J文档清楚地说明,如果你混合使用给定集成中的桥接和委托JAR,你将遇到StackOverflowError
。
Log4J 2为该领域增加了自己的复杂性,因为它还提供了桥接选项,包括Slf4J桥接。
如果我们看看不同的库、它们的交互方式以及它们提供的选项,我们就会得到以下图表
要弄清楚基于你选择的日志框架的正确库组合,需要研究你使用的框架以及你不使用的框架的兼容性说明,因为它们可能在你不注意的情况下通过传递性依赖包含进来。此外,目前没有任何这些需求可供构建工具使用,这意味着工具无法通过告知开发者无效配置或根据所选日志堆栈选择正确的组合来帮助他们。
自动检测无效的日志设置 #
借助Gradle中的功能概念,可以向构建工具提供信息,以便它至少能够检测无效的设置。
功能本质上是软件模块提供的功能的标识符。多个模块可以提供相同功能的实现,这允许Gradle检测不同模块是否冲突。这可以直接应用于依赖图中相同日志API的两个不同实现。
提供功能信息的方式有多种:直接通过组件的元数据,手动添加到构建中,或通过插件提供。
我们为日志领域创建了这样一个插件,它捕获了上述所有日志库所需的信息,并允许你解决冲突。dev.jacomet.logging-capabilities
Gradle插件将确保你永远不会在运行时因无效的日志配置而感到意外,因为你的项目将在构建时报告问题!
让我们回顾一下它处理的一些情况。
Slf4J及其多个绑定 #
由于Slf4J警告多个绑定存在问题,它实际上创建了一种排他性的实现关系。任何实现slf4j-api
以提供绑定的模块不能与类路径上的另一个此类实现共存。
为了检测给定的依赖关系图中是否存在多个Slf4J绑定,该插件将功能dev.jacomet.logging:slf4j-impl:1.0
添加到以下所有模块:logback-classic
、slf4j-simple
、slf4j-log4j12
、slf4j-jdk14
、log4j-slf4j-impl
和slf4j-jcl
。
有了这些信息,在任何已解析的依赖关系图中拥有两个Slf4J绑定都是非法的,从而在构建时强制执行了之前Slf4J只在运行时报告的内容。
下面是一个包含此类失败的示例构建输出
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':doIt'.
> Could not resolve all files for configuration ':runtimeClasspath'.
> Could not resolve org.slf4j:slf4j-simple:1.7.27.
Required by:
project :
> Module 'org.slf4j:slf4j-simple' has been rejected:
Cannot select module with conflict on capability 'dev.jacomet.logging:slf4j-impl:1.0' also provided by [ch.qos.logback:logback-classic:1.2.3(runtime)]
> Could not resolve ch.qos.logback:logback-classic:1.2.3.
Required by:
project :
> Module 'ch.qos.logback:logback-classic' has been rejected:
Cannot select module with conflict on capability 'dev.jacomet.logging:slf4j-impl:1.0' also provided by [org.slf4j:slf4j-simple:1.7.27(runtime)]
Log4J还是其他日志解决方案? #
Log4J的第一个版本发布于2001年,早于JUL的存在,并且自2012年的1.2.17
版本以来没有发布过。
鉴于此,作为开发者,你可能会更倾向于使用更新的解决方案,例如Slf4J绑定或Log4J 2,作为你的日志框架,但你可能正在使用针对Log4J API开发的传递性依赖项。
为了应用你的选择,你需要了解一些潜在的冲突
log4j:log4j
需要被Slf4J的log4j-over-slf4j
或Log4J 2的log4j-1.2-api
替换- 这两个替换项本身是互斥的
- 如果你使用
log4j-over-slf4j
,你不能使用slfj-log4j12
同样,dev.jacomet.logging-capabilities
插件会为你声明所需的功能
- 它将
dev.jacomet.logging:slf4j-vs-log4j
功能添加到log4j-over-slf4j
和slfj-log4j12
- 它将
dev.jacomet.logging:slf4j-vs-log4j2-log4j
功能添加到log4j:log4j
、log4j-over-slf4j
和log4j-1.2-api
这保证了你不会在任何已解析的依赖关系图中混合不兼容的Log4J桥接和实现。
全面的解决方案 #
正如你在增强的图表中看到的,还有许多其他有问题的模块组合
- 与Log4J替换类似,JUL可以被Slf4J或Log4J 2替换
- 对于Apache Commons Logging,Log4J2集成需要
commons-logging
,而Slf4J集成则替换它 - ...
对于所有这些潜在冲突,插件dev.jacomet.logging-capabilities
会注册必要的功能来检测所有无效组合。请访问插件文档以获取功能及其角色的完整列表。
幕后 #
该插件利用Gradle的组件元数据规则来添加功能信息。
查看插件代码,了解它是如何将功能dev.jacomet.logging:slf4j-impl:1.0
添加到所有配置了该规则的模块的。
同样,为上图确定的所有可能的冲突添加了规则。
在发布时增强日志生态系统 #
借助Gradle模块元数据,上一节介绍的概念可以应用于日志库的已发布元数据。这将使自定义ComponentMetadataRule
或上述插件等变得不必要,因为信息将由库作者编码到库的已发布元数据中。
作为库作者,可以为发布添加功能,如Gradle文档所示。
确定要声明的功能 #
工作的重要部分是确定这些共享功能的坐标。理想情况下,这个选择应该由提供可扩展系统的原始库做出。然后,第三方实现者将能够在其实现中遵循功能声明。
我不想让我的构建失败,我希望Gradle来解决! #
我们已经看到了功能如何用于检测冲突并在发生冲突时导致构建失败。但如果无法解决检测到的冲突,这本身并没有帮助。为此,Gradle提供了功能解析策略。
dev.jacomet.logging-capabilities
插件已经设置了这样的解析策略,并提供了简单的结构来选择和激活它们。你可以声明性地表达你的日志选择,插件会确保通过相关的能力解析和替换规则来增强你的构建,从而只在类路径上显示必要的日志库。
以下内容将确保Log4J 2被用作日志实现
plugins {
`java-library`
id("dev.jacomet.logging-capabilities")
}
loggingCapabilities {
enforceLog4J2()
}
它将
- 配置Log4J 2桥接Slf4J,如果图中存在Slf4J桥接
- 配置JUL与Log4J 2的桥接
- 仅在需要时配置
commons-logging
的桥接 - 仅在需要时将
log4j
替换为log4j-1.2-api
但是,它不会添加Log4J 2依赖项,这些依赖项需要作为依赖项添加——直接或间接。
有关可以表达的选择的完整概述,请参阅插件文档。
幕后 #
Gradle提供了API来指示如何解析功能冲突。
插件使用它来告诉Gradle,在dev.jacomet.logging:slf4j-impl
发生冲突时,引擎必须为测试运行时类路径选择模块org.slf4j:slf4j-simple:1.7.25
。冲突解决逻辑会检查可用的候选项并执行有条件的选择。
请注意,在上面的示例中,如果没有功能冲突,也可以使用另一个Slf4J实现。但是,如果你打算使用slf4j-simple
依赖项,很可能你已经在构建中声明了它。并且该插件要求它才能正常工作。
结论 #
我们已经看到,功能是Gradle提供的一种建模概念,用于表达不同库之间的互斥性。正如在日志用例中所示,它们使得Gradle的依赖解析在依赖关系图中发现功能的冲突实现时会失败。与对齐类似,在Gradle模块元数据中使用功能可以让库作者分享更多关于何时以及在哪些组合下使用他们的库的知识。有了这些信息,Gradle为构建作者提供了API,让他们能够在自己的构建中声明性地解决冲突,而无需采用hacky的方法。
可以通过功能概念解决的用例超出了此处演示的日志用例。坐标已更改的库,存在多种格式的库(如cglib
和cglib-nodep
),或者仅仅具有不同功能集的库,都可以利用这一概念来表达将多个模块放在类路径上应被视为错误。