依赖项管理

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

处理模块和依赖项

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

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

在构建基于工件的构建系统时,首先要考虑的问题是 单个模块应包含多少功能。在 Bazel 中, 模块由指定可构建单元(如 java_librarygo_binary)的目标表示。在一种极端情况下,整个项目可以 包含在单个模块中,方法是在根目录中放置一个 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 文件中的大部分内容。

内部依赖项

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

传递依赖项

图 1. 传递依赖项

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

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

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

外部依赖项

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

自动与手动依赖项管理

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

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

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

单版本规则

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

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

传递外部依赖项

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

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

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

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

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

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

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

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

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

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