通过与你已知的事物进行比较来学习。我最近因为假设 Rust 在传递性依赖版本解析方面与 Java 工作方式相同而吃了亏。在这篇文章中,我想比较这两者。
在深入探讨每个技术栈的细节之前,让我们先描述一下这个领域及其带来的问题。
\ 当开发任何超过 Hello World 级别的项目时,你很可能会面临他人曾经遇到过的问题。如果这个问题很普遍,很可能有人足够友善和具有公民意识,已经将解决该问题的代码打包,供他人重用。现在,你可以使用这个包并专注于解决你的核心问题。这就是当今行业构建大多数项目的方式,即使它带来了其他问题:你站在巨人的肩膀上。
\ 编程语言都配有可以将这些包添加到你项目中的构建工具。大多数工具将你添加到项目中的包称为依赖项。而项目的依赖项也可以有自己的依赖项:后者被称为传递性依赖项。

在上图中,C 和 D 是传递性依赖项。
\ 传递性依赖项本身存在问题。最大的问题是当一个传递性依赖项从不同路径被需要,但版本不同时。在下图中,A 和 B 都依赖于 C,但依赖于不同版本的 C。

构建工具应该在你的项目中包含哪个版本的 C?Java 和 Rust 有不同的答案。让我们依次描述它们。
提醒:Java 代码编译为字节码,然后在运行时被解释(有时也会编译为本地代码,但这超出了我们当前的问题范围)。我将首先描述运行时依赖解析和构建时依赖解析。
\ 在运行时,Java 虚拟机提供了类路径的概念。当需要加载一个类时,运行时会按顺序搜索配置的类路径。想象以下类:
public static Main { public static void main(String[] args) { Class.forName("ch.frankel.Dep"); } }
\ 让我们编译并执行它:
java -cp ./foo.jar:./bar.jar Main
\ 上述命令将首先在 foo.jar 中查找 ch.frankel.Dep 类。如果找到,它会停止并加载该类,无论该类是否也存在于 bar.jar 中;如果没有找到,它会进一步在 bar.jar 类中查找。如果仍未找到,它会失败并抛出 ClassNotFoundException。
\ Java 的运行时依赖解析机制是有序的,并且具有按类的粒度。无论你是运行 Java 类并在命令行上定义类路径(如上所示),还是运行在其清单中定义类路径的 JAR,都适用。
\ 让我们将上述代码更改为以下内容:
public static Main { public static void main(String[] args) { var dep = new ch.frankel.Dep(); } }
\ 因为新代码直接引用 Dep,新代码需要在编译时进行类解析。类路径解析的工作方式相同:
javac -cp ./foo.jar:./bar.jar Main
\ 编译器在 foo.jar 中查找 Dep,如果未找到,则在 bar.jar 中查找。以上是你在 Java 学习之旅开始时学到的内容。
\ 之后,你的工作单元是 Java 归档文件(称为 JAR),而不是类。JAR 是一个增强的 ZIP 归档文件,带有指定其版本的内部清单。
\ 现在,假设你是 foo.jar 的用户。foo.jar 的开发者在编译时设置了特定的类路径,可能包括其他 JAR。你需要这些信息来运行自己的命令。库开发者如何将这些知识传递给下游用户?
\ 社区提出了一些想法来回答这个问题:第一个被采纳的回应是 Maven。Maven 有项目对象模型的概念,你可以在其中设置项目的元数据以及依赖项。Maven 可以轻松解析传递性依赖项,因为它们也发布了自己的 POM,包含自己的依赖项。因此,Maven 可以追踪每个依赖项的依赖项,直到叶子依赖项。
\ 现在,回到问题陈述:Maven 如何解决版本冲突?Maven 将为 C 解析哪个依赖版本,1.0 还是 2.0?
\ 文档很清楚:最近的那个。

在上图中,到 v1 的路径距离为二,一个到 B,然后一个到 C;同时,到 v2 的路径距离为三,一个到 A,然后一个到 D,最后一个到 C。因此,最短路径指向 v1。
\ 然而,在初始图中,两个 C 版本与根工件的距离相同。文档没有提供答案。如果你对此感兴趣,它取决于 A 和 B 在 POM 中的声明顺序!总之,Maven 返回重复依赖项的单一版本,以将其包含在编译类路径中。
\ 如果 A 可以与 C v2.0 一起工作,或者 B 可以与 C 1.0 一起工作,那太好了!如果不行,你可能需要升级 A 的版本或降级 B 的版本,以便解析的 C 版本可以与两者兼容。这是一个痛苦的手动过程——问问我怎么知道的。更糟糕的是,你可能会发现没有一个 C 版本可以同时与 A 和 B 兼容。是时候替换 A 或 B 了。
Rust 在几个方面与 Java 不同,但我认为以下几点对于我们的讨论最为相关:
\ 让我们逐一检查它们。
\ Java 编译为字节码,然后你运行后者。你需要在编译时和运行时都设置类路径。使用特定类路径编译并使用不同的类路径运行可能导致错误。例如,想象你使用你依赖的类进行编译,但该类在运行时不存在。或者,它存在,但版本不兼容。
\ 与这种模块化方法相反,Rust 将 crate 的代码和每个依赖项编译为一个独特的本地包。此外,Rust 也提供了自己的构建工具,从而避免了记住不同工具怪癖的需要。我提到了 Maven,但其他构建工具在上述用例中可能有不同的版本解析规则。
\ 最后,Java 从二进制文件解析依赖项:JAR。相反,Rust 从源代码解析依赖项。在构建时,Cargo 解析整个依赖树,下载所有需要的源代码,并按正确的顺序编译它们。
\ 考虑到这一点,Rust 如何解决初始问题中的 C 依赖版本?如果你来自 Java 背景,答案可能看起来很奇怪,但Rust 两者都包括。实际上,在上图中,Rust 将使用 C v1.0 编译 A,并使用 C v2.0 编译 B。问题解决了。
JVM 语言,特别是 Java,提供了编译时类路径和运行时类路径。它允许模块化和可重用性,但也为类路径解析问题打开了大门。另一方面,Rust 将你的 crate 构建为一个单一的自包含二进制文件,无论是库还是可执行文件。
\ 进一步了解:
最初发布于 A Java Geek,2025 年 9 月 14 日


