为什么 Guava 这样的库需要 POM 文件之外的东西
引言
十多年前,Google 发布了一个新的 Java 集合库。这个库,现在被称为 Google Guava,在接下来的几个月和几年里获得了巨大的发展,并且可能是当今生产代码中使用最多的 Java 库。
由于 Guava 的广泛采用,今天许多其他库都依赖于它。即使你没有直接使用 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")
}
我们期望最终得到一个 JRE(而不是 Android)版本的 Guava,并且我们期望构建工具在我们的类路径上存在其他可疑冲突时通知我们。让我们来看一个 构建扫描,显示依赖图
如果我们仔细看,我们可以观察到一些意想不到的事情:为什么 guava:28.0-jre
会在没有警告的情况下被 guava:28.1-android
取代?为什么会有 google-collections
依赖——那不就是 Guava 吗?为什么我的运行时类路径上需要 j2objc-annotations
?还有这个奇怪的 9999.0-empty-to-avoid-conflict-with-guava
依赖是什么?
为了理解这一点,我们将讨论 Guava 演变过程中出现的依赖管理挑战以及它们是如何被处理的。最后,我们将展示如何使用 Gradle Module Metadata 来避免这些麻烦。
命名很难 #
对于曾经被称为 Google Collections Library 的库来说,依赖管理上的麻烦早就开始了。在 2009 年,com.google.collections:google-collections:1.0
是 Google Collections Library 的最终发布版本在 Maven Central 仓库 发布的坐标。2010 年,Guava 的第一个稳定版本 com.google.guava:guava:10.0
发布,其中包含了所有 Google Collections 的功能以及其他实用工具,取代了 Google Collections Library。
由于从 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。相反,他们发布了一个 ListenableFuture 模块的空版本 – com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava
– Guava 现在依赖于此。这是欺骗 Gradle 的依赖管理引擎,使其在 Guava 使用时始终使用 ListenableFuture 模块的空 9999.0-empty-to-avoid-conflict-with-guava
版本。这样就不会选择包含 ListenableFuture 接口重复的真实版本 1.0
。
虽然这似乎是一个巧妙的方法,并且对于许多使用 Gradle 的 Android 开发场景来说确实如此,但它也存在 问题。JVM 构建作者,如果只使用 Guava,会抱怨即使没有冲突,他们的类路径上总会有一个额外的空 JAR。在 Maven 中,这种方法只在某些设置下有效,因为 Maven 不一定会选择最高的版本,而是选择最近的版本——也就是说,如果 com.google.guava:listenablefuture:1.0
在依赖图中首先被发现,它将被选择,而不是空版本。
当你想要什么但又无法表达时 #
如果你现在认为 Guava 团队在所有这些麻烦中应该做得更好,那你就错了。事实上,正如你从链接的讨论中可以看到的,团队对所做的每个决定都非常谨慎。
麻烦的根本原因是 POM 元数据模型不够有表现力,无法传达所需的信息。正如 Guava 和其他库在过去十年中所展示的,需要在元数据中表达更多信息来解决许多常见用例。为了满足这一需求,我们开发了 Gradle Module Metadata 格式。
有了 Gradle Module Metadata,就可以解决本文所述的问题
- 重命名为 Guava Gradle Module Metadata 提供了 功能(capabilities) 的建模概念。通过它,一个模块可以声明它提供了另一个模块实现的替代实现。
Guava 的每个版本都可以声明它提供了com.google.collections:google-collections
功能,如果 Google Collections 和 Guava 都存在于依赖图中,Gradle 就会检测到冲突。 - 发布更多变体 使用 Gradle Module Metadata,每个模块都可以有任意数量的 变体(variants)。每个变体都可以指向不同的构件(JAR),并且可以具有不同的依赖项。变体由多个属性标识,包括 Java 库的情况下的
org.gradle.jvm.version
属性。
Guava 可以在一个模块中为不同的 Java 版本、JRE(Java 8)和 Android(Java 6/7)发布变体。Gradle 将根据使用的 Java 版本选择正确的变体。 - 仅编译时依赖 使用 Gradle Module Metadata 发布的模块会显式定义 *运行时* 和 *编译时(API)* 变体,其中每个变体独立定义依赖项。
通过这种灵活的 API 和实现分离,Guava 可以仅将注解库依赖添加到编译时变体,从而防止它们泄露到运行时类路径中。 - 将 ListenableFuture 复制到第二个模块 Gradle Module Metadata 提供的 功能(capabilities) 概念,可用于解决重命名问题,也可用于此处。
Guava 可以声明它提供了com.google.guava:listenablefuture
功能,这足以让 Gradle 在listenablefuture
模块出现在依赖图中时检测到与真实listenablefuture
模块的冲突。在最常见的情况下,没有冲突时,此功能将不起作用。可以删除对空listenablefuture
模块的依赖。
结论 #
在这篇博文中,我们带您回顾了 Guava 的历史,作为一个广泛使用的、不断发展的库的例子。正如您可能亲自体验过的,对于这样的库,版本和变体冲突的可能性很高。但是,通过发布足够多的元数据,构建工具可以检测并解决这些冲突。
正如我们所展示的,Guava 的开发者对此非常清楚,并且没有轻率地做出决定。然而,他们多次受到 POM 格式表现力的限制。尽管他们付出了最大的努力并应用了多种技巧,但许多构建作者仍然会遇到与 Guava 相关的未检测到和未解决的冲突问题。
为了将来改善这种情况,我们创建了一个 pull request,提议为 Guava 发布 Gradle Module Metadata。对于已经发布的 Guava 版本或其他库,Gradle 允许您编写一个 组件元数据规则 来添加缺失的元数据。我们为已发布的 Guava 版本 编写了这样一个规则,并将其作为 Gradle 插件提供。如果您将 此插件 应用于您的构建,您就可以自己探索我们在博文中描述的内容。
plugins {
id("de.jjohannes.missing-metadata-guava") version "0.1"
}
如果我们把插件添加到开头示例中,您就可以观察到,例如,缩小的运行时类路径 以及 选择了 Guava 的 Java 8 版本,尽管选择了 28.1-android
版本,但它提供了 guava-28.1-jre.jar。
如果您自己正在开发库,或者知道有面临类似问题的库,请随时 与我们联系。我们很乐意通过发布 Gradle Module Metadata 来探索改进。