软件依赖管理发展历程

16.9K
0
0
最后修改于

开始之前#

最近,基于 npm 的软件供应链攻击又成了技术圈的一小波热点新闻。先是 npm 攻击尝试窃取加密货币,再又是沙虫病毒,一波接一波。又回想到前两年有人在 npm 上发布武林外传视频;faker.js、colors.js 作者因为 “贫困” 删库跑路;再往前的 log4j 漏洞等等,供应链安全已然成为软件开发生命周期中一个显著的问题。不过,本文并非要在笔者了解不多的安全领域夸夸其谈。只是因为这些新闻、旧闻让笔者意识到对对软件编程领域的 toolchain 并没有完整的、成体系的认识,只有着关联于特定工具、由此而来的一些隐性经验。

本系列文章的目的在于构建对 coding toolchain 的体系化认识。其中本文的目的在于回顾软件依赖管理的发展历程。

依赖管理的自动化历程#

依赖管理是软件开发中老生常谈的话题了,甚至感觉老得有些没必要再提起了。因为开发者们早已习惯了使用 pnpm install, go get, mvn install 这样轻松的命令。这些依赖管理器非常方便,为日常开发解决了太多问题。以至于离开了它们再去写代码,似乎就不知道要从哪里动手了。

但它们也不总是令人满意,因为它们有时候也会罢工,这不常见,但总是有可能出现的,而代价也谈不上多大:只是花上一段时间去深入了解管理的机制,然后去解决一个可能大半年才能遇到一次的问题。

但这个过程往往是痛苦的。一方面,这是一种非常少见的场景,所以自然不熟悉,不熟悉就容易踩坑,而且是非常简单的坑。另外一方面,依赖管理工具自身也在演进,上一次解决的流程可能并不完全适用于新一次的问题,似乎总有新坑要踩。

在不同的依赖管理器下经历了几次这样的过程后,我想是值得为我自己写一份总结的,梳理梳理它们的发展历程。

按照经验,我们大致可以将与依赖打交道的过程划分为几个部分:

  1. 依赖的使用
  2. 依赖的安装
  3. 依赖的分发
  4. 依赖的发现
  5. 依赖的获取

本文会对这其中的每个部分进行回顾,展示依赖管理工具是如何处理各个部分的子问题,然后将这些过程自动化的。

你会看到:

  • 早期软件开发在没有自动依赖管理机制下,编程者们如何手动进行依赖管理。
  • 手动依赖管理是如何一步步发展到如今的自动化依赖管理。
  • 后发编程语言在依赖管理上踩的坑。
  • 与依赖管理有关的其他问题
    你不会看到:
  • 软件版本号规范(如 SemVer)的深度讨论
  • 编程语言的包与模块化问题
  • 如何动手实现一个依赖管理
  • 依赖管理器如何跟构建工具深度集成

依赖的使用:编译、链接与运行时加载#

本节主要描述程序编译的静态链接和动态链接机制。对于已有足够了解的读者,可跳过本节😌。

在正式开始讨论依赖管理之前,我们还是先回顾一下,程序是如何从代码转变为最终可运行的二进制文件的。这与依赖的使用有关。

编译、链接#

以 C 语言的经典 HelloWorld 作为一个案例。

#include <stdio.h>

int main() {
    printf("Hello, World!\n");
    return 0;
}

我们不妨深入一下,在编译和运行这段代码时,底层到底发生了什么。我们在 main.c 中敲入代码并保存之后,我们先对其进行编译(只编译,不链接)。

# `-E`表示只进行预编译,不进行后续步骤,`--verbose`表示输出详细信息
gcc --verbose -E main.c -o main.i

输出结果会是一长串的日志和一个 main.o 的文件。
日志大概会有类似于下面的内容:

GGC heuristics: --param ggc-min-expand=100 --param ggc-min-heapsize=131072
ignoring nonexistent directory "/usr/local/include/x86_64-linux-gnu"
#include "..." search starts here:
 /usr/lib/gcc/x86_64-linux-gnu/12/include
 /usr/local/include
 /usr/include/x86_64-linux-gnu
 /usr/include
End of search list.

这段内容表明,在进行编译时gcc会在一组预定义的、标准的 “头文件搜索路径” 中查找对应的文件。这些路径通常包括 /usr/include/usr/local/include,然后将 stdio.h 文件中的函数、变量声明复制到源码中,以便在编译过程中使用。这个过程称为预编译。

随后进行下一步汇编,汇编将上一步经过预编译得到的源码进行汇编,得到汇编代码,这一步不依赖于任何外部库或文件,仅仅使用汇编器即可完成汇编。

# `-S`表示只进行编译,不进行后续的汇编、链接,`--verbose`表示输出详细信息
gcc --verbose -S main.i -o main.s

汇编进一步得到更为底层的汇编代码。

# `-C`表示只进行编译,不进行后续的链接,`--verbose`表示输出详细信息
gcc --verbose -c main.s -o main.o

这个过程 gcc 会调用 as -v --64 -o main.o main.s,将汇编代码进一步转为机器码(目标代码)。

gcc --verbose main.o -o main.elf

在链接这一步,可以看到类似的日志:

COMPILER_PATH=...
LIBRARY_PATH=...
/usr/lib/gcc/x86_64-linux-gnu/12/collect2
...
-dynamic-linker /lib64/ld-linux-x86-64.so.2
-o main.elf
-L/usr/lib/gcc/x86_64-linux-gnu/12
...
/usr/lib/gcc/x86_64-linux-gnu/12/../../../x86_64-linux-gnu/crtn.o
main.o

从日志中可以发现,gcc 为我们配置了一些默认的搜索目录 LIBRARY_PATH,这些目录是全局共享的。这是由 gcc 调用 collect2 完成的,collect2 底层调用的还是 ld 链接器,因此我们不妨直接使用 ld 进行链接。

ld --trace --verbose ...
/usr/lib/gcc/x86_64-linux-gnu/12/../../../x86_64-linux-gnu/crtn.o
...
main.o

可以得到十分详细的日志:

ld: mode elf_x86_64
attempt to open /usr/lib/gcc/x86_64-linux-gnu/12/../../../x86_64-linux-gnu/Scrt1.o succeeded
main.o
attempt to open /usr/lib/gcc/x86_64-linux-gnu/12/libgcc.so failed
attempt to open /usr/lib/gcc/x86_64-linux-gnu/12/libgcc.a succeeded
...
/usr/lib/gcc/x86_64-linux-gnu/12/../../../x86_64-linux-gnu/crtn.o
ld-linux-x86-64.so.2 needed by /lib/x86_64-linux-gnu/libc.so.6
found ld-linux-x86-64.so.2 at /lib64/ld-linux-x86-64.so.2

链接器 ld 会在 /usr/lib 等预设的 LIBRARY_PATH 中用枚举的方式去搜索依赖,然后将它们与目标文件 main.o 链接起来,最终生成可执行文件。

而链接方式又可以划分为动态链接和静态链接。动态链接告诉ld,不需要将其他依赖的具体机器码写入产出文件,而是给一个索引。在运行时,可以由另外的动态链接器动态获取。静态链接则是告诉ld,将其他依赖的具体机器码全部写入产出文件。

加载#

对于静态链接,在程序启动时,操作系统将程序运行需要的内容一次性全部加载进内存执行,几乎没有外部依赖。

而对于动态链接,在编译期完成动态链接之后,在程序要运行时,操作系统会创建一个进程,并加载可执行文件到内存中。加载器会读取可执行文件的头部信息,了解程序的布局和所需的动态库,然后将这些库加载到内存中。

加载器会根据可执行文件中的动态库路径信息,去系统的标准库路径(如 /lib/usr/lib)中查找并加载这些库。如果找不到某个库,加载器会报错,程序无法启动。

加载器将动态库加载到内存后,运行时链接器(dynamic linker)会负责将程序中的符号引用解析到这些库中。它会根据可执行文件中的符号表,找到每个符号对应的库函数地址,并更新程序的内存映射。

最终,程序的 main 函数被调用,程序开始执行。

load elf

在 C 语言中待加载依赖的直接体现为库文件。但是在其他编程语言中,大多屏蔽了底层的编译、链接、加载的细节,依赖的概念可能体现在模块、包等更高层次的抽象中。但是不管怎样,编译、链接、加载的底层机制仍然是类似的。依赖的获取、安装、使用都需要基于对这些底层机制的理解和约定。

再以 Java 为例。编译之后的产物时 .class 的文件,打包成 .jar 文件的方式进行分发。JVM 通过 CLASSPATH 环境变量来指定类文件和 JAR 包的搜索路径。JVM 启动后,由解释器执行字节码,在需要时会利用 ClassLoader 加载类文件和 JAR 包。它会根据 CLASSPATH 中的路径,去文件系统中查找并加载这些文件,如果找不到某个类,JVM 会抛出 ClassNotFoundException 异常。加载器将类文件加载到内存后,JVM 会解析类中的符号引用,将其映射到内存中的类对象,随后继续执行。

而这其中查找和加载 .class 文件,也需要基于约定去查找。

alt text

而对于 Python 来说,当 Python 解释器启动时,它会初始化一个模块搜索路径列表,存储在 sys.path 变量中。这个列表包含了当前目录、标准库目录以及安装的第三方包目录等。当解释器遇到 import 语句时,它会按照 sys.path 列表的顺序,依次搜索每个目录,查找所需的模块文件(.py、.pyc 或 .so 文件)。如果找到匹配的模块文件,解释器会将其加载到内存中,并执行模块中的代码。如果在所有搜索路径中都找不到所需的模块,解释器会抛出 ModuleNotFoundError 异常,提示模块不存在。

关于依赖的约定#

可以发现。无论是 C/C++ 这种与平台有关的静态链接/动态链接,还是 JVM .class 这种更高级的抽象,抑或是 python 更为动态的 module。从源代码转变为最终的可执行文件,很大程度上依赖于一些特定的 “约定”。比如搜索路径,启动方式等。甚至这种约定会从编译时一直延续到运行时。

这是进行依赖管理的一大前提,也是进行自动依赖管理的最基本的要求:在构建时,有人能够帮编译器找到所需的依赖,在执行时,有人能帮运行时找到所需要的依赖

也就是要进行自动依赖管理就必须将依赖放在一个合适的地方。可以是放在一个与编译器/解释器约定好的位置,也可以是直接告诉编译器/解释器所有的依赖的具体位置。

原始时代的依赖安装#

即使是有了系统级依赖的约定之后,古早程序员的日子仍然很苦逼。因为还没解决应用层的问题。在没有语法高亮的日子里,程序员只能小心翼翼维护 Makefile,稍不小心一个 typo 就是熟悉的 No such file or directory

想象一下,一个 80 年代的开发者 Bob。他业余花费很长时间构建了一个小程序。要新增一个功能,但要用到一个复杂的函数库,系统中又没有。这个功能很复杂。他有两个选择。

  1. 自己从头开始手搓一个,他大致估算了一下,这可能要耗费一个月的空闲时间。
  2. 复用别人已经造好的轮子,可能得花上一段时间去寻找,还得自行处理兼容性问题。

它很明智的选择了第二个方案,很快便开始了查找过程。他翻阅了些计算机技术杂志,试图从软件工具 / 库评测的板块找到所需的包,一无所获。随后又花时间翻了翻 usenet 上的 computer 相关的新闻组,也没有找到多少相关线索。第二天他去到公司,跟同事聊起来,通过同事得知了某大学的 ftp 服务器上有一个高度符合要求的依赖包。

下载下来之后,他熟练的解压到 home/bob 目录下,然后仔细阅读了一遍解压出来的 README 文件,小心翼翼的按照里面的说明进行编译,安装,将必要的内容按照 FHS (Filesystem Hierarchy Standard) 的约定放入了/usr/local/lib,/usr/local/bin

但真正用起来之后,发现仍然缺失部分功能,又经过一顿搜寻,下载了一个新的依赖。下载之后却发现无法正常安装,因为与一个已有依赖冲突了,bob 不想再有更多麻烦了,他直接将源码放入项目中一个名为 vendor 的文件夹中,修改冲突部分的代码,然后再次修改 Makefile,将新增依赖比编入构建脚本中。

从上面的过程不难发现,这一个阶段,依赖的分发,发现,获取、安装、使用全部由程序员自己负责,手动执行。

自动化安装系统级依赖#

随着可复用软件库的快速增长,出现了大量的安装系统级依赖的需求。而手动进行这个过程的体验...,呃,想必各位体验过 “从源码安装” 的读者都深有体会。

如前文所述的那样,要使用一个依赖大致得做这些事情:

  1. 需要有人先将其发布到某个位置,如 ftp、usenet、某本书的附带光盘上,找到并获取它。
  2. 解压这个包,阅读其中的 README 或 INSTALL 文件,了解如何安装。
  3. 如果运气好,这个文件会讲清楚需要的依赖和安装步骤。按照说明进行安装,带有 configure 脚本,可以将系统环境需求配置一次性完成
  4. 相反,如果运气不好,这个依赖的 README 可能写得很糟糕,你只能按照经验,make && make install,然后冷不丁的在哪里报上一个因依赖不存在导致的No such file or directory,或者在使用时发现某个安装的依赖和另外一个依赖的版本不兼容。需要卸载其中一个或是每次使用时手动指明依赖库的路径。
  5. 重复上述步骤,直到所有的依赖都满足需求。

这种手动、易错、极其耗时的过程是当时开发者和系统管理员共同的噩梦。随着自由软件和开源软件运动的发展,尤其是 GNU 项目和早期 Linux 内核的出现,可复用的软件库呈爆炸式增长。一个复杂的软件可能依赖于数十个其他库,手动管理它们几乎成了一项不可能完成的任务。社区迫切需要一种更系统、更自动化的方式来处理这些系统级依赖。

1992 年,一名药剂师 G.W. Wettstein 发现了 Linux。为了满足工作需要,需要为他所在的罗杰・马里斯癌症中心部署 Linux,安装必要的专业软件。他意识到要让多台机器投入服务,就必须有一种可靠的方式来复制和更新 Linux。因此他编写了 StopAlop 的第一个版本( StopAlop 意为 “(Stop Alopocea)防脱发”(医学术语)或 “Stop A lot of problems”)。

StopAlop 让他们能够将 Linux 打包成一系列模块,这些模块可以独立地安装、验证和更新,并且具有版本控制。安装以一个包含了独立运行的 UUCP 功能 Unix 工作站所需内容的 “基础” 模块为中心。在此基础上,安装自行编写的程序、emacs、开发环境、文本处理(TeX),网络和 X11 等额外模块。(摘自 Linux in the Trenches)

有了 StopAlop 提供的灵感。Ian Murdock(1993 年发起了 Debian 项目) 于 1994 年 1 月创建了 dpkg,最初只是一个 Shell 脚本,它能做的仅仅是解压软件包并将其放置到正确的目录。这比纯手动操作要好,但远未达到 “管理” 的程度。Carl Streeter 用 Perl 重写了这个工具,并正式命名为 dpkg(Debian Package),为其加入了依赖关系信息和已安装文件的追踪功能。再后来,Ian Jackson 用 C 语言再次重写了 dpkg,使其更加高效和健壮,奠定了我们今天所熟知的版本。

The history of dpkg is really interesting | Hacker News

# 通过一行 shell 命令,即可将原来需要仔细配置、编译、安装的过程简化为一条命令
dpkg -i package.deb

dpkg architecture

dpkg 的诞生是一个里程碑。它解决了 Linux 系统本地依赖的安装和管理问题:

  • 标准化:它定义了 .deb 这种标准的软件包格式,包含了程序文件、元数据、依赖信息以及安装 / 卸载脚本。
  • 自动化安装:dpkg -i package.deb 一条命令,就替代了过去解压、configure、make、make install 的繁琐流程。
  • 状态追踪:dpkg 维护了一个本地数据库,记录了哪些包被安装了,每个包包含了哪些文件。这使得干净地卸载一个软件包(dpkg -r package)成为了可能,彻底告别了 make install 后文件散落系统各处、无法清理的窘境。

对于那时候的开发者来说,dpkg 的出现使得开发者与复杂依赖 battle 的时间减少了不少,开发者能花更多的时间在写自己的程序功能上。不过从现在的视角来看,dpkg 它只解决了依赖管理整个链条中的一环,也就是本地依赖的安装和管理,而且这一环解决得也并不完美。

  • 依赖的发现和获取:仍然需要开发者自己去 FTP 站点、邮件列表、新闻组或者其他地方找到需要的 .deb 包,然后手动下载。
  • 传递依赖的缺失:dpkg 最大的局限在于此。如果要安装 libA.deb,dpkg 会检查它的依赖。如果它发现 libA 依赖 libB,而系统里没有 libB,它会报错并停止安装。它不知道去哪里寻找 libB,更不会自动下载和安装它。如果 libB 又依赖 libC 和 libD,libC 又依赖…… 恭喜,你得可能得花上至少数个小时手动去解决这样的依赖地狱。
  • 菱形依赖导致的版本冲突:dpkg 也无法处理版本冲突的问题。如果 libA 需要 libB 1.0,而系统里已经安装了 libB 2.0,dpkg 会报错并停止安装。它无法同时安装多个版本的同一个包,也无法自动解决版本冲突。

CPAN:依赖的分发、发现、获取#

不过有了 dpkg 后,deb 软件包的数量迅速增长,依赖的发现、获取很快就成了规模问题,需要手动从镜像中获取软件包,然后用它安装。过了一段时间,出现了各种脚本,自动执行这项任务。仓库规模不断扩大,因此很快就需要一个像 dselect 这样的工具用来发现和获取软件包。

而在另外一个技术角落,在 dpkg 诞生的同一年,1994 年,Perl 作者 Larry Wall 为 Perl 引入了模块化,同时创建了 CPAN(Comprehensive Perl Archive Network)并于 1995 年公开发布。CPAN 是一个中心化的 Perl 模块仓库,他参考了 CTAN(Comprehensive Tex Archive Network)的设计理念。CPAN 的想法很简单。用户 A 构建一个软件包,上传到服务端,然后在服务端存储所有的 perl 包,提供客户端软件,用户 B 可以通过很简单的方式在这里查找 / 下载他们所要的包。

deps-registry

这就是如今依赖管理中离不开的 registry 模式。

通过引入一个中心化的包仓库,用户可以轻松地发现和获取所需的依赖包,而不需要先去苦苦翻阅杂志、新闻组或 FTP 站点。

CPAN 一次性解决了依赖的分发、发现、获取问题。程序员只需要知道包的名字,就可以通过 cpan Module::Name 来安装它,cpan 客户端会自动从 CPAN 服务器下载并安装这个模块。

CPAN 开创了软件包管理的中心化的包分发网络模式,并搭配镜像站点的来保证其可用性。

但究其本质,那似乎只是一个文件服务器而已,因此一些其他社区的语言,便有了搭便车的想法。其他编程语言不也可以打个包然后不也可以直接利用 CPAN 的分发网络吗?不过社区这样的要求被 CPAN 礼貌的拒绝了。原因很多,但是保持 CPAN 对于 Perl 的纯洁性是重要的。因为 CPAN 不仅仅是一个文件服务器,重要的是还有一个客户端,搭配着半自动化的下载包并使用 perl 包的方式。

The Zen of Comprehensive Archive Networks

CPAN components

一个现代化的依赖管理雏形出现了。软件包的发现,获取,分发,安装,使用 (需要与构建工具搭配,提前约定) 流程被标准化。可以说 CPAN 为程序开发打开了自动化依赖管理的大门。但是 CPAN 的局限性也很明显。dpkg 有的问题 CPAN 也有。

  • 传递依赖缺失:和 dpkg 一样,CPAN 也无法处理传递依赖的问题。如果要安装 ModuleA,而 ModuleA 依赖 ModuleB,ModuleB 又依赖 ModuleC,CPAN 不会自动下载和安装这些传递依赖。程序员仍然需要手动解决这些依赖关系。
  • 菱形依赖导致的版本冲突:CPAN 也无法处理版本冲突的问题。如果 ModuleA 需要 ModuleB 1.0,而系统里已经安装了 ModuleB 2.0,CPAN 会报错并停止安装。它无法同时安装多个版本的同一个包,也无法自动解决版本冲突。
  • 依赖的全局性:CPAN 安装的模块是全局的,所有的 Perl 程序共享同一套模块。这意味着如果两个程序需要不同版本的同一个模块,就会发生冲突。用户无法为每个项目单独管理依赖。

APT:传递依赖的自动处理#

时间来到 1997 年。dpkg 和 CPAN 都已经诞生了一段时间。它们各自解决了依赖管理中的一些问题,但都不够完善。社区也有一些其他对 dpkg 和 CPAN 方案的学习借鉴。以及对他们尚未解决问题的各种尝试。

在 那一年,Debian 社区启动了一个雄心勃勃的项目,用来解决自动传递依赖问题,最初的代号叫做 deity(神)。这个名字也许意味着 Debian 社区对其的期望:成为 “软件包管理之神”。

其目标极其远大:计划开发一个全新的 GUI,不仅要取代当时的 dpkg 前端 dselect,甚至要彻底替换掉底层的 dpkg 本身。这是一个试图从上到下重塑 Debian 软件包管理体系的宏伟计划。

然而,这个宏伟的愿景很快遇到了现实的挑战。开发一个能统一所有依赖管理功能的图形界面是一项异常艰巨的任务。1998 年 4 月 1 日,项目组宣布,项目名称将更改为 APT(Advanced Package Tool,高级软件包工具)。与此同时, deity 的宏大构想被大幅缩减。最终,那个被寄予厚望的 GUI 从未问世,替换 dpkg 的想法也被彻底放弃。

Re: apt, deity, dpkg, aptitude history | Debian maillist

apt-getapt-cache 成为了 APT 的核心组件。它们提供了一个命令行界面,允许用户轻松地搜索、安装、升级和删除软件包。更重要的是,APT 引入了自动处理传递依赖的功能。当用户请求安装一个软件包时,APT 会自动解析该包的依赖关系树,下载并安装所有必要的依赖包,极大地简化了用户的操作流程。

alt text

但是,这种解决方案很朴素,朴素到略显粗暴。

当遇到复杂的依赖冲突时(比如安装一个新包需要卸载另一个已安装的重要包),apt-get 的解决方案通常比较单一和强制。它可能会直接给出一个解决方案:“要安装 A,必须卸载 B、C 和 D”,而不会提供其他可能性。对于系统核心组件的冲突,这种处理方式有时会带来风险,甚至可能破坏系统。

当你使用 apt-get install 安装一个软件包时,它会同时安装所有必需的依赖包。但当你使用 apt-get remove 卸载这个软件包时,那些作为依赖被自动安装且不再被任何其他包需要的包(即 “孤儿包”)会默认保留在系统中。你需要手动运行 apt-get autoremove 来清理它们。这增加了用户的管理负担,也容易导致系统因残留不再需要的库文件而变得臃肿。

于是很快,1999 年,Debian 社区引入了 aptitude,这是一个更为智能和交互式的包管理工具。aptitude 不仅继承了 APT 的所有功能,还增加了对依赖冲突的更灵活处理方式。它会在遇到冲突时提供多个解决方案供用户选择,而不是强制执行单一方案。此外,aptitude 还集成了一个基于文本的用户界面,使得用户可以更直观地浏览和管理软件包。

依赖的隔离性问题#

apt-get 出现后,依赖管理从发布、发现、获取、安装、传递依赖等环节的自动处理都得到了较为完善的解决。因此在很长一段时间内,依赖管理的功能都没有出现新的变革。有的只是对现有工具改进。但是上述的部分问题仍然没有得到彻底解决。尤其是版本冲突的问题。

在前述的 dpkgCPAN 方案中,所有的依赖都是全局安装的,所有程序共享同一套依赖。这种方式简单直接,但也带来了严重的问题:版本冲突。如果两个程序需要不同版本的同一个依赖,就会发生冲突,而他们给出的解决方案也非常粗暴,必须要卸载某一种依赖,导致其中一个程序的依赖无法满足。

deps-global|436

但是,随着软件开发的复杂化,会出现不同项目彼此之间的依赖冲突问题,比如项目 1 依赖于deps-a1.0版本,而项目 2 依赖于deps-b2.0版本。如果按照原有方案,要满足项目 2 的依赖,就会导致项目 1 的依赖无法满足,反之亦然。

在这种情况下,之前的约定 “依赖安装的位置是固定的少数几个位置”,“编译器/链接器能自动寻找依赖”,就无法满足需求了。因为不可能同时安装多个版本的同一个依赖。即使安装了,也无法让编译器/链接器区分开来。

因此,基于其他语言社区长时间手动实践出来的宝贵经验,就有了一些新的解决思路。

1. 在不同位置创建新的环境

比如早期的 python。由于 python 的所有依赖全部安装到site-packages,无法在同一个 Python 环境中隔离依赖,开发者只能在操作系统层面上进行隔离,在同一台机器上安装多个不同版本的 Python 解释器。这实际上就是 venv 的前身。

golang 早期的 GOPATH 也是类似的思路。通过设置不同的 GOPATH,可以在同一台机器上隔离不同项目的依赖。

deps-multi-env

2. vendor 模式,依赖内置为项目的一部分

早期的几乎所有语言依赖管理缺失又有多项目依赖管理的情况下都采用这种方式。将项目的依赖直接放在与项目目录下,作为项目内置的依赖,完全不依赖外部。
比如:

许多 C / C++ 项目,会将第三方依赖的源码直接放在项目目录下,作为项目的一部分。这样就避免了版本冲突的问题,每个项目都有自己独立的依赖副本。

在 java 早期,可能会将依赖的 jar 包直接放在项目目录下的 lib 目录中,作为项目的一部分。

Golang 也曾经采用过这种方式,将解析后的依赖源码直接放在项目目录下的 vendor 目录中,作为项目的一部分。比如著名的 kubernetes 项目。

deps-vendor-env

3. 在同一个依赖存储的位置引入版本化

Maven 在这个问题上则采取了一个不同的思路。它在本地存储所有下载的依赖包,允许存储同一个依赖的多个版本。在进行构建时,Maven 会将所需的依赖包路径通过参数一并传递给编译器,而不是让编译器去按照约定去寻找依赖。这样就避免了版本冲突的问题。

# maven 在构建时,会将所需的依赖路径传递给 javac,mvn compile 实际上会执行类似下面的命令
javac -cp path/to/dependency1.jar:path/to/dependency2.jar MyApp.java

RubyGems 也采取了类似的策略,所有的依赖版本化的存储在一个位置。虽然 Ruby 本身的解释器并不知道 "gem" 是什么。为了让 Ruby 能够 “看到” 安装在 Gem 目录中的库,需要在使用它们之前先加载 RubyGems 库本身。这通常通过在代码的开头写一行 require 'rubygems' 来完成。

后续再进行模块导入的时,require 'A' 会触发 RubyGems 的逻辑,动态地修改 Ruby 的加载路径 $LOAD_PATH,将所需的 Gem 的 lib 目录添加进去。这样 Ruby 就能找到并加载这些库了。
目录:RubyGems 会读取所有已安装 Gem 的 .gemspec 文件,确定要激活的版本:对于每一个被 require 的库(比如 rake),RubyGems 会根据版本依赖关系,从已安装的多个版本中选择最新、最合适的版本来使用。
如果要进行版本指定,可以使用类似于 gem 'A', '1.2.3' 的方式来指定所需的版本。

一旦确定了要使用的 Gem 版本(例如 A-1.2.3),RubyGems 就会将该 Gem 的 lib 目录的绝对路径添加到 Ruby 的 $LOAD_PATH 数组的最前面。例如,它会把 /usr/lib/ruby/gems/1.8/gems/A-1.2.3/lib 这个路径添加进去。

RubyGems 完成路径修改后,下一行的 require 'A' 就能正常工作了。Ruby 解释器会遍历 $LOAD_PATH,在它刚刚被添加进去的路径中找到 A.rb 文件并加载它。

deps-global-versioned

Ruby 和 Maven 均采用了 “版本化存储 + 构建时指定依赖路径” 的方式来解决版本冲突的问题。这种方式的优点在于:

  • 允许同一个环境中安装同一个依赖的多个版本,满足不同项目的需求。
  • 通过构建工具在编译时指定依赖路径,避免了编译器 / 解释器对依赖位置的约定依赖。
  • 简化了依赖管理的复杂性,开发者不需要手动处理版本冲突。
  • 需要确保构建工具和依赖管理工具的兼容性,避免出现版本不匹配的问题。

依赖的不可复现性:Works on my machine#

依赖管理演化到上面所述阶段,已经称得上是一个非常成熟的依赖管理方案了。但是...,凡事总有个但是。

在经过多年的实践后,社区发现,依赖管理还有问题。这通常表现为:在机器 A 上构建好、运行良好的项目,过了一段时间,在机器 B 上构建时却出现了依赖冲突,无法构建成功。它无法保证在不同环境下都能得到一致的结果。

而最根本的原因在于:早期依赖的版本控制问题没有妥善处理。

这表现在两个方面:

  1. 软件包的自身版本声明,是相对随意的。没有如今天这般标准化的版本号规范(尽管分歧还是很多),比如 linux kernel 的早期版本号就很随意,0.010.1a0.1b0.9pl1 等等。且在不同情况下会按照不同的语义进行解读。
  2. 对于声明所需的依赖的版本也没有一个统一的规范,具体来说,可能会有几下情况:
    3. 无版本声明:不指定版本号,表示可以使用任何版本的依赖。比如最开始的 Perl。
    4. 精确版本:指定一个具体的版本号,例如 1.2.3。这表示只能使用这个版本,不能使用其他版本。实践上兼容性不佳,非常容易遇上依赖版本的差异导致的冲突。
    5. 范围版本:指定一个版本范围,例如 >=1.0.0, <2.0.0。这表示可以使用 1.0.0 及以上但低于 2.0.0 的任何版本。是在实践中最具可行性的方案。

依赖解析算法的目标是根据所有依赖的版本声明,找到一个满足所有约束条件的版本组合。这通常涉及到以下几个步骤:

  1. 收集所有依赖及其版本声明。
  2. 构建一个依赖图,表示各个依赖之间的关系。
  3. 使用图论算法(如拓扑排序、回溯搜索等)来寻找一个满足所有版本约束的版本组合。
  4. 如果找到一个可行的版本组合,则安装这些版本的依赖;否则,报告冲突并提示用户进行手动解决。

于是,漏洞就显现出来了。依赖解析算法会根据依赖的版本声明来选择合适的版本,但因为对依赖的版本声明大多是范围声明。就可能出现,同一份依赖声明在不同情况下解析出的依赖版本组合不同。依赖不可复现。

比如 A 依赖 B 版本 >=1.0.0, <2.0.0,而 B 有多个版本可用:1.0.0、1.5.0、1.9.0。在机器 A 上,可能因为某些原因(如网络延迟、镜像不同等)优先选择了 1.5.0,而在机器 B 上则选择了 1.9.0。这样就导致了在不同机器上构建出的项目依赖版本不一致,进而可能引发兼容性问题。

为了解决这个问题,社区探索过一些方案。

比如 Maven 鼓励声明依赖时使用精确版本号,避免范围版本带来的不确定性。这样可以确保在不同环境下都能安装相同版本的依赖,从而实现依赖的可复现性。但是这种方式显然不够灵活。因为依赖的版本更新是不可避免的,过于严格的版本锁定会导致依赖难以更新,且无法利用新版本带来的改进和修复。因此 Maven 推出了 BOM (Bill of Materials)机制,允许集中管理依赖版本,社区人为维护一批能保持依赖兼容性的版本集合。然后将 BOM 作为一个依赖进行发布,其他项目只需引用这个 BOM,就能继承其中定义的依赖版本。这样既保证了依赖版本的一致性,又提供了一定的灵活性,允许在 BOM 中统一更新依赖版本。

但是这仍然无法彻底解决问题。因为 BOM 仍然是一个相对静态的文件,依赖版本的更新仍然需要手动维护和发布。

还有就是前面提到过的 vendor 模式。将依赖直接内置为项目的一部分,作为项目代码库的一部分进行版本控制。这样就彻底避免了依赖解析的问题,因为依赖已经被锁定为特定版本,且与项目代码一起管理和发布。这样就确保了在任何环境下都能使用相同版本的依赖,从而实现依赖的可复现性。但是 vendor 模式的缺点也十分显著:它会导致项目代码库变得庞大,因为每个依赖的代码都被包含在内。更新依赖变得更加复杂,因为需要手动更新项目中的依赖代码,而不是简单地更新版本号声明。

2010 年,Ruby 社区引入了一个全新的lockfile机制:Gemfile.lock。在项目第一次安装依赖时,新的包管理工具 Bundler 会按照以往扫描依赖声明的方式,解析出一个满足所有约束条件的版本组合。并将这个版本组合记录在一个名为 Gemfile.lock 的锁文件中。这个文件会被提交到版本控制系统中,作为项目的一部分进行管理。后续的任何依赖安装操作,都会优先参考这个锁文件,而不是重新解析依赖声明。这样就确保了在不同环境下都能安装相同版本的依赖,从而实现依赖的可复现性。

至此,早期依赖管理的各个环节都得到了妥善处理,形成了一套完善的解决方案。

Nix / NixOS:用掀桌子的方式解决问题#

上面所述的各种依赖管理方案,大多是在既有约定(如 FHS)下进行的,有的严苛,有的宽松。

而 NixOS 则另辟蹊径,它没有试图去满足现有体系,因为那无法解决依赖冲突的可能。而是提出了一套全新的、基于纯函数式构建的思路,从根本上消除了依赖管理中的不确定性。

Nix 彻底抛弃了 FHS 约定(如 /usr/bin, /lib)。系统中的每一个软件包、依赖,包括其特定版本和编译配置。,都被安装在一个唯一的、以内容哈希命名的路径下:

/nix/store/<hash>-<package-name>-<version>

例如:

  • /nix/store/s66j6z...-openssl-1.1.1k
  • /nix/store/fpa3x7...-openssl-3.0.2
  • /nix/store/b28ws0...-curl-7.83.1-with-openssl-1.1.1k
  • /nix/store/a15fhq...-curl-7.83.1-with-openssl-3.0.2

Nix 保证了软件包的构建过程是纯函数式的,每个依赖都有一个唯一的、基于其所有输入(源代码、依赖、编译选项等)的哈希值作为前缀的子目录,对于相同的输入,永远能得到完全相同的输出,这从根本上解决了依赖冲突的问题。

而这里的输入被严格定义和控制,包括:

  • 源代码
  • 所有的编译依赖(如 gcc, make)
  • 所有的库依赖(如 openssl)
  • 编译配置脚本

所有依赖的声明方式通过函数式语言nix.nix文件中声明,这个过程是纯函数式的。
而依赖的构建过程在一个高度隔离的沙箱中进行,它无法访问网络,也无法看到任何未在 .nix 文件中明确声明的依赖。

任何一个输入的微小变动,都会导致最终路径哈希值的变化。这带来了完全的确定性:Nix 构建的产物是完全可复现的,无需 lockfile,因为整个系统和 .nix 声明本身就已经做到了这一点。

但这样做的缺点也是明显的,完全无视/usr/lib/usr/local/lib,这种破坏 FHS 约定的行为,整个 NixOS 无法直接使用其他 Linux 平台的编译产物,而它的编译产物也不能直接用于其他 Linux 平台。因此不得不引入兼容性工具。

  • 对于由 NixOS 进行编译,自行使用的产物,通过 patchelf 对 ELF 文件打补丁,将对动态链接器的依赖,如/lib64/ld-linux-x86-64.so.2,更改为 Nix Store 中对应依赖的路径。将二进制文件运行时查找依赖库的路径(RPATH)设置为其所有 Nix Store 对应路径。对于一些更复杂的程序(比如需要设置环境变量),NixOS 会创建一个启动脚本。这个脚本会先设置好所有必要的环境变量,指向 /nix/store 中的正确路径,然后再执行真正的二进制文件。
  • 而对于 NixOS,想要运行其他 Linux 编译的产物,则需要在.nix中使用 buildFHSUserEnv 函数,这个函数可以创建一个临时的、模拟 FHS 结构的沙箱环境。它通过符号链接(symlinks)将 /usr/bin, /usr/lib 等 FHS 路径链接到 Nix Store 中你指定的包。
  • 而对于由 NixOS 进行编译,由其他 Linux 平台使用的产物,则需要 nix-bundle,它会获取一个 Nix 构建产物及其所有运行时依赖项(整个闭包),然后将它们打包成单个的、自包含的可执行文件。

Nix 将依赖管理问题通过纯函数构建的方式解决,提供了一种在理论上近乎完美的解决方案。尽管这种行为破坏了既有约定,对于个体开发者的经验破坏很大,使得 NixOS 学习成本很高。

但 Nix 完全可复现性使得它在一些大规模商业场景下显得尤为有效。如 Replit、Tailscale 等,都使用 Nix 来统一和管理开发环境。Cloudflare 会用 NixOS 来管理其部分基础设施,以获得可靠和可复现的系统配置。

后发编程语言在依赖管理上踩过的坑#

按理来说,在例举了如此多的前辈之后,后发编程语言在依赖管理上应该能充分吸取前人的经验教训,避免重蹈覆辙才对。但是事实并非完全如此,不少编程语言在依赖管理上也在踩坑,此处仅以 Node.js 与 Golang 为例。

Node.js#

JS 是一个很有趣的语言,最先只是作为一个单文件脚本语言设计的。

作为一个仅仅十天就设计出来的脚本语言,一个早产儿,很多功能都是缺失的,包、模块化、依赖管理,就是其中之一。

它本应该像大多数玩具编程语言一样,在历史中短暂的出现,然后迅速消失。但幸运的是在 netscape 的运作下,JS 乘上了浏览器的东风,顺利的在浏览器的战争中存活下来。

早期的 JS 主要在浏览器中被使用,一切都是全局的,<script>加载完毕之后就即刻运行。没有什么包、模块化、依赖管理的概念。

依赖的获取和使用是完全的手动。要实现各种隔离,只能依靠闭包进行。而对于可能存在的冲突,则全部都只能依靠程序员手动解决。

这种状态一直持续到了 2009 年 NodeJS 出现。Node 作为一个全新的 JS 运行时,明确的引入了包和模块化的概念(CJS),以及对应的依赖管理工具 npm。

虽然 Node 最先为 JS 社区带来了模块化以及基于 npm 提供的包管理能力。

但是实际体验是极其糟糕的,因为对于彼时的前端并不适用, node_modules 并不适合直接在 <script> 标签中引用。因此社区很快也行动起来。

一方面在模块化方面发力,从 IIFE 的原始方式,逐步演进到 AMDCMDUMD 等。

另外一方面在包管理器上发力,最开始是为前端管理依赖的 bower,相比于 npm 的嵌套结构, bower 采用了一种扁平式的依赖。所有依赖都安装在 bower_components 目录下,对于依赖冲突在安装时直接留给用户处理,而依赖的使用则交由 AMD 这样的模块化方案处理。

但是这就导致了 JS 前端和后端生态的割裂。因此后续又出现了 UMD 这样同时兼容两个运行时的模块化方案和 browserify 这样的打包器。使得前端的依赖管理可以顺利的迁移到 npm 上,共用一个生态。

CJS 的依赖搜索方式参考了 Perl 的 @INC 路径搜索。模块加载分为三类:node 自带的模块,本地目录的模块,node_modules 中的模块。

对于 node_modules 的模块,会采用向上搜索的方式,从当前 JS 文件所处的包中,搜索 node_modules 文件夹,如果不存在,则从父目录中搜索(向上搜索),直到根目录。

整个依赖结构类似于下面这样:

node_modules
├── express
│   └── node_modules
│       └── debug
└── koa
    └── node_modules
        └── debug
└── moduleA
    └── node_modules
        └── deps

这样的好处显而易见,不存在依赖冲突的问题,所有依赖都有自己的 node_modules

但是问题也是非常显著的,存在大量的重复依赖,安装极其缓慢,且浪费大量磁盘空间,lockfile 的缺失也同样导致复现问题。经典 meme:

node_modules meme

很快,为了解决磁盘占用问题,在 npm v3 中,就从嵌套的依赖结构转为了扁平化的方案。对于首次安装的依赖,先放在当前项目的顶级 node_modules 中,而对于已经存在但版本有冲突的依赖,则按照原样放在嵌套目录下。

node_modules/
├── A/
│   └── ...
├── B/
│   ├── ...
│   └── node_modules/
│       └── lodash/   <-- 包 B 后安装,其 lodash 依赖因版本冲突,按原有方式嵌套放入。
│           └── ...
└── lodash/  <-- 包 A 先安装,其 lodash 依赖放入顶级 node_modules
    └── ...

但这也引入了新的、 node 特有的问题,幻影依赖(Phantom Dependency),对于在 package.json 中没有声明的依赖,却可以正常使用。

面对着一堆问题,社区也没有闲着,开始自造包管理器,比较典型的包括 yarnpnpm

其中 yarn 充分吸收了其他包管理器的经验:

  • 最先引入了 lockfile 机制,通过 yarn.lock 锁定依赖,解决了复现问题,后续 npm v5 也引入了 package-lock.json
  • 在安装流程上实现并行化以提高安装速度
  • 提供依赖的本地缓存

但是他仍然无法解决 node_modules 占用空间大的问题,因为版本冲突是非常常见的,依赖提升对依赖体积的缩小并无太大帮助,只能说有所缓解。

pnpm 则另辟蹊径:

  • 学习 yarn 引入 lockfile
  • 从架构上,还原原来的 node_modules 嵌套结构,以满足 node 的依赖搜索方式,并彻底解决幻影依赖问题
  • 利用符号链接,将嵌套的 node_modules 依赖链接到一个扁平化、版本化的.pnpm中,再链接到版本化的全局的依赖缓存中,全局仅存储一份,从而彻底解决磁盘占用问题。

依赖的存储上类似于其他编程语言的包管理方案,只是多利用了文件系统的软硬链接特性以模拟 node_modules

但是这些都还是在满足 CJS 这个模块化方案对 node_modules 依赖的假设。yarn@v2 通过一种很 hacker的方式试图去修改 node 在引用模块时的处理逻辑。这个模式称为 PnP (Plug'n'Play)。

PnP 直接拒绝了 node_modules,取而代之的是一个 .pnp.cjs 文件。
安装依赖时,所有依赖的压缩包 (.zip) 被存储在项目下的 .yarn / cache 目录中。
.pnp.cjs 文件则是一个 Node.js 脚本,它包含了项目中所有包的精确位置信息(在哪个 zip 文件里)以及它们之间的依赖关系图,
yarn 通过 "monkey-patching"(猴子补丁)的方式重写 Node.js 的 require 解析逻辑。
当在代码中执行 require ('module') 时,Node.js 不再在 node_modules 中逐级查找,而是直接查询 .pnp.cjs 文件,该文件会告诉对应模块的代码具体在哪个 zip 文件的哪个位置。
此外,这些已安装的依赖压缩包会随着 VCS 一并提交。

这种处理方式带来的好处是明显的:

  • 参照 vendor 模式将依赖同 VCS 一同提交,使得 git clone 时不再需要安装依赖。
  • 因为 hacker 了原有导入依赖的处理逻辑,解决了幻影依赖的问题。

但缺点更是致命的,挑战原有的假设必然会导致一系列问题,生态兼容性非常差。需要非常多的时间来处理兼容性问题,需要大量工具链进行适配。这种激进的技术路线选择直接导致 yarn 后续在与 pnpm 的竞争中失去优势。

此外,虽然经历过如此多的竞争和协作,这些问题已经解决的很好了。但是运行时的割裂问题显得尤为突出,为了实现依赖导入和使用,目前浏览器端在依赖管理的运用上严重依赖于构建工具,毕竟浏览器并不知道 node_modules。

因此 W3C 提出了 ImportMaps 标准,通过一个 import-map.json,映射 import {} from 'deps-key',到一个特定的 URL。然后运行时用这个 URL 中解析依赖。

但在实践中似乎推进得并不顺利,即使浏览器已经普遍支持了这个特性,Node 的 import-map 支持仍然是实验性的。而且社区很多既有工具没有动力支持。

Golang#

Go 语言最初也并没有做好依赖管理的设计,采用的是一种非常朴素的方案。

Golang 假设依赖位于一个 VCS 的远程仓库上,通过 go get 命令从某个 git / svn 获取依赖(一份 go 源码),然后自动将其放入全局的 GOPATH 中。

没有采用已经经过验证的 registry 模式、没有类似于go.mod这样的依赖声明方式、也没有专门的一个操作:发布 Go 包

开发者只是手动通过go get 从 VCS 的默认分支中获取代码作为依赖。

也就是说,依赖可用性,完全取决于提供方,如果某天 URL 不再可达,那么依赖就无法获取。

而且每一次 VCS 仓库更新,go get 都会获取最新的代码。

于是很快就遇到了依赖的版本冲突问题,而且是最严重的那一种:每一次 go get 获取一份新依赖,都会自动获取依赖的最新版本放在磁盘上。

这意味着可能会出现如下情况:

  • 对于开发者 A,通过 go get 为项目安装了依赖 github.com/golang/deps
  • 一个月后,依赖 github.com/golang/deps 更新了,并且存在一些 breaking change
  • 与开发者 A 协作的开发者 B 将项目拉取下来,再进行 go get,此时会安装更新后的、带有 breaking change 的依赖。
  • 在开发者 A 机器上能够顺利编译运行的项目,在开发者 B 的机器上无法正常运行。

很快, Go 1.0 发布一年后,社区就针对这些问题提出了对应的解决方案。

最先出现的是godep,它引入了一种非常类似于 node_modules 的方案,通过 <project>/GoDeps/GoDeps.json 实现依赖声明和锁定(进入 VCS)。
通过<project>/GoDeps/_workspace存储依赖(不进入 VCS),在项目新安装依赖时,将依赖从 GOPATH 中复制一份到<project>/GoDeps/_workspace中,而原有的 GOPATH 只视为一种缓存。在构建时临时修改 GOPATH,将其优先指向 <project>/GoDeps/_workspace

这种方案基本解决依赖的获取、管理以及依赖复现的问题。

因为 GoDeps 的依赖出来还是需要经过 GOPATH,因此还有一个需求没能解决:开发者希望能够在项目级别隔离依赖,以防止依赖冲突。

因此 Go 团队在 Go 1.5 中引入了 vendor 目录(自 Go 1.6 默认启用),提供了一个由官方工具链支持的机制,用户能够保持项目依赖的独立性。

相比于GoDeps_workspace方案,vendor 更为激进,提倡依赖进入 VCS。

通过 vendor 目录,每次项目构建时,默认会先从 vendor 目录中获取依赖,如果 vendor 目录中不存在,才会从 GOPATH 中获取。

这样好处是可以完全绕过 GOPATH,以避免不同项目在 GOPATH 中的依赖冲突。但是 vendor 模式的弊端前面已经提到过了,所有依赖代码全部跟着 VCS 走,每一次更新依赖,都会导致大量的代码变更。在每一个依赖都有自己的 vendor 的情况下,项目大小会很快膨胀(请想象一下 node_modules in Go,而且还将这个 node_modules 随 VCS 一起提交了)。

为了彻底解决这些问题,Go 团队成立了专门的包管理委员会来试图解决这些问题。最终在 Go 1.11 中引入了包括 Go Module 机制在内的大量更改。

  • 引入中心化的包管理系统,类似于 npm registry。
  • 引入模块语义化版本号,支持多版本并存。
  • 引入 go.modgo.sum,用于声明和锁定依赖。
  • 模块不再存入$GOPATH/src,而是统一存入 $GOPATH/pkg/mod并标记版本,如$GOPATH/pkg/mod/...pkgpath/pkgname@version

这一次更新基本将之前的固有的、不成熟的依赖管理问题一次性全部解决。

Go Module

Deno#

作为 Node.js 原作者的全新的 JS 运行时,Deno 充分的吸取了 node_modules 的教训。

不再有 node_modules,所有依赖都被视为一个 URL,可以是本地,也可以是远程,在这方面与 Golang 早期很像,获取依赖后缓存在本地的一个全局目录中。

但远程 URL 依赖的可复现性和安全性欠佳。因此很快引入了 deno.lock,通过 lockfile 来实现确定性。还支持了采用了 W3C 标准的 ImportMaps 完成依赖声明。

与之相对的是 Bun,同样作为新兴 JS 运行时,在依赖管理问题上,完全妥协,全面拥抱 node_modules。

Rust#

作为一个 2015 年才 v1 的后后后发编程语言,Rust 在工具链上显得非常成熟。从最开始就自带了依赖管理、构建、测试、文档生成等诸多功能于一身的 cargo。

采用的是一种非常成熟的依赖管理方式,lockfile + 版本化存储的全局依赖位置(如~/.cargo/registry/src/)。编译器在编译时会自动寻找依赖并加入编译。

此外而且还能解决菱形依赖问题:依赖 A 依赖于 dep@v0.3,B 依赖于 log@v0.4。编译器会分别编译它们,生成 liblog-v0.3.rlib 和 liblog-v0.4.rlib,在最终链接时,依赖 A 会链接到 v0.3 的版本,依赖 B 会链接到 v0.4 的版本。

但是为了支持 Rust 极为强大的宏, cargo 默认从源码编译所有依赖。不像 Go 等语言可以分发预编译的库。这就会导致每次清理项目或更新依赖后,都需要花费大量时间重新编译整个依赖树。

其他问题#

尽管依赖管理已然十分成熟,但是仍然存在一些尚未完全解决的遗留问题,同时还面临着新的挑战。

多层依赖问题:胶水语言#

依赖管理一个长期存在的问题来自于 “胶水语言”。这些语言为了获取高性能或复用现有生态,通常会通过外部函数接口(Foreign Function Interface, FFI)调用 C / C++ 编写的底层库,而依赖本身只作为底层实现的 API 入口,形成一个双层依赖的结构。

编程语言自身的包管理器能够很好地管理编程语言层的依赖包,但对相关联的底层库依赖管理不是很好做。这种结构带来了两类问题:

系统级依赖安装问题#

每个系统环境的依赖是不同的,为 linux_x64 构建的底层库不能用于 linux_arm64,安装依赖时要保证这个依赖可用有两个思路:

一个是在安装时从源码编译,比如一个 Python 包依赖的 C 扩展没有提供预编译版本,pip 会尝试在用户本地从源码进行编译,这就要求用户的系统必须安装完整的 C / C++ 工具链和所有必需的头文件、开发库。这对许多非 C++ 背景的开发者来说是一个巨大的障碍。

另外一种思路,也是社区对此的主流解决方案是提供预编译的二进制包(如 Python 的 .whl 文件)。开发者在发布时,为各种主流操作系统和 CPU 架构提前编译好二进制产物。这样,用户在安装时可以直接下载使用,绕过了本地编译的痛苦过程。但这种方案也只能缓解(总有预编译覆盖不到的情况)

运行时动态库符号冲突的问题#

预编译包很大程度上缓解了安装的问题,但仍然存在运行时问题。比如 Python 的 .whl 文件在打包时,会将编程语言依赖和底层库依赖打包为一个 .whl 文件,在运行时,引用这个依赖时自行加载。
考虑以下情景:

  • package-A.whl 为了实现某个功能,自己打包了 libdeps.so 的 1.0 版本。
  • package-B.whl 为了实现另一个功能,自己打包了 libdeps.so 的 2.0 版本。

当程序同时 import package_A 和 import package_B 时,操作系统的动态加载器会将这两个不同版本的 .so 文件加载到同一个进程的内存空间。当代码调用一个 libdeps 中的函数时,加载器可能会因为符号表中的函数名重复,错误地将 package-A 的调用解析到 package-B 的函数实现上,反之亦然。由于两个版本内部数据结构和 API 行为可能完全不同,这可能会导致各种 Undefined Behavior。预编译包甚至加剧了这个问题,因为它将底层库依赖的版本冲突完全隐藏了起来,直到运行时才以一种诡异的方式出现。

要解决这个问题,也有两种思路:

一种是有效但略微成本高的些的静态链接,将底层库静态链接到扩展模块中,并控制符号的可见性,避免将其导出到全局符号表,类似于 Rust 链接不同版本的依赖。

另外一种则是依赖于整个生态的约定:在一个特定生态中(如科学计算),通常会有一个核心包(如 numpy)来提供某个底层库(如 BLAS)的唯一、权威版本,其他包都依赖于这个核心包,而不是自己单独捆绑一份。

安全合规问题#

在完成依赖管理的自动化后,依赖的制作、发现、获取成本都变得极低,而对依赖需求也自然是越来越大,随便一个普通项目,展开依赖树很容易见到成百上千个依赖。因此更高层次的要求也从次要矛盾转为主要矛盾。如安全性、合规性等。

安全性#

依赖的安全性是一个日益严重的问题,有些漏洞是内生性的,因设计缺陷或无意中引入的 bug,可以通过远程执行命令等方式被利用。
而有些则是由依赖提供方、或被第三方攻击实现的,将恶意代码植入到合法的依赖包中,导致依赖本身主动携带漏洞(如盗取依赖开发者账号、中间人攻击等)。

为了应对这样的安全性问题,业界正在建立一套端到端的信任链,其核心思想是 “零信任”,即对供应链的每个环节都进行验证。如:

  • 对对已有漏洞进行记录,构建 CVE 数据库,配合漏洞扫描,做到及时通知。
  • 保证来源可信,如在提供方使用 MFA,减少盗取风险,进行签名验证,建立可信的私有仓库等
  • 保证内容可知,对于任何依赖,应该要了解其本身包含了什么,依赖结构是什么样的。也就是 SBOM (Software Bill of Materials),这通常是各种 lockfile

最终引入 SLSA(Supply-chain Levels for Software Artifacts)框架,对软件供应链等全链条进行持续安全评估。SLSA 是一个安全框架,它定义了一系列安全准则,等级从 1 到 4 越来越严格。它要求构建过程的每个环节都留下防篡改的证据(即出处证明 - Provenance),如:

  • 构建脚本来自哪个代码提交? (SLSA 1)
  • 构建过程是否在隔离、可信的平台上完成? (SLSA 2-3)
  • 构建过程的每个依赖是否都经过了验证? (SLSA 4)
    通过验证这些数字签名和出处证明,可以确信手中的软件包确实是由可信的源码、在可信的环境中构建出来的,未经任何篡改。
合规性#

在代码安全性之外,还有法律上的安全性需要考虑,也就是合规性。对于任何一个大型项目,依赖的层级深、依赖树极其庞大,而不同的依赖可能会采用不同的许可证,种类也极其繁多(MIT、Apache、GPL、AGPL 等),它们的授权条款差异巨大。自动化扫描所有直接和间接依赖的许可证,根据固定策略(如 “禁止使用 GPL 协议的包”)自动告警或阻断构建。

维护可靠性#

对于一个长久项目,必然希望使用到的依赖是处于积极维护状态的。否则旧版本一旦出现新漏洞,就是非常致命的。这可能有多个指标,如开源提交数、Issue 响应速度、社区健康程度等。最终的要求是 依据这些指标,来判断是否采用某些依赖。

可审计性#

对于一项长期项目,可能需要在最终溯源的时候,要了解何时开始采用、以何种方式采用了某个依赖。要将依赖管理的所有决策和变更都记录在版本控制系统 / 工单系统中,形成完整的追溯链。

以上所有的治理需求最终都指向一个需求:根据依赖和项目的客观信息,对依赖的使用请求进行把控,按照特定策略来判断能否采用依赖,对依赖进行筛选。也就是需要一个形成一个策略引擎,这个策略引擎会自动执行判断规则,对每一次依赖变更进行审查,从而将实现更高要求的依赖治理。

小结#

写到这里,差不多洋洋洒洒水了 1.5w 字,回头看却发觉讲述的结构并不是非常清晰,有的按发展时间划分、有的按功能点划分,详略也并非得当,略显混乱。在此做一简单小结:

  • 手动管理:最初没有专门的依赖管理工具,开发者需要手动查找、下载、编译和链接所需的代码库。这个过程耗时且容易出错。
  • 系统级包管理:系统包管理器定义了标准的软件包格式,并能自动处理库的安装和部分依赖关系,但主要针对全局共享的系统环境。
  • 中心仓库实现依赖分发:通过中心化的仓库(Registry)简化了依赖的发现和获取。
  • 传递依赖解析:用依赖解析算法解析传递依赖找到符合要求的依赖版本集合并进行安装。
  • 项目级隔离:
    • vendor:项目内嵌依赖,如 Node.js 的 node_modules,Golang 的 vendor 目录
    • 多环境:多个环境存储依赖,在使用时自行切换环境,如 Python 的 Venv。
    • 单环境多版本:在同一个位置存储依赖,但是进行版本化区分。由编译器自行寻找依赖(如 Rust 的 Cargo)/构建工具提供给编译器(如 Java 的 Maven)
  • 构建可复现性:引入 lockfile 机制,通过锁定依赖的确切版本,解决了因版本范围导致的不确定性问题,保证了构建结果的一致性。

各种后发的编程语言,如 Go 和 Node.js,在早期也经历了各自的探索和弯路,但最终也都吸收了 Registry、lockfile 等成熟方案的优点。而像 Rust 的 Cargo 则是在设计之初就集成了这些最佳实践。此外,还出现了像 Nix 这样通过纯函数和哈希路径,用完全的确定性来彻底解决版本冲突和可复现性的思路。

至此,软件依赖管理本身模式已经基本成熟,新的关注点转向了供应链安全等更高层面的问题。