依赖项管理

在浏览前面几页时,会反复发现一个主题:管理您自己的代码相当简单,但管理其依赖项要困难得多。依赖项有很多种:有时某项任务存在依赖性(例如“在将某个版本标记为完成之前推送文档”),有时也需要依赖某个工件(例如“我需要使用最新版本的计算机视觉库来构建我的代码”)。有时,您的内部依赖项依赖于其他组织代码的其他部分,或者某个第三方拥有外部依赖项。但无论在什么情况下,“我需要先做到这一点”这一理念在构建系统的设计中会反复出现,而管理依赖项可能是构建系统最基本的工作。

处理模块和依赖项

使用 Bazel 等基于工件的构建系统的项目可拆分为一组模块,各个模块通过 BUILD 文件相互表示依赖关系。这些模块和依赖项的适当组织会对构建系统的性能和维护所需的工作量产生重大影响。

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

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

大多数项目都处于这两种极端情况之间,因此选择时需要在性能与可维护性之间进行权衡取舍。针对整个项目使用单个模块可能意味着除了添加外部依赖项时绝不需要处理 BUILD 文件,但这意味着构建系统必须始终一次性构建整个项目。这意味着它将无法并行处理或分发 build 的某些部分,也无法缓存已构建的部分。每个文件一个模块则相反:构建系统在缓存和安排构建步骤方面具有最大的灵活性,但每当工程师更改引用的文件时,都需要投入更多精力来维护依赖项列表。

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

较小的构建目标的优势已经开始大规模地显现,因为它们有助于实现更快的分布式构建,并且不需要重新构建目标。在测试开始后,优势会变得更加引人注目,因为更精细的目标意味着构建系统可以更智能,即仅运行可能会受任何给定更改影响的有限部分测试。由于 Google 相信使用较小目标能够带来系统性好处,因此我们投资开发了用于自动管理 BUILD 文件的工具,在缓解该不利问题方面取得了长足的进展,以避免给开发者带来负担。

其中一些工具(例如 buildifierbuildozer)可以与 Bazel 的 buildtools 目录搭配使用。

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

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

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

管理依赖项

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

内部依赖项

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

传递依赖项

图 1. 传递依赖项

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

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

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

外部依赖项

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

自动与手动依赖项管理

构建系统可以允许手动或自动管理外部依赖项的版本。手动管理时,构建文件通常会使用语义版本字符串(例如 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 保持一致,就像它们始终从同一源代码构建一样。这是 Google 在内部使用的策略,Bazel 可以配置为使用远程缓存。

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

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