为什么像 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)变体,并且我们期望构建工具在我们的类路径中存在其他可疑冲突时通知我们。让我们看一下 构建扫描,显示依赖关系图

Build scan showing a dependency graph with conflicting Guava versions

如果我们仔细观察,我们可以观察到一些意想不到的事情:为什么 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 Library 的时候就开始了。com.google.collections:google-collections:1.0Google Collections Library 的最终版本发布到 2009 年的 Maven 中央仓库 的坐标。2010 年,Guava 的第一个稳定版本 com.google.guava:guava:10.0 发布,其中包括所有 Google Collections 以及其他实用程序——取代了 Google Collections Library。

由于从 google-collectionsguava 的“重命名”,Gradle 和 Maven 的依赖管理引擎不再能够检测 Google Collections 和 Guava 版本之间的冲突。这种未处理的冲突导致类路径上有两个包含不同版本 Google Collections 类的 jar 包。在构建作者偶然发现冲突的情况下,他们必须 手动排除 google-collections 作为传递依赖,或者在 Gradle 中,注册一个 替换规则

字母顺序版本控制的麻烦 #

当 2017 年 5 月发布 Guava 的 22.0 版本时,Guava 已经从 Java 6 迁移到 Java 8。然而,Android 仍然停留在 Java 6。如果不进行更改,Android 用户将永远停留在旧版本上。因此,Guava 开始发布一个单独的 Android 变体,其中剥离了所有 Java 8 特有的功能。

这两个变体使用相同的 com.google.guava:guava 坐标发布,但版本字符串不同:22.022.0-android。在 GitHub 上进行了较长时间的讨论 GitHub 和公开的 GoogleDoc 后,版本控制模式在 23.1 版本中更改为 23.1-jre23.1-android。没有为不同的变体使用不同的分类器或坐标,而是使用不同的版本,这允许 Gradle 和 Maven 中的依赖冲突解决检测到冲突并仅选择两个变体之一。(引入 -jre 后缀是为了确保 -jre 版本始终被 Maven 认为高于 -android 版本,因为在字母顺序中 j 胜过 a。)

J(不总是)胜过 A #

虽然 -jre 后缀的引入解决了 Maven 用户的一些问题,但如果涉及多个 Guava 版本,Gradle 和 Maven 用户仍然存在问题。

再次查看我们的 初始示例,实际版本变体都编码在版本字符串中:28.0-jre28.1-android。构建工具无法知道如何使用此信息。Gradle 查看完整的版本字符串,选择更高的版本:28.1-android。这是一个没有 Java 8 特定类的版本,这很可能会破坏依赖于这些类的代码。最佳解决方案可能是选择 28.1-jre,因为它满足了两个请求:28.1(假定与 28.0 兼容)和 jre(与 android 兼容)。但是,无法使用 POM 元数据对独立请求版本和变体进行建模。

烦人的注解库 #

Guava 意识到其广泛使用,其代码主要是自包含的,避免了额外的依赖项。尽管如此,随着时间的推移,Guava 还是添加了一些对注解库的依赖项,例如 com.google.code.findbugs:jsr305com.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 时始终使用空的 9999.0-empty-to-avoid-conflict-with-guava 版本的 ListenableFuture 模块。然后不会选择包含 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 能力,如果 Google Collections 和 Guava 都是依赖关系图的一部分,那么 Gradle 将检测到冲突。
  • 发布更多变体 使用 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 相关的未检测到和未解决的冲突问题。

为了在未来改善这种情况,我们创建了一个 拉取请求,建议为 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 模块元数据进行的改进!

讨论