依赖项管理

报告问题 查看源代码

通过查看前面的页面,您会发现一个主题一再重复:管理您自己的代码相当简单,但管理其依赖项要困难得多。依赖项有各种各样:有时依赖于一项任务(例如“在将版本标记为完成之前推送文档”),有时还依赖于某个工件(例如“我需要最新版计算机视觉库来构建我的代码”)。有时,内部依赖项依赖于您组织的外部代码,或者依赖于您组织的外部代码。但无论如何,“我需要先获得这个东西”这种想法才会在构建系统的设计中反复出现,管理依赖项或许是构建系统最基本的任务。

处理模块和依赖项

使用基于工件的构建系统(如 Bazel)的项目被分解为一组模块,模块通过 BUILD 文件表示彼此的依赖关系。正确组织这些模块和依赖项会对构建系统的性能及其维护所需的工作量产生巨大影响。

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

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

大多数项目都介于这些极端之间,选择时需要在性能和可维护性之间进行权衡。为整个项目使用单个模块可能意味着除了添加外部依赖项时,您永远无需处理 BUILD 文件,但这意味着构建系统始终必须一次构建整个项目。这意味着它无法并行处理或分布 build 的各个部分,也无法缓存已构建的部分。相反,每个文件只能有一个模块:构建系统在缓存和安排构建步骤方面具有极大的灵活性,但每当更改哪些文件引用哪个文件时,工程师都需要花费更多精力来维护依赖项列表。

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

较小的 build 目标的优势已经开始显现出规模化优势,因为它们可以提高分布式 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,因此 C 中定义的任何符号都是 A 的已知符号。多年来,Bazel 一直允许这种做法,但随着 Google 的发展,我们发现了一些问题。假设 B 经过重构,不再需要依赖于 C。如果随后取消了 B 对 C 的依赖,A 以及通过依赖 B 而使用 C 的任何其他目标都将中断。实际上,目标的依赖项已成为其公共合同的一部分,并且永远无法安全地更改。这意味着依赖项的积累会随时间的推移而不断积累,Google 的构建速度也开始变慢。

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

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

外部依赖项

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

自动与手动依赖项管理

构建系统可以允许手动或自动管理外部依赖项的版本。手动管理时,build 文件会明确列出要从工件仓库下载的版本,通常使用语义版本字符串,例如 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 始终与采用同一源代码构建时一样一致。这是 Google 在内部使用的策略,您可以将 Bazel 配置为使用远程缓存。

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

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