为什么像 Guava 这样的库需要比 POM 更多的东西
十多年前,Google 发布了一个新的 Java 集合库。这个库现在被称为 Google Guava,在接下来的几个月和几年里获得了很大的关注,并且可能是今天生产代码中使用最多的 Java 库。
由于 Guava 的广泛采用,今天许多其他库都依赖于它。很有可能你会在任何相当大的 Java 项目的类路径中找到它,通过传递依赖关系,即使它没有直接使用。随着越来越多的代码依赖于这样一个广泛使用的库,冲突的可能性增加,增加了项目的 依赖地狱。
考虑以下看起来无害的依赖声明块
dependencies {
implementation("com.google.guava:guava:28.0-jre")
implementation("org.codehaus.plexus:plexus-container-default:2.1.0")
implementation("com.google.api-client:google-api-client:1.30.7")
}
我们预计最终会使用 Guava 的 JRE 版本(而不是 Android 版本),并且我们希望构建工具在我们的类路径上出现其他可疑冲突时通知我们。让我们看一下构建扫描,其中显示了依赖关系图。
如果仔细观察,我们会发现一些意外情况:为什么guava:28.0-jre
被guava:28.1-android
替换,却没有发出警告?为什么存在google-collections
依赖项——它不是与 Guava 相同吗?为什么我的运行时类路径上需要j2objc-annotations
?这个奇怪的9999.0-empty-to-avoid-conflict-with-guava
依赖项是什么?
为了理解这一点,我们将讨论 Guava 发展过程中出现的依赖项管理挑战以及如何处理这些挑战。最后,我们将展示如何使用 Gradle 模块元数据来避免这些问题。
命名很困难
依赖项管理问题很早就出现在以前被称为 Google Collections 库的项目中。com.google.collections:google-collections:1.0
是Google Collections 库的最终版本发布到Maven 中央仓库时的坐标,时间是 2009 年。2010 年,第一个稳定版本的Guava,com.google.guava:guava:10.0
,发布了,其中包括所有 Google Collections 以及其他实用程序——取代了 Google Collections 库。
由于从google-collections
到guava
的“重命名”,Gradle 和 Maven 的依赖项管理引擎不再能够检测 Google Collections 和 Guava 版本之间的冲突。这种未处理的冲突导致类路径上存在两个 jar 包,它们包含不同版本的 Google Collections 类。如果构建作者偶然发现了冲突,他们必须手动排除google-collections
作为传递依赖项,或者在 Gradle 中注册替换规则。
字母版本控制问题
当 Guava 的22.0
版本于 2017 年 5 月发布时,Guava 已经从 Java 6 迁移到 Java 8。然而,Android 仍然停留在 Java 6 上。如果没有改变,Android 用户将永远停留在旧版本上。因此,Guava开始发布一个单独的 Android 版本,其中删除了所有特定于 Java 8 的功能。
这两个变体使用相同的 com.google.guava:guava
坐标发布,但版本字符串不同:22.0
和 22.0-android
。经过在 GitHub 上的长时间讨论和公开的 GoogleDoc,版本模式从 23.1 开始更改为 23.1-jre
和 23.1-android
。与使用不同的分类器或坐标来区分不同变体不同,使用不同的版本允许 Gradle 和 Maven 中的依赖冲突解析检测到冲突,并只选择两个变体中的一个。(-jre
后缀的引入是为了确保 Maven 始终将 -jre
版本视为高于 -android
版本,因为 j
在字母顺序上优于 a
。)
J(并不总是)优于 A
虽然引入 -jre
后缀解决了 Maven 用户的一些问题,但如果涉及多个版本的 Guava,Gradle 和 Maven 用户仍然会遇到问题。
再次查看我们的 初始示例,实际版本和变体都编码在版本字符串中:28.0-jre
和 28.1-android
。构建工具无法知道如何使用此信息。Gradle 查看完整版本字符串,选择更高的版本:28.1-android
。这是一个没有 Java 8 特定类的版本,这很可能破坏依赖于这些类的代码。最好的解决方案可能是选择 28.1-jre
,因为它满足了两个要求:28.1
(假设与 28.0
兼容)和 jre
(与 android
兼容)。但是,无法使用 POM 元数据独立地请求版本和变体。
令人讨厌的注解库
Guava 的代码在很大程度上是自包含的,避免了额外的依赖关系,因为它意识到其广泛使用。然而,随着时间的推移,Guava 添加了一些对注解库的依赖关系,例如 com.google.code.findbugs:jsr305
或 com.google.errorprone:error_prone_annotations
,这些依赖关系在编译时需要。
许多 用户对这些依赖关系感到厌烦,因为它们也存在于运行时类路径中。注解库依赖关系只是为了避免在 Java 编译器检查 Guava 类上的注解时出现编译时警告。在运行时,不会触及注解,因此不需要注解库 jar 包。但是,在 POM 中定义的每个编译范围依赖关系都会自动出现在运行时类路径中,并且没有概念来声明仅用于编译时的依赖关系。
重复问题
在 2018 年 9 月,一个接口 - ListenableFuture
- 从 Guava 复制到一个单独的模块 - com.google.guava:listenablefuture:1.0
- 以 允许 Android 开发人员在他们的 API 中使用它,而无需依赖 Guava 的所有内容。为了保持 Guava 的自包含性,开发团队决定复制该接口,而不是完全将其移出 Guava。相反,他们在 Guava 依赖的 ListenableFuture 模块上发布了一个空版本 - com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava
-。这欺骗了 Gradle 的依赖管理引擎,使其在使用 Guava 时始终使用 ListenableFuture 模块的空版本 9999.0-empty-to-avoid-conflict-with-guava
。然后,包含 ListenableFuture
接口重复的真实版本 1.0
不会被选中。
虽然这似乎是一种巧妙的方法,并且在使用 Gradle 的许多 Android 开发情况下确实如此,但它也存在 问题。仅使用 Guava 的 JVM 构建作者抱怨说,即使没有冲突,他们始终在类路径上有一个额外的空 jar。在 Maven 中,这种方法仅在某些设置中有效,因为 Maven 不一定选择最高版本,而是选择最接近的版本 - 也就是说,如果 com.google.guava:listenablefuture:1.0
首先在依赖关系图中被发现,它将被选中,而不是空版本。
当你想要什么,但无法表达出来的时候
如果你现在认为 Guava 团队应该在所有这些麻烦中做得更好,那么你就错了。事实上,正如你从链接的讨论中看到的那样,该团队对所做的每个决定都非常关注。
这些麻烦的根本原因是 POM 元数据模型不够表达,无法传达所需的信息。正如 Guava 和其他库在过去十年中所展示的那样,需要在元数据中表达更多信息来解决许多常见用例。为了解决这一需求,我们开发了 Gradle 模块元数据 格式。
使用 Gradle 模块元数据,可以解决本文中描述的问题
- 重命名为 Guava Gradle 模块元数据提供了 功能 的建模概念。通过它,模块可以表达它提供了另一个模块实现的某些内容的替代实现。
Guava 的每个版本都可以声明它提供了com.google.collections:google-collections
功能,然后 Gradle 会在 Google Collections 和 Guava 都是依赖关系图的一部分时检测到冲突。 - 发布更多变体 使用 Gradle 模块元数据,每个模块都有任意多个 变体。每个变体都可以指向不同的工件(jar)并且可以具有不同的依赖项。变体由许多属性标识,包括在 Java 库的情况下,
org.gradle.jvm.version
属性。
Guava 可以在一个模块中发布针对不同 Java 版本、JRE(Java 8)和 Android(Java 6/7)的变体。然后 Gradle 将根据使用的 Java 版本选择正确的变体。 - 仅编译时依赖项 使用 Gradle 模块元数据发布的模块明确定义了运行时和编译时(api)变体,其中每个变体都独立定义依赖项。
使用这种灵活的 API 和实现分离,Guava 可以仅将注释库依赖项添加到编译时变体,以防止它们泄漏到运行时类路径。 - 将 ListenableFuture 复制到第二个模块 Gradle 模块元数据提供的 功能 概念可以用来解决重命名问题,也可以在这里使用。
Guava 可以声明它提供了com.google.guava:listenablefuture
功能,这足以让 Gradle 在依赖关系图中出现真正的listenablefuture
模块时检测到冲突。在最常见的情况下,如果没有冲突,该功能将不会有任何影响。对空listenablefuture
模块的依赖项可以被移除。
结论
在这篇博文中,我们带您踏上了 Guava 历史之旅,它是一个广泛使用的不断发展的库的例子。正如您可能自己经历的那样,对于这样的库,版本和变体冲突的可能性很高。但是,通过发布适量的元数据,构建工具可以检测到这些冲突并解决这些冲突。
正如我们所展示的,Guava 的开发人员非常清楚这一点,并没有轻易做出决定。但是,他们多次受到 POM 格式表达能力的限制。尽管他们付出了最大的努力,并应用了多种技巧,但许多构建作者仍然面临着与 Guava 相关的未检测到和未解决的冲突问题。
为了改善未来的情况,我们创建了一个 pull request,该请求建议为 Guava 发布 Gradle 模块元数据。对于已经发布的 Guava 版本或其他库,Gradle 允许您编写 组件元数据规则 来添加缺少的元数据。我们已经为 Guava 的已发布版本编写了 这样的规则,并将其作为 Gradle 插件提供。如果您将 此插件 应用于您的构建,您可以自己探索我们在博文中描述的内容
plugins {
id("de.jjohannes.missing-metadata-guava") version "0.1"
}
如果将插件添加到一开始的示例中,您可以观察到,例如,减少的运行时类路径,以及选择了 Guava 的 Java 8 变体,它提供了guava-28.1-jre.jar,尽管选择了28.1-android
版本。
如果您正在自己开发库,或者了解遇到类似问题的库,请随时联系我们。我们很乐意通过发布 Gradle 模块元数据来探索改进!