依赖项管理

在浏览前面几页时,有一个主题反复出现:管理您自己的代码相当简单,但管理其依赖项却并非易事。存在各种依赖项:有时某项任务有依赖项(例如“在将某个版本标记为完成之前推送文档”),有时还需要某个工件依赖工件(例如“我需要使用最新版本的计算机视觉库来构建代码”)。有时,您对代码库的其他部分有内部依赖项,或者由第三方的代码拥有外部依赖项,或者有外部依赖项。但无论如何,“我必须先做到这一点”这一理念在构建系统的设计中反复出现,而管理依赖项可能是构建系统最基本的工作。

处理模块和依赖项

使用 Bazel 等基于工件的构建系统的项目被拆分为一组模块,各个模块通过 BUILD 文件表示彼此之间的依赖关系。这些模块和依赖项的恰当组织会对构建系统的性能以及维护工作量产生重大影响。

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

在构建基于工件的 build 时,出现的第一个问题是确定单个模块应包含多少功能。在 Bazel 中,模块由目标表示,用于指定可构建单元,例如 java_librarygo_binary一种极端情况是,将整个项目包含在单个模块中,只需将一个 BUILD 文件放在根目录下,然后以递归方式将该项目的所有源文件组合在一起即可。而另外一种极端情况是,几乎每个源文件都可以制作成自己的模块,这实际上要求每个文件都列在一个 BUILD 文件中,列出它所依赖的所有其他文件。

大多数项目都处于这些极端情况之间,并且选择该过程需要在性能和可维护性之间进行权衡取舍。对整个项目使用一个模块可能意味着除非在添加外部依赖项时才需要处理 BUILD 文件,但这意味着构建系统必须始终一次性构建整个项目。这意味着它无法并行处理或分布 build 的部分内容,也无法缓存已构建的部分。每个文件一个模块正好相反:构建系统在缓存和调度构建步骤方面具有最大灵活性,但每当工程师更改引用哪个文件时,工程师都需要投入更多精力来维护依赖项列表。

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

较小的 build 目标的优势在于可以真正地大规模展现出优势,因为它们可以加快分布式构建的速度,并且无需频繁地重新构建目标。 在测试开始出现后,这种优势会变得更有说服力,因为更精细的目标意味着构建系统可以更智能,即仅运行可能会受任何给定更改影响的有限测试子集。由于 Google 相信使用较小目标的系统性好处,因此我们投资开发用于自动管理 BUILD 文件的工具,以避免加重开发者的负担,在缓解该负面影响方面取得了一些进步。

其中一些工具(例如 buildifierbuildozer)可通过 buildtools 目录中的 Bazel 获得。

最大限度地降低模块可见性

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

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

管理依赖项

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

内部依赖项

在分解为精细模块的大型项目中,大多数依赖项可能是内部依赖项;也就是说,在同一源代码库中定义和构建的其他目标上。内部依赖项与外部依赖项的不同之处在于,它们是从源代码构建而成,而不是在运行 build 时作为预构建工件下载。这也意味着,内部依赖项不存在“版本”的概念 - 目标及其所有内部依赖项始终在代码库中的同一提交/修订版本中构建。关于内部依赖项,应谨慎处理的一个问题是如何处理传递依赖项(图 1)。假设目标 A 依赖于目标 B,而目标 B 依赖于通用库目标 C。目标 A 是否应该能够使用目标 C 中定义的类?

传递依赖项

图 1. 传递依赖项

就底层工具而言,这没有问题;构建完成后,B 和 C 都会关联到目标 A,因此 A 已知 C 中定义的任何符号。多年来,Bazel 一直支持我们这样做,但随着 Google 的发展,我们开始发现一些问题。假设 B 经过重构,不再需要依赖于 C。如果 B 对 C 的依赖随后被移除,A 以及因依赖于 B 而使用 C 的任何其他目标都会中断。实际上,目标的依赖项已成为其公共协定的一部分,并且永远无法安全地更改。这意味着依赖项随着时间的推移逐渐累积,致使 Google 的构建速度开始变慢。

Google 最终通过在 Bazel 中引入“严格传递依赖项模式”解决了这个问题。在此模式下,Bazel 会检测目标是否尝试在不直接依赖符号的情况下引用该符号;如果尝试引用该符号,则会失败,并显示错误以及可用于自动插入依赖项的 shell 命令。在整个 Google 代码库中推行这项更改,并重构我们数百万个构建目标中的每一个,以明确列出其依赖项,这是一项需要多年的工作,但非常值得。由于目标具有的不必要依赖项更少,并且工程师能够移除不需要的依赖项,因此我们的构建速度现在要快得多,并且工程师无需担心破坏依赖于它们的目标。

与往常一样,强制执行严格的传递依赖项需要权衡利弊。这会使构建文件变得更加冗长,因为现在需要在许多位置明确列出常用的库,而不是意外拉取,工程师需要花费更多精力来向 BUILD 文件添加依赖项。此后,我们开发了一些工具,可以自动检测许多缺失的依赖项并将其添加到 BUILD 文件中,而无需开发者进行任何干预,从而减少这种重复劳动。但我们发现,即使没有此类工具,随着代码库规模的扩大,这种权衡是值得的:向 BUILD 文件明确添加依赖项需要一次性付出,但只要构建目标存在,处理隐式传递依赖项就可能会导致问题持续发生。默认情况下,Bazel 会对 Java 代码强制执行严格的传递依赖项

外部依赖项

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

自动与手动依赖项管理

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

自动管理的依赖项对于小型项目来说很方便,但对于规模庞大的项目或要由多个工程师处理的项目,它们通常会导致灾难。自动管理的依赖项的问题在于,您无法控制版本的更新时间。无法保证外部各方不会进行破坏性更新(即使他们声称使用语义版本控制),因此某天正常运行的 build 可能会在第二天出现问题,无法轻松检测更改的内容或将其回滚到正常运行状态。即使 build 没有中断,可能存在无法跟踪的细微行为或性能变化。

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

单一版本规则

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

允许多个版本的最大问题是菱形依赖项问题。假设目标 A 依赖于目标 B 和外部库的 v1。如果后来重构目标 B 以添加对同一外部库的 v2 的依赖项,目标 A 将中断,因为它现在隐式依赖于同一库的两个不同版本。实际上,将来自目标的新依赖项添加到具有多个版本的第三方库是有风险的,因为该目标的所有用户都可能依赖于不同的版本。遵循单一版本规则会使这种冲突不可能实现,如果目标添加对第三方库的依赖项,任何现有依赖项都将在该同一版本上,因此它们可以共存。

传递性外部依赖项

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

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

因此,Bazel 不会自动下载传递依赖项。而且,没有什么妙方,Bazel 的替代方案是需要一个全局文件,其中列出代码库的每个外部依赖项,以及整个代码库中用于该依赖项的显式版本。幸运的是,Bazel 提供的工具可以自动生成包含一组 Maven 工件的传递依赖项的文件。此工具可以运行一次来为项目生成初始 WORKSPACE 文件,然后您可以手动更新该文件以调整每个依赖项的版本。

同样,我们需要在便利性和可伸缩性之间做出选择。小型项目可能更不必费心自行管理传递依赖项,或许能够摆脱使用自动传递依赖项的麻烦。随着组织和代码库的扩大,这种策略的吸引力变得越来越低,冲突和意外结果变得更加频繁。在较大规模的情况下,手动管理依赖项的费用远低于处理自动依赖项管理引起的问题的费用。

使用外部依赖项缓存构建结果

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

但是,这也会带来大量开销和复杂性:需要有人负责构建每个工件并将它们上传到工件代码库,而客户端需要确保这些工件始终是最新版本。此外,调试难度也大大增加,因为系统将从仓库中的不同点构建系统的不同部分,而且不再具有一致的源代码树视图。

如前所述,如需解决构建工件需要很长时间的问题,一种更好的方法是使用支持远程缓存的构建系统。此类构建系统会将每个 build 生成的工件保存到工程师共享的位置。因此,如果开发者依赖于其他人最近构建的工件,构建系统会自动下载该工件,而不是进行构建。这样可提供直接依赖于工件的所有性能优势,同时仍可确保 build 与始终基于同一源代码构建的 build 保持一致。这是 Google 在内部使用的策略,Bazel 可以配置为使用远程缓存。

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

依赖第三方来源的工件本身就存在风险。如果第三方来源(如工件代码库)发生故障,则存在可用性风险,因为如果无法下载外部依赖项,您的整个构建可能会暂停。这还存在安全风险:如果攻击者入侵了第三方系统,攻击者可以将引用的工件替换为他们自己的设计,这样他们就可以将任意代码注入您的 build。将您依赖的所有工件镜像到由您控制的服务器,并阻止构建系统访问 Maven Central 等第三方工件代码库,即可缓解这两个问题。但弊端是,维护这些镜像需要花费精力和资源,因此是否使用它们通常取决于项目的规模。还可以要求在源代码库中指定每个第三方工件的哈希,从而以很少的开销完全阻止安全问题,从而导致在工件被篡改时构建失败。另一种完全规避问题的替代方案是提供项目的依赖项。当项目提供其依赖项时,它会以源代码或二进制文件的形式将其与项目的源代码一起签入源代码控制系统。这实际上意味着项目的所有外部依赖项都将转换为内部依赖项。Google 在内部使用此方法,将整个 Google 中引用的每个第三方库检查到 Google 源代码树根目录下的 third_party 目录中。不过,这只适用于 Google,因为 Google 的源代码控制系统是为处理超大型 monorepo 而构建的,因此并非所有组织都可以选择提供供应商。