更智能的依赖降级

目录

介绍

处理传递性依赖时,最大的挑战之一是控制它们的版本。流行的库会作为传递性依赖出现在依赖关系图中的多个位置。并且很可能在每个路径上版本信息都会有所不同。

通过多篇博客文章,您已经了解到 Gradle 提供了丰富的功能集来表达复杂的依赖需求。在这篇文章中,我们将讨论在降级依赖版本时语义为何重要。您还将了解到 Gradle 6 的严格版本特性,它提供了这种语义信息,并有效地为您提供了处理这个复杂问题的强大而精确的工具。

在这篇文章中,我们将再次使用 Google 的 Guava 作为示例,因为

  • 这是一个非常流行的库,被超过 20,000 个其他库使用
  • 它在 API 稳定性方面有着复杂的历史,这导致在大型依赖关系图中升级有时会很困难

在下面的依赖关系视图中,取自此构建扫描,我们可以看到许多 Guava 版本正在使用

Extract of build scan showing conflicting Guava versions

然而,此构建的依赖声明非常简单

dependencies {
    implementation("org.optaplanner:optaplanner-core:7.24.0.Final")
    implementation("com.spotify:folsom:1.5.0")
}

仅有两个直接依赖项,我们已经有四个 Guava 版本之间的冲突。在此示例中,它将始终解析为 25.0-jre。这是 Gradle 考虑依赖关系图中的所有版本并乐观地选择与约束匹配的最高版本(在本例中为 25.0-jre)的结果。

如果我们将这个 Gradle 项目与匹配的 Maven 项目进行比较,我们会得到一些不同的结果。对于 Maven,将使用到依赖项的最短路径中的第一个来确定版本。这意味着,在我们的示例中,Guava 版本实际上是依赖于顺序的,因为这两个库都直接依赖于 Guava。如果在 Maven POM 文件中首先声明 com.spotify:folsom:1.5.0,则 Guava 将解析为版本 24.1-jre。但是,如果 org.optaplanner:optaplanner-core:7.24.0.Final 首先出现,Guava 将解析为版本 25.0-jre

如果版本升级有问题怎么办? #

假设将 Guava 升级到 25.0-jre 对 Folsom 来说是一个问题,因为它依赖于 24.1-jre 中的 Files.fileTreeTraverser() API,该 API 在 25.0 中被移除

在 Gradle 6 之前,处理此问题最常用的解决方案是

  • org.optaplanner:optaplanner-core:7.24.0.Final 上对 Guava 添加 exclude
  • 在 Guava 上添加 resolutionStrategy.force 以强制使用版本 24.1-jre

不幸的是,这些解决方案都没有给出版本降级的明确理由。当排除依赖项时,不清楚您是意味着您对 org.optaplanner:optaplanner-core:7.24.0.Final 的使用不需要 Guava,还是您只是为了它对已解析版本产生的副作用而这样做。相反,如果您选择强制使用特定版本的依赖项,则您的库的使用者将无法获得该信息,这可能就像多项目构建中的不同项目一样简单,他们仍然会遇到您已解决的相同不兼容问题。

Maven 的情况非常相似

  • exclude 同样缺乏语义。
  • 对传递性依赖项使用 dependencyManagement(如 Gradle 的 force)不适用于您的库的使用者。

有意义的降级 #

在 Gradle 6 中,rich version 提供了增强的strict version声明。此版本声明具有以下语义

  1. strict version 有效地优先于声明严格版本的项目子图中所有其他依赖项版本。
  2. strict version 有效地拒绝所有不兼容的版本。

因此,在我们的示例中,我们将把该版本声明与依赖项约束结合使用,以选择 Guava 24.1-jre

dependencies {
    constraints {
        implementation("com.google.guava:guava") {
            version {
                strictly("24.1-jre")
            }
            because("Guava 25.0-jre removed APIs used by Folsom")
        }
    }
    implementation("org.optaplanner:optaplanner-core:7.24.0.Final")
    implementation("com.spotify:folsom:1.5.0")
}

为什么语义很重要? #

在我们举的例子中,简单地组合两个依赖项就导致了代码损坏。我们必须弄清楚哪种依赖项版本的组合在我们自己的库的上下文中有效。然后,我们通过添加带有严格版本的依赖项约束来记录我们的决定。如果我们正在构建的库被重用,那么重要的是,这个决定要为未来的使用者保留。

使用 Gradle 6 之前的解决方案,此信息会丢失。excludeforce 都没有向我们的使用者传递足够的信息,他们很可能会再次受到该问题的困扰。

Maven 解决方案具有相同的缺点,以及相同的潜在后果。

借助 Gradle 6 的 strict version 定义,您的使用者将了解您所做的选择。如果他们的任何其他依赖项导致 Guava 更新,构建将失败,表明您的库对 Guava 24.1-jre 有严格的要求。借助 because 子句提供的附加信息,这些开发人员将了解问题所在,并已经开始寻找自己的解决方案。他们可以尊重您的选择,也可以通过定义自己的 strict version 来否决它。

严格版本的最佳实践 #

由于其语义,添加严格版本时应牢记以下最佳实践

  • 对于可重用的软件库
    • 建议在可能的情况下,在版本的 strictly 部分中使用版本范围。这为库的使用者提供了更多自由,并使他们能够在不依赖于其他严格版本定义的情况下找到解决方案。
    • 提供一个 prefer 版本,以便在使用者不在意时可以使用。
  • 对于应用程序,strictly 部分中的固定版本是最简单、最直接的选择。
  • 在所有情况下,请务必使用 because 记录您的决定。

结论 #

库重用的一个后果是版本冲突是不可避免的,尤其是对于流行的库。有时,有必要为特定库组合做出显式的版本选择。Gradle 6 的 strict version 概念允许您做出此选择并为您的使用者保留它。

虽然 Maven 的解析机制乍听起来更简单,但我们表明,随着依赖关系图的增长,它存在语义问题。对您的库有意义的解决方案在被使用时会失去所有意义。

另一方面,Gradle 通过将语义与版本声明相关联,具有一致的解析模型,这使开发人员可以清楚地表达他们所做的选择并记录他们的理由。

当库被其他人使用时,这些选择仍然可用,这使使用者有机会更好地尊重或否决并记录库开发人员所做的选择。

讨论