覆盖传递依赖项版本

Gradle 通过选择依赖项图中找到的最新版本来解决任何依赖项版本冲突。某些项目可能需要偏离默认行为,并强制使用较早版本的依赖项,例如,如果项目的源代码依赖于比某些外部库更旧的依赖项 API。

强制使用依赖项版本需要有意识的决定。如果外部库在没有它们的情况下无法正常运行,则更改传递依赖项的版本可能会导致运行时错误。考虑将源代码升级为使用较新版本的库作为替代方法。

一般来说,强制依赖项是为了降级依赖项。降级可能会有不同的用例

  • 在最新版本中发现了错误

  • 你的代码依赖于较低版本,该版本不兼容二进制文件

  • 你的代码不依赖于需要更高版本的依赖项的代码路径

在所有情况下,最好表示你的代码严格依赖于传递版本的版本。使用严格版本,即使传递依赖项另有说明,你实际上也将依赖于你声明的版本。

严格依赖项在某种程度上类似于 Maven 的最近优先策略,但有一些细微差别

  • 严格依赖项不会遇到排序问题:它们会传递应用于子图,并且声明依赖项的顺序无关紧要。

  • 相互冲突的严格依赖关系将触发您必须解决的构建失败

  • 严格依赖关系可与丰富的版本一起使用,这意味着最好用严格范围加上单个首选版本来表示需求

假设某个项目使用HttpClient 库来执行 HTTP 调用。HttpClient 会将Commons Codec作为传递依赖关系引入,版本为 1.10。但是,该项目的生产源代码需要 Commons Codec 1.9 中的一个 API,而 1.10 中不再提供该 API。可以通过在构建脚本中将其声明为严格依赖关系来强制执行依赖关系版本

build.gradle.kts
dependencies {
    implementation("org.apache.httpcomponents:httpclient:4.5.4")
    implementation("commons-codec:commons-codec") {
        version {
            strictly("1.9")
        }
    }
}
build.gradle
dependencies {
    implementation 'org.apache.httpcomponents:httpclient:4.5.4'
    implementation('commons-codec:commons-codec') {
        version {
            strictly '1.9'
        }
    }
}

使用严格版本的含义

必须仔细考虑使用严格版本,尤其是库作者。作为生产者,严格版本实际上将表现得像一个强制:版本声明优先于传递依赖关系图中找到的任何内容。特别是,严格版本将覆盖传递中发现的同一模块上的任何其他严格版本

但是,对于使用者来说,严格版本在图解析期间仍然被视为全局版本,并且可能会触发错误,如果使用者不同意的话。

例如,假设您的项目B严格依赖于C:1.0。现在,使用者A同时依赖于BC:1.1

那么这将触发一个解析错误,因为A表示它需要C:1.1,但B在其子图中严格需要1.0。这意味着如果您在严格约束中选择单个版本,那么该版本不能再升级,除非使用者也在同一模块上设置了严格版本约束。

在上面的示例中,A必须表示它严格依赖于 1.1

因此,一个好习惯是,如果您使用严格版本,您应该用范围和该范围内的首选版本来表示它们。例如,B可能会说,它严格依赖于[1.0, 2.0[范围,但首选1.0,而不是严格依赖于 1.0。然后,如果使用者选择 1.1(或范围内的任何其他版本),构建将不再失败(约束已解析)。

强制依赖关系与严格依赖关系

如果项目需要在配置级别强制使用依赖项的特定版本,则可以通过调用方法 ResolutionStrategy.force(java.lang.Object[]) 来实现。

build.gradle.kts
configurations {
    "compileClasspath" {
        resolutionStrategy.force("commons-codec:commons-codec:1.9")
    }
}

dependencies {
    implementation("org.apache.httpcomponents:httpclient:4.5.4")
}
build.gradle
configurations {
    compileClasspath {
        resolutionStrategy.force 'commons-codec:commons-codec:1.9'
    }
}

dependencies {
    implementation 'org.apache.httpcomponents:httpclient:4.5.4'
}

排除传递依赖项

虽然上一部分展示了如何强制使用传递依赖项的特定版本,但本部分介绍了使用 excludes 作为完全移除传递依赖项的方法。

与强制使用依赖项版本类似,完全排除依赖项需要经过深思熟虑。如果外部库在没有传递依赖项的情况下无法正常运行,则排除传递依赖项可能会导致运行时错误。如果你使用 excludes,请确保通过足够的测试覆盖率来确保不使用需要被排除依赖项的任何代码路径。

传递依赖项可以在声明依赖项的级别上被排除。排除项通过属性 group 和/或 module 以键/值对的形式写出,如下面的示例所示。有关更多信息,请参阅 ModuleDependency.exclude(java.util.Map)

build.gradle.kts
dependencies {
    implementation("commons-beanutils:commons-beanutils:1.9.4") {
        exclude(group = "commons-collections", module = "commons-collections")
    }
}
build.gradle
dependencies {
    implementation('commons-beanutils:commons-beanutils:1.9.4') {
        exclude group: 'commons-collections', module: 'commons-collections'
    }
}

在此示例中,我们为 commons-beanutils 添加了一个依赖项,但排除了传递依赖项 commons-collections。在下面显示的代码中,我们只使用了 beanutils 库中的一个方法 PropertyUtils.setSimpleProperty()。通过测试覆盖率验证,对现有 setter 使用此方法不需要 commons-collections 的任何功能。

src/main/java/Main.java
import org.apache.commons.beanutils.PropertyUtils;

public class Main {
    public static void main(String[] args) throws Exception {
        Object person = new Person();
        PropertyUtils.setSimpleProperty(person, "name", "Bart Simpson");
        PropertyUtils.setSimpleProperty(person, "age", 38);
    }
}

实际上,我们表示我们只使用库的子集,该子集不需要 commons-collection 库。这可以看作是隐式定义了一个 commons-beanutils 本身尚未明确声明的 功能变体。但是,这样做会增加未经测试的代码路径中断的风险。

例如,这里我们使用 setSimpleProperty() 方法来修改 Person 类中由 setter 定义的属性,这工作得很好。如果我们尝试设置类中不存在的属性,我们应该会收到一个错误,例如 Unknown property on class Person。但是,由于错误处理路径使用 commons-collections 中的一个类,因此我们现在收到的错误是 NoClassDefFoundError: org/apache/commons/collections/FastHashMap。因此,如果我们的代码更具动态性,并且我们忘记充分覆盖错误情况,则我们库的使用者可能会遇到意外错误。

这只是一个示例,用于说明潜在的缺陷。在实践中,更大的库或框架可以引入大量的依赖项。如果这些库未能分别声明功能并且只能以“全有或全无”的方式使用,则排除项可以成为将库缩减到实际所需功能集的有效方法。

另一方面,Gradle 的排除处理与 Maven 相反,它会考虑整个依赖关系图。因此,如果对一个库有多个依赖关系,只有当所有依赖关系都同意时,才会执行排除。例如,如果我们将 opencsv 作为另一个依赖关系添加到我们上面的项目中,它也依赖于 commons-beanutils,则 commons-collection 将不再被排除,因为 opencsv 本身排除它。

build.gradle.kts
dependencies {
    implementation("commons-beanutils:commons-beanutils:1.9.4") {
        exclude(group = "commons-collections", module = "commons-collections")
    }
    implementation("com.opencsv:opencsv:4.6") // depends on 'commons-beanutils' without exclude and brings back 'commons-collections'
}
build.gradle
dependencies {
    implementation('commons-beanutils:commons-beanutils:1.9.4') {
        exclude group: 'commons-collections', module: 'commons-collections'
    }
    implementation 'com.opencsv:opencsv:4.6' // depends on 'commons-beanutils' without exclude and brings back 'commons-collections'
}

如果我们仍然希望排除 commons-collections,因为我们对 commons-beanutilsopencsv 的组合使用不需要它,我们需要从 opencsv 的传递依赖关系中排除它。

build.gradle.kts
dependencies {
    implementation("commons-beanutils:commons-beanutils:1.9.4") {
        exclude(group = "commons-collections", module = "commons-collections")
    }
    implementation("com.opencsv:opencsv:4.6") {
        exclude(group = "commons-collections", module = "commons-collections")
    }
}
build.gradle
dependencies {
    implementation('commons-beanutils:commons-beanutils:1.9.4') {
        exclude group: 'commons-collections', module: 'commons-collections'
    }
    implementation('com.opencsv:opencsv:4.6') {
        exclude group: 'commons-collections', module: 'commons-collections'
    }
}

从历史上看,排除也被用作修复某些依赖关系管理系统不支持的其他问题的创可贴。然而,Gradle 提供了多种功能,这些功能可能更适合解决某些用例。你可以考虑查看以下功能

  • 更新降级依赖关系版本:如果依赖关系的版本发生冲突,通常最好通过依赖关系约束调整版本,而不是尝试排除具有不希望的版本的依赖关系。

  • 组件元数据规则:如果库的元数据明显错误,例如,如果它包含一个在编译时永远不需要的编译时依赖关系,一种可能的解决方案是在组件元数据规则中删除依赖关系。通过此操作,你告诉 Gradle 两个模块之间的依赖关系永远不需要——即元数据错误——因此永远不应该考虑。如果你正在开发一个库,你必须意识到此信息未发布,因此有时排除可能是更好的选择。

  • 解决相互排斥的依赖关系冲突:你经常看到通过排除解决的另一个情况是,两个依赖关系不能一起使用,因为它们表示同一事物的两个实现(相同的功能)。一些流行的示例是冲突的日志记录 API 实现(如 log4jlog4j-over-slf4j)或在不同版本中具有不同坐标的模块(如 com.google.collectionsguava)。在这些情况下,如果 Gradle 不知道此信息,建议通过组件元数据规则添加缺少的功能信息,如声明组件功能部分所述。即使你正在开发一个库,并且你的使用者将不得不再次处理解决冲突的问题,通常正确的解决方案是将决定权留给库的最终使用者。即,你作为库作者不必决定你的使用者最终使用哪个日志记录实现。