原文:How Rust Solved Dependency Hell

每隔一段时间我就会参与一个关于依赖管理和版本的对话,通常是在工作中,其中会出现“依赖地狱”的主题。如果你对这个术语不熟悉,那么我建议你查一下。简要总结可能是:“处理应用程序依赖版本和依赖冲突所带来的挫败感”。带着这个,让我们先获得关于依赖解析的一些技术。

问题

在讨论包应该具有哪种依赖关系以及哪些依赖关系可能导致问题时,本主题通常会进入讨论。作为一个真实的例子,在 Widen Enterprises,我们有一个内部的,可重用的Java框架,它由几个软件包组成,为我们提供了创建许多内部服务的基础(如果你愿意的话,微服务)。这很好,但是如果你想创建一个依赖于框架中某些东西的可重用共享代码库呢?如果你尝试在应用程序中使用这样的库,最终可能会得到如下依赖关系图:

first

就像在这个例子中一样,每当你试图在服务中使用库时,你的服务和库很可能依赖于不同版本的框架,这就是“依赖地狱”的开始。

现在,在这一点上,一个好的开发平台将为你提供以下两种选择的组合:

  • 使构建失败并警告我们framework版本21.1.121.2.0相互冲突。
  • 使用语义版本控制允许包定义与其兼容的 一系列 版本。如果幸运的话,两个软件包都兼容的版本集是非空的,你最终可以在应用程序中自动使用其中一个版本。

这两个看起来都合理,对吧?如果两个软件包确实彼此不兼容,那么我们根本无法在不修改其中一个的情况下将它们一起使用。这是一个艰难的情况,但替代方案往往更糟糕。事实上,Java是不该学习的一个很好的例子:

  • 默认行为是允许将依赖项的多个版本添加到类路径(Java的定位类的方式)。当应用程序需要库中的类时,实际使用哪个版本?在实践中,类的加载顺序因环境而异,甚至以非确定的方式运行,因此你实际上不知道将使用哪一个。哎呀!
  • 我们在Widen使用的另一个选择是强制版本对齐。这类似于之前的第二个合理选择,在Java中,依赖关系无法表达兼容性范围,因此我们只选择较新的可能依赖项并祈祷它仍然有效。在前面显示的依赖关系图示例中,我们将强制app升级到framework 21.2.0

这看起来像是一个双输的情况,所以你可以想象,这对添加依赖项非常不利,并且使之成为一个事实上的策略,除了实际的应用程序之外什么都不允许依赖我们的核心框架。

Rust的解决方案

在进行这些讨论时,我会经常提到这是一个不适用于所有语言的问题,作为一个例子,Rust“解决”了这个问题。我常常拿Rust如何解决世界上所有的问题开玩笑,但在那里通常有一个真实的核心。因此,当我说Rust“解决”了这个问题以及它是如何工作的时候,让我们深入了解一下我的意思。

Rust的解决方案涉及相当多的动人的部分,但它基本上归结为挑战我们在此之前做出的核心假设:

最终应用程序中只应存在任何给定包的一个版本。

Rust挑战了这一点,以便重构问题,看看是否有一个在依赖地狱之外更好的解决方案。Rust平台主要有两个功能可以协同工作,为解决这些依赖问题提供基础,现在我们将分别研究并看看最终结果是怎样的。

Cargo和Crates

难题的第一部分当然是Cargo,Rust官方依赖管理器。Cargo类似于NPM或Maven之类的工具,并且有一些有趣的功能使它成为一个真正高质量的依赖管理器(这里我最喜欢的是Composer,一个非常精心设计的PHP依赖管理器)。Cargo负责下载项目依赖的Rust库,称为crates,并协调调用Rust编译器以获得最终结果。

请注意,crates是编译器中的第一类构造。这在以后很重要。

与NPM和Composer一样,Cargo允许你根据语义版本控制的兼容性规则指定项目兼容的一系列依赖项版本。这允许你描述与你的代码兼容(或可能)兼容的一个或多个版本。例如,我可能会添加

[dependencies]
log = "0.4.*"

Cargo.toml文件,表明我的代码适用于0.4系列中log包的任何补丁版本。也许在最终的应用程序中,我们得到了这个依赖树

second

因为在my-project中我声明了与log版本0.4.*的兼容性,我们可以安全地为log选择版本0.4.4,因为它满足所有要求。(如果log包遵循语义版本控制的原则,这个原则对于已发布的库而言并不总是如此,那么我们可以确信这个发布不包括任何会破坏我们代码的重大更改。)你可以在Cargo文档中找到一个更好地解释版本范围以及它们如何应用于Cargo。

太棒了,所以我们可以选择满足每个项目版本要求的最新版本,而不是选择避开遇到版本冲突或只是选择更新的版本并祈祷。但是,如果我们遇到无法解决的问题,例如:

third

没有可以选择满足所有要求的log版本!我们接下来做什么?

名字修饰

为了回答这个问题,我们需要讨论名字修饰。一般来说,名字修饰是一些编译器用于各种语言的过程,它将符号名称作为输入,并生成一个更简单的字符串作为输出,可用于在链接时消除类似命名符号的歧义。例如,Rust允许你在不同模块之间重用标识符:

mod en {
fn greet() {
println!("Hello");
}
}

mod es {
fn greet() {
println!("Hola");
}
}

这里我们有两个不同的函数,名为greet(),但当然这很好,因为它们在不同的模块中。这很方便,但通常应用程序二进制格式没有模块的概念;相反,所有符号都存在于单个全局命名空间中,非常类似于C中的名称。由于greet()在最终二进制文件中不能显示两次,因此编译器可能使用比源代码更明确的名称。例如:

  • en::greet()成为en__greet
  • es::greet()成为es__greet

问题解决了!只要我们确保这个名字修饰方案是确定性的并且在编译期间到处使用,代码就会知道如何获得正确的函数。

现在这不是一个完全完整的名字修饰方案,因为我们还没有考虑很多其他的东西,比如泛型类型参数,重载等等。此功能也不是Rust独有的,并且确实在C++和Fortran等语言中使用了很长时间。

名字修饰如何帮助Rust解决依赖地狱?这一切都在Rust的名字管理体系中,这似乎在我所研究的语言中相当独特。那么让我们来看看?

在Rust编译器中查找名字修饰的代码很简单;它位于一个名为symbol_names.rs的文件中。如果你想学习更多内容,我建议你阅读这个文件中的注释,但我会包括重点。似乎有四个基本组件包含在一个修饰符号名称中:

  • 符号的完全限定名称。
  • 通用类型参数。
  • 包含符号的crate的名称。(还记得crates在编译器中是一流的吗?)
  • 可以通过命令行传入的任意“歧义消除器(disambiguator)”字符串。

使用Cargo时,Cargo本身会将“歧义消除器”提供给编译器,所以让我们看一下compilation_files.rs包含的内容:

  • 包名字
  • 包源
  • 包版本
  • 启用编译时功能
  • 一堆其他的东西

这个复杂系统的最终结果是,即使是不同版本的crate中的相同功能也具有不同的修饰符号名称,因此只要每个组件知道要调用的函数版本,就可以在单个应用程序中共存。

合在一起

现在回到我们之前的“无法解决的”依赖图:

forth

借助依赖范围的强大功能,以及Cargo和Rust编译器协同工作,我们现在可以通过在我们的应用程序中包含log 0.5.0log 0.4.4来实际解决此依赖关系图。app内部使用log的任何代码都将被编译以达到从0.5.0版生成的符号,而my-project中的代码将使用为0.4.4版生成的符号。

现在我们看到了大局,这实际上看起来非常直观,并解决了一大堆依赖问题,这些问题会困扰其他语言的用户。这个解决方案并不完美:

  • 由于不同版本生成不同的唯一标识符,因此我们无法在库的不同版本之间传递对象。例如,我们无法创建一个log 0.5.0LogLevel并将其传递给my-project使用,因为它期望LogLevel来自log 0.4.4,并且它们必须被视为单独的类型。
  • 对于库的每个实例,任何静态变量或全局状态都将被复制,如果没有一些特殊方法,它们就无法通信。
  • 我们的二进制大小必然会因为我们应用程序中包含的库的每个实例而增加。

由于这些缺点,Cargo仅在需要时才采用这种技术来解决依赖图。

为了解决一般用例,这些似乎值得为Rust做出权衡,但对于其他语言,采用这样的东西可能会更加困难。以Java为例,Java严重依赖于静态字段和全局状态,因此简单地大规模采用Rust的方法肯定会增加破坏代码的次数,而Rust则将全局状态限制在最低限度。这种设计也没有对在运行时或反射时加载任意库进行说明,这两者都是许多其他语言提供的流行功能。

结论

Rust在编译和打包方面的精心设计以(主要)无痛依赖管理的形式带来红利,这通常消除了可能成为开发人员在其他语言中最糟糕的噩梦的整类问题。当我第一次开始玩Rust的时候,我当然很喜欢我所看到的,深入了解内部,看到宏大的架构,周到的设计,以及合理的权衡取舍对我来说更令人印象深刻。这只是其中的一个例子。

即使你没有使用Rust,希望这会让你对依赖管理器,编译器以及他们必须解决的棘手问题给予新的重视。(虽然我鼓励你至少尝试一下Rust,当然…)

GitHub repo: qiwihui/blog

Follow me: @qiwihui

Site: QIWIHUI