10多年前,Google发布了一个新的Java集合库,这个库现在被称为Google Guava,在过去的几个月和几年中获得了很大的发展,可能是目前生产代码中使用最多的Java库。
由于Guava的广泛采用,今天许多其他库都依赖于它。 你很有可能在任何合理的大型Java项目的classpath上发现它,即使它没有被直接使用。 随着越来越多的代码依赖于这样一个广泛使用的库,发生冲突的可能性增加,增加了一个项目的依赖性地狱。
考虑一下下面这个看起来无害的依赖关系声明块。
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)变体,并且我们希望构建工具能够告知我们在classpath上是否有其他可疑的冲突。 让我们看一下显示依赖关系图的构建扫描。
如果我们仔细观察,我们可以观察到一些意想不到的事情:为什么guava:28.0-jre ,在没有警告的情况下被替换为guava:28.1-android ?为什么会有一个google-collections 的依赖关系--这不是和Guava一样吗?为什么我需要在我的运行时classpath上有j2objc-annotations ? 还有这个奇怪的9999.0-empty-to-avoid-conflict-with-guava 的依赖关系是什么?
为了理解这个问题,我们将讨论在Guava的发展过程中出现的依赖性管理挑战,以及如何处理这些挑战。 最后,我们将展示如何使用Gradle模块元数据来避免麻烦。
命名事物具有困难
依赖性管理的问题很早就开始了,它的前身是Google Collections Library。com.google.collections:google-collections:1.0,Google Collections Library的最终版本是在2009年发布到Maven中央仓库的。2010年,Guava的第一个稳定版本com.google.guava:guava:10.0 ,包括所有Google Collections和其他实用程序,取代了Google Collections Library。
由于将google-collections "重命名 "为guava ,Gradle和Maven的依赖管理引擎不再能够检测到Google Collections和Guava版本之间的冲突。这种未处理的冲突导致classpath上有两个包含不同版本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.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。
许多用户对这些依赖感到厌烦,因为它们也存在于运行时的classpath中。 注释库的依赖只是为了避免在Java编译器检查Guava类的注释时出现编译警告。 在运行时,注释不会被触及,因此不需要注释库的jars。 然而,在POM中定义的每个编译范围的依赖会自动存在于运行时的classpath中,不存在只为编译时声明依赖的概念。
重复的麻烦
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 版本。真正的版本1.0 ,包含重复的ListenableFuture 接口,然后不被选中。
虽然这似乎是个巧妙的方法,而且在许多使用Gradle的Android开发中也是如此,但也存在一些问题。 只使用Guava的JVM构建者抱怨说,即使没有冲突,他们的classpath上也总是有一个额外的空jar。 在Maven中,这种方法只在某些设置中有效,因为Maven不一定挑选最高版本,而是最接近的版本--即如果在依赖图中首先发现com.google.guava:listenablefuture:1.0 ,它将被挑选而不是空版本。
当你知道自己想要什么但却无法表达时
如果你现在认为Guava团队应该把所有这些麻烦事做得更好,那你就错了。 事实上,正如你在链接的讨论中所看到的,团队对所做的每个决定都非常关注。
造成这些麻烦的根本原因是POM元数据模型的表达能力不够强,无法传达所需的信息。 正如Guava和其他库在过去十年中所显示的那样,需要在元数据中表达更多的信息来解决许多常见的用例。 作为对这种需求的回答,我们开发了Gradle Module Metadata格式。
有了Gradle Module Metadata,这篇文章中描述的问题就可以得到解决。
- 重命名为GuavaGradle Module Metadata提供了能力的建模概念。有了它,一个模块可以表达它提供了另一个模块实现的东西的替代实现。
每个版本的Guava都可以声明它提供了com.google.collections:google-collections的能力,如果Google Collections和Guava都是依赖图的一部分,Gradle就会检测到这个冲突。 - 发布更多的变体通过Gradle模块元数据,每个模块都有任意多的变体。 每个变体可以指向不同的工件(jars),可以有不同的依赖关系。一个变体由一些属性来识别,包括在Java库的情况下,
org.gradle.jvm.version属性。
Guava可以在一个模块中发布不同Java版本的变体,JRE(Java 8)和Android(Java 6/7)。然后Gradle将根据使用的Java版本选择合适的变体。 - 只有编译时的依赖性用Gradle Module Metadata发布的模块明确地定义了运行时和*编译时(api)*的变体,其中每个变体都独立地定义了依赖性。
使用这种灵活的API和实现分离,Guava可以将注释库的依赖性添加到编译时的变体中,只防止它们泄露到运行时的classpath中。 - 将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"
}
如果我们从一开始就把这个插件添加到例子中,你可以注意到运行时的classpath减少,并选择了Java 8的变体Guava,尽管选择的是28.1-android ,但它提供了guava-28.1-jre.jar。
如果你自己正在开发库,或者知道有库面临类似的问题,请随时与我们联系。 我们很高兴通过发布Gradle模块元数据来探索改进的方法