使用功能解决 Java 日志生态系统的复杂性
目录
介绍
Gradle 6.0 在依赖管理方面进行了一些改进,我们在 一系列 博客文章 中介绍了这些改进。在这篇文章中,我们将通过 功能 的概念,探讨检测类路径上不兼容的依赖项。
为了说明这个概念,我们将看看 Java 应用程序和库的日志记录状态。除了 Java 核心库提供的 java.util.logging
(JUL) 之外,开发人员还可以使用许多日志记录库,例如
- 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 存在之前,自 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
s 或像上面这样的插件变得过时,因为信息将由库作者编码到库的已发布 元数据中。
作为库作者,可以为发布添加功能,如 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
,则很可能在构建中声明了 slf4j-simple
依赖项。并且该插件需要它才能正常工作。
结论 #
我们已经看到,功能是 Gradle 提供的一种建模概念,用于表达不同库之间的互斥性。如日志记录用例所示,当在依赖关系图中发现功能的冲突实现时,它们使 Gradle 的依赖关系解析能够失败。与 对齐 类似,将功能与 Gradle 模块元数据结合使用,使库作者能够分享更多关于他们的库何时以及在哪些组合中使用的知识。有了这些信息,Gradle 为构建作者提供了 API,以便在他们自己的构建中以声明方式解决冲突,而无需进行 hack。
可以使用功能 概念解决的用例超出了此处演示的日志记录用例。坐标已更改、以多种格式存在(如 cglib
和 cglib-nodep
)或仅具有不同功能集的库都可以利用此概念来表达类路径上存在多个模块应被视为错误。