利用功能解决 Java 日志生态系统的复杂性

Gradle 6.0 在依赖管理方面进行了一些改进,我们在 系列博客文章 中介绍了这些改进。在这篇文章中,我们将探讨通过“功能”概念检测类路径上不兼容的依赖项。

为了说明这个概念,我们将看看 Java 应用程序和库的日志记录状态。除了 Java 核心库提供 java.util.logging (JUL) 之外,开发人员还可以使用许多日志记录库,例如

日志问题

面对如此丰富的选择,不同的库使用不同的日志 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 桥接。

如果我们看一下不同的库、它们之间的交互方式以及它们提供的选项,最终会得到以下图表

Java logging landscape

根据您选择的日志框架确定正确的库组合需要研究您使用的以及您未使用的框架的兼容性说明,因为它们可能会通过传递依赖项在您不知情的情况下包含进来。此外,目前没有将这些要求提供给构建工具,这意味着工具无法通过让开发人员了解无效配置甚至根据选择的日志堆栈选择正确的组合来帮助开发人员。

自动检测无效的日志设置

借助 Gradle 中的 功能 概念,可以向构建工具提供信息,以便它至少可以检测到无效的设置。

功能本质上是软件模块提供的功能的标识符。多个模块可以提供相同功能的实现,这使得 Gradle 可以检测到不同的模块是否发生冲突。当它们位于依赖关系图上时,这可以直接应用于同一日志 API 的两个不同实现。

可以通过不同的方式提供功能信息:直接通过组件的元数据、手动将其添加到构建中或通过插件提供。

我们为日志记录领域创建了这样一个插件,它捕获了上面提到的所有日志记录库所需的必要信息,并允许您解决冲突。 dev.jacomet.logging-capabilities Gradle 插件 将确保您永远不会在运行时遇到无效的日志记录配置,因为您的项目将在构建时报告问题!

让我们回顾一下它处理的一些情况。

Slf4J 及其多个绑定

由于 Slf4J 警告多个绑定存在问题,因此它实际上创建了一种排他性的实现关系。任何实现 slf4j-api 以提供绑定的模块不能与类路径上的另一个此类实现共存。

为了检测在给定的依赖关系图中是否有多个 Slf4J 绑定,该插件将功能 dev.jacomet.logging:slf4j-impl:1.0 添加到以下所有模块:logback-classicslf4j-simpleslf4j-log4j12slf4j-jdk14log4j-slf4j-implslf4j-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-slf4jslfj-log4j12
  • 它将添加 dev.jacomet.logging:slf4j-vs-log4j2-log4j 功能到 log4j:log4j, log4j-over-slf4jlog4j-1.2-api

这保证了你不会在任何解析的依赖关系图中混合不兼容的 Log4J 桥接和实现。

一个全面的解决方案

Java logging landscape and conflicts

正如你在增强后的图中看到的,还有许多其他有问题的模块组合

  • 与 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()
}

它将

  • 如果图中存在 Slf4J 桥接,则配置 Log4J 2 桥接 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,以便 *声明式* 地解决其自身构建中的冲突,而无需使用黑客技术。

功能概念可以解决的用例超出了这里演示的日志记录用例。 坐标已更改的库、存在于多种格式中的库(例如 cglibcglib-nodep),或者只是具有不同功能集的库,都可以利用此概念来表达在类路径上存在多个模块应该被视为错误。

讨论