依赖项管理

报告问题 查看来源 Nightly · 8.3 · 8.2 · 8.1 · 8.0 · 7.6

在浏览前面的页面时,您会发现一个反复出现的主题:管理自己的代码相当简单,但管理其依赖项却要困难得多。依赖项种类繁多:有时依赖于任务(例如“在将版本标记为完成之前推送文档”),有时依赖于制品(例如“我需要最新版本的计算机视觉库来构建代码”)。有时,您会内部依赖于代码库的另一部分,有时会外部依赖于另一团队(无论是您组织中的团队还是第三方)拥有的代码或数据。但在任何情况下,“我需要先有这个,才能有那个”的想法在构建系统的设计中反复出现,而管理依赖项或许是构建系统最基本的工作。

处理模块和依赖项

使用基于制品构建系统的项目(例如 Bazel)会分解为一组模块,模块之间通过 BUILD 文件表达依赖关系。妥善组织这些模块和依赖项对构建系统的性能和维护工作量都有巨大影响。

使用精细化模块和 1:1:1 规则

在构建基于制品的 build 时,首先要考虑的问题是单个模块应包含多少功能。在 Bazel 中,模块由指定可构建单元(如 java_librarygo_binary)的目标表示。在一种极端情况下,整个项目可以包含在一个模块中,方法是将一个 BUILD 文件放在根目录下,并以递归方式将该项目的所有源文件 glob 在一起。在另一个极端情况下,几乎每个源文件都可以成为自己的模块,这实际上要求每个文件在 BUILD 文件中列出它所依赖的所有其他文件。

大多数项目都介于这两个极端之间,因此选择时需要在性能和可维护性之间进行权衡。为整个项目使用单个模块可能意味着您永远不需要修改 BUILD 文件(添加外部依赖项时除外),但这同时也意味着 build 系统必须始终一次性构建整个项目。这意味着,它无法并行化或分发 build 的各个部分,也无法缓存已构建的部分。每个文件一个模块则恰恰相反:构建系统在缓存和调度构建步骤方面具有最大的灵活性,但工程师需要花费更多精力来维护依赖项列表,因为他们会经常更改哪些文件引用了哪些文件。

虽然确切的粒度因语言而异(有时甚至在同一语言内也有所不同),但 Google 倾向于使用比基于任务的构建系统中通常编写的模块小得多的模块。Google 中的典型生产二进制文件通常依赖于数万个目标,即使是中等规模的团队也可以在其代码库中拥有数百个目标。对于像 Java 这样具有强大的内置封装概念的语言,每个目录通常包含一个软件包、目标和 BUILD 文件(Pants 是另一个基于 Bazel 的 build 系统,它将此称为 1:1:1 规则)。包装规范较弱的语言通常会为每个 BUILD 文件定义多个目标。

较小的 build 目标在规模化时才能真正体现出优势,因为它们可以加快分布式 build 的速度,并减少重新 build 目标的频率。 在测试阶段,细粒度目标带来的优势会更加明显,因为这意味着构建系统可以更智能地仅运行可能受到任何给定更改影响的有限测试子集。由于 Google 认为使用较小的目标文件具有系统性优势,因此我们投入资金开发工具,以自动管理 BUILD 文件,从而避免给开发者带来负担,在一定程度上缓解了使用较小目标文件带来的缺点。

其中一些工具(例如 buildifierbuildozer)可通过 Bazel 在 buildtools 目录中使用。

最大限度地减少模块可见性

Bazel 和其他 build 系统允许每个目标指定一个可见性,该属性用于确定哪些其他目标可以依赖于该目标。专用目标只能在其自己的 BUILD 文件中引用。目标可以向明确定义的 BUILD 文件列表的目标授予更广泛的可见性,或者(如果是公开可见性)向工作区中的每个目标授予更广泛的可见性。

与大多数编程语言一样,最好尽可能缩小可见性。一般来说,Google 的团队仅在目标代表 Google 任何团队都可以使用的广泛使用的库时,才会将目标设为公开。如果团队要求其他人在使用其代码之前与其协调,则会维护客户目标平台的许可名单,作为其目标平台的可见性。每个团队的内部实现目标将仅限于团队拥有的目录,并且大多数 BUILD 文件将只有一个非私有目标。

管理依赖项

模块需要能够相互引用。将代码库细分为细粒度模块的缺点是,您需要管理这些模块之间的依赖关系(不过工具可以帮助自动执行此操作)。表达这些依赖项通常会成为 BUILD 文件中的大部分内容。

内部依赖项

在细分为多个细粒度模块的大型项目中,大多数依赖项很可能是内部依赖项,即依赖于在同一源代码库中定义和构建的另一个目标。内部依赖项与外部依赖项的不同之处在于,前者是在运行 build 时从源代码构建的,而不是作为预构建的制品下载的。这也意味着,内部依赖项没有“版本”的概念,目标及其所有内部依赖项始终在代码库中的同一提交/修订版本中构建。在处理内部依赖项时,应仔细处理的一个问题是如何处理传递依赖项(图 1)。假设目标 A 依赖于目标 B,而目标 B 依赖于通用库目标 C。目标 A 是否应能够使用目标 C 中定义的类?

传递依赖项

图 1. 传递依赖项

就底层工具而言,这没有问题;B 和 C 在构建时都会链接到目标 A,因此 C 中定义的任何符号对于 A 都是已知的。多年来,Bazel 一直允许这样做,但随着 Google 的发展,我们开始发现问题。假设 B 经过重构,不再需要依赖 C。如果随后移除了 B 对 C 的依赖项,那么 A 和任何其他通过对 B 的依赖项使用 C 的目标都会中断。实际上,目标的依赖项已成为其公共合约的一部分,并且永远无法安全地更改。这意味着,依赖项会随着时间的推移而累积,Google 的 build 开始变慢。

Google 最终通过在 Bazel 中引入“严格的传递依赖模式”解决了此问题。在此模式下,Bazel 会检测目标是否尝试引用某个符号,但未直接依赖该符号,如果检测到,则会失败并显示一条错误消息和一个可用于自动插入依赖项的 shell 命令。在整个 Google 代码库中推出此变更并重构数百万个构建目标以明确列出其依赖项是一项历时多年的工作,但非常值得。现在,由于目标具有较少的非必要依赖项,我们的 build 速度快了很多,工程师可以放心地移除不需要的依赖项,而不必担心会破坏依赖于这些依赖项的目标。

与往常一样,强制执行严格的传递依赖项需要权衡利弊。它使 build 文件更加冗长,因为常用库现在需要在许多地方明确列出,而不是偶然拉入,并且工程师需要花费更多精力向 BUILD 文件添加依赖项。此后,我们开发了一些工具,可自动检测许多缺失的依赖项并将其添加到 BUILD 文件中,无需任何开发者干预,从而减少了这种繁琐的工作。但即使没有此类工具,我们也发现随着代码库规模的扩大,这种权衡也是值得的:在 BUILD 文件中显式添加依赖项是一次性成本,但处理隐式传递依赖项可能会在 build 目标存在期间持续造成问题。默认情况下,Bazel 会对 Java 代码强制执行严格的传递依赖项

外部依赖项

如果依赖项不是内部依赖项,则必须是外部依赖项。外部依赖项是指在构建系统之外构建和存储的制品。依赖项直接从制品库(通常通过互联网访问)导入,并按原样使用,而不是从源代码构建。外部依赖项和内部依赖项之间最大的区别之一是,外部依赖项具有版本,并且这些版本独立于项目的源代码而存在。

自动与手动依赖项管理

构建系统可以手动或自动管理外部依赖项的版本。如果手动管理,build 文件会明确列出要从制品库下载的版本,通常使用语义版本字符串(例如 1.1.4)。如果由系统自动管理,源文件会指定一系列可接受的版本,而 build 系统始终会下载最新版本。例如,Gradle 允许将依赖项版本声明为“1.+”,以指定只要主要版本为 1,任何次要版本或补丁版本都是可接受的。

自动管理的依赖项对于小型项目来说可能很方便,但对于规模不小的项目或由多位工程师共同处理的项目来说,通常会带来灾难性后果。自动管理的依赖项存在的问题是,您无法控制版本更新的时间。我们无法保证外部方不会进行破坏性更新(即使他们声称使用语义化版本控制),因此,某一天正常运行的 build 可能在第二天就无法正常运行,而且很难检测到发生了哪些变化,也无法将其回滚到正常运行的状态。即使构建没有中断,也可能会出现难以追踪的细微行为或性能变化。

相比之下,由于手动管理的依赖项需要更改源代码控制,因此可以轻松发现和回滚,并且可以检出旧版本的代码库以使用旧依赖项进行构建。Bazel 要求手动指定所有依赖项的版本。即使在适中的规模下,手动版本管理的开销也完全值得,因为它可以提供稳定性。

单版本规则

库的不同版本通常由不同的制品表示,因此从理论上讲,没有理由不能在构建系统中以不同的名称同时声明同一外部依赖项的不同版本。这样,每个目标都可以选择要使用的依赖项版本。这在实践中造成了很多问题,因此 Google 对代码库中的所有第三方依赖项强制执行严格的单版本规则

允许多个版本存在的最严重问题是菱形依赖关系问题。假设目标 A 依赖于目标 B 和外部库的 v1 版本。如果稍后对目标 B 进行重构,以添加对同一外部库 v2 的依赖项,目标 A 将会中断,因为它现在隐式依赖于同一库的两个不同版本。实际上,从目标向任何具有多个版本的第三方库添加新的依赖项都是不安全的,因为该目标的任何用户可能已经依赖于其他版本。遵循“单版本规则”可避免此冲突。如果目标添加了对第三方库的依赖项,则任何现有依赖项都将是同一版本,因此它们可以顺利共存。

传递外部依赖项

处理外部依赖项的传递依赖项可能尤其困难。许多制品库(例如 Maven Central)都允许制品指定对制品库中其他制品的特定版本的依赖关系。Maven 或 Gradle 等构建工具通常默认会以递归方式下载每个传递依赖项,这意味着在项目中添加单个依赖项可能会导致总共下载数十个制品。

这非常方便:在添加对新库的依赖项时,如果必须跟踪该库的每个传递依赖项并手动添加它们,那将非常麻烦。但这种方法也有一个很大的缺点:由于不同的库可以依赖于同一第三方库的不同版本,因此这种策略必然会违反“单版本规则”,并导致菱形依赖项问题。如果您的目标依赖于两个使用同一依赖项的不同版本的外部库,那么您无法确定会获得哪个版本。这也意味着,如果新版本开始拉取其某些依赖项的冲突版本,更新外部依赖项可能会导致整个代码库中出现看似无关的故障。

因此,Bazel 不会自动下载传递性依赖项。遗憾的是,没有万全之策。Bazel 的替代方案是要求提供一个全局文件,其中列出代码库的每个外部依赖项,以及在整个代码库中用于相应依赖项的明确版本。幸运的是,Bazel 提供了能够自动生成此类文件的工具,该文件包含一组 Maven 制品的传递依赖项。此工具可运行一次,以生成项目的初始 WORKSPACE 文件,然后可以手动更新该文件以调整每个依赖项的版本。

同样,这里的选择也是在便利性和可扩缩性之间进行权衡。小型项目可能不想自己管理传递依赖项,或许可以使用自动传递依赖项。随着组织和代码库的增长,这种策略的吸引力会越来越低,冲突和意外结果也会越来越频繁。在大规模情况下,手动管理依赖项的成本远低于处理自动依赖项管理所导致问题的成本。

使用外部依赖项缓存 build 结果

外部依赖项通常由发布稳定版库的第三方提供,可能不提供源代码。有些组织可能还会选择将自己的部分代码作为制品提供,从而允许其他代码将其作为第三方依赖项(而非内部依赖项)来依赖。如果制品构建速度较慢但下载速度较快,从理论上讲,这可以加快构建速度。

不过,这也带来了很多开销和复杂性:需要有人负责构建每个制品并将其上传到制品库,并且客户端需要确保它们与最新版本保持同步。调试也会变得更加困难,因为系统的不同部分将从代码库中的不同点构建,并且不再有源代码树的一致视图。

解决制品构建时间过长问题的更好方法是使用支持远程缓存的构建系统,如前所述。此类构建系统会将每次构建生成的制品保存到工程师共享的位置,因此,如果开发者依赖于其他人最近构建的制品,构建系统会自动下载该制品,而不是自行构建。这样一来,既能获得直接依赖于制品带来的所有性能优势,又能确保构建的制品与始终从同一来源构建的制品一样具有一致性。这是 Google 在内部使用的策略,并且可以将 Bazel 配置为使用远程缓存。

外部依赖项的安全性和可靠性

依赖第三方来源的制品本身就存在风险。如果第三方来源(例如制品库)出现故障,则存在可用性风险,因为如果无法下载外部依赖项,整个 build 可能会停止运行。此外,还存在安全风险:如果第三方系统遭到攻击者入侵,攻击者可能会将引用的制品替换为自己设计的制品,从而将任意代码注入到您的 build 中。通过将您依赖的所有制品镜像到您控制的服务器上,并阻止您的 build 系统访问 Maven Central 等第三方制品库,可以缓解这两个问题。不过,维护这些镜像需要付出精力和资源,因此是否使用它们通常取决于项目的规模。此外,还可以通过要求在源代码库中指定每个第三方制品的哈希,从而完全避免安全问题,并且开销很小。如果制品遭到篡改,构建会失败。完全避开此问题的另一种替代方法是提供项目的依赖项。当项目对其依赖项进行 vendor 处理时,会将这些依赖项与项目的源代码一起签入源代码控制中,可以是源代码,也可以是二进制文件。这实际上意味着项目的所有外部依赖项都将转换为内部依赖项。Google 在内部使用这种方法,将整个 Google 中引用的每个第三方库都签入到 Google 源代码树根目录下的 third_party 目录中。不过,这仅适用于 Google,因为 Google 的源代码控制系统是专门构建的,可处理极大的单体代码库,因此对于所有组织来说,供应商化可能不是一个可行的选项。