在浏览前面的页面时,有一个主题反复出现:管理您自己的代码非常简单,但管理其依赖项要困难得多。依赖项有很多种:有时是任务依赖项(例如“在将版本标记为已完成之前推送文档”),有时是工件依赖项(例如“我需要拥有最新版本的计算机视觉库才能构建代码”)。有时,您会对代码库的其他部分有内部依赖项,有时会对其他团队(贵组织内的团队或第三方)拥有的代码或数据有外部依赖项。但无论如何,“我需要先获得这个东西”这种想法才会在构建系统的设计中反复出现,管理依赖项或许是构建系统最基本的任务。
处理模块和依赖项
使用 Bazel 等基于工件的构建系统的项目会拆分为一系列模块,模块通过 BUILD
文件表达彼此之间的依赖项。正确整理这些模块和依赖项对构建系统的性能和维护工作量有很大影响。
使用精细模块和 1:1:1 规则
在构建基于工件的 build 时,出现的第一个问题是确定单个模块应包含多少功能。在 Bazel 中,模块由指定可构建单元(例如 java_library
或 go_binary
)的目标表示。在一种极端情况下,只需将一个 BUILD
文件放在根目录下,并以递归方式将该项目的所有源文件全球化,即可将整个项目包含在单个模块中。在另一个极端,几乎每个源文件都可以被制作成自己的模块,这实际上要求每个文件在 BUILD
文件中列出它依赖的每个其他文件。
大多数项目都介于这两种极端情况之间,因此在选择时需要在性能和可维护性之间进行权衡。为整个项目使用单个模块可能意味着,除了添加外部依赖项外,您永远无需修改 BUILD
文件,但这意味着构建系统始终必须一次性构建整个项目。这意味着,它无法并行处理或分发 build 的部分,也无法缓存已构建的部分。“每个文件一个模块”则相反:构建系统在缓存和调度构建步骤方面具有极大的灵活性,但工程师每次更改哪些文件引用哪些文件时,都需要付出更多精力来维护依赖项列表。
虽然确切的粒度因语言而异(甚至在同一语言中也可能不同),但 Google 通常更倾向于使用比基于任务的构建系统中通常编写的模块小得多。Google 中典型的正式版二进制文件通常依赖于数万个目标,即使是中型团队,其代码库中也可能有数百个目标。对于 Java 等具有强大内置封装概念的语言,每个目录通常包含一个软件包、目标和 BUILD
文件(Pants,另一个基于 Bazel 的构建系统,将其称为 1:1:1 规则)。打包规范较弱的语言通常会为每个 BUILD
文件定义多个目标。
较小的 build 目标的好处在规模扩大后才会真正显现,因为它们可以加快分布式 build 速度,并减少重新构建目标的频率。在引入测试后,这些优势变得更加显著,因为更精细的目标意味着 build 系统可以更智能地运行可能受任何给定更改影响的测试的有限子集。由于 Google 坚信使用较小目标文件具有系统性优势,因此我们投资开发了自动管理 BUILD
文件的工具,以避免给开发者带来负担,从而取得了一些成效,减少了使用较小目标文件的弊端。
其中一些工具(例如 buildifier
和 buildozer
)可与 Bazel 一起在 buildtools
目录下提供。
尽量减少模块可见性
Bazel 和其他构建系统允许每个目标指定一个可见性,该属性用于确定哪些其他目标可能会依赖于它。专用目标只能在自己的 BUILD
文件中引用。一个目标可以向明确定义的 BUILD
文件列表中的目标授予更广泛的可见性,或者(在公开可见性的情况下)向工作区的每个目标授予更广泛的可见性。
与大多数编程语言一样,通常最好尽可能地降低可见性。通常,Google 团队仅在目标代表可供 Google 中的任何团队使用的广泛使用的库时,才会将目标设为公开。如果需要其他人在使用其代码之前进行协调,团队会保留一份客户目标许可名单,作为其目标的公开范围。每个团队的内部实现目标将仅限于该团队拥有的目录,并且大多数 BUILD
文件只有一个非私密目标。
管理依赖项
模块需要能够相互引用。将代码库拆分为精细模块的缺点在于,您需要管理这些模块之间的依赖项(不过,工具可以帮助您自动执行此操作)。表达这些依赖项通常会成为 BUILD
文件中的内容正文。
内部依赖项
在拆分为精细模块的大型项目中,大多数依赖项都可能是内部依赖项;也就是说,是在同一源代码库中定义和构建的其他目标上。内部依赖项与外部依赖项的不同之处在于,它们是从源代码构建的,而不是在运行 build 时作为预构建工件下载的。这也意味着,内部依赖项没有“版本”的概念,目标及其所有内部依赖项始终在代码库中构建于同一提交/修订。在处理内部依赖项时,需要谨慎处理的一个问题是如何处理传递依赖项(图 1)。假设目标 A 依赖于目标 B,而目标 B 依赖于公共库目标 C。目标 A 是否应能够使用目标 C 中定义的类?
图 1. 传递依赖项
从底层工具的角度来看,这没有任何问题;在构建目标 A 时,B 和 C 都会被链接到其中,因此 A 知道 C 中定义的任何符号。Bazel 多年来一直允许这样做,但随着 Google 的发展,我们开始发现问题。假设 B 已重构,因此不再需要依赖于 C。如果此时移除 B 对 C 的依赖项,则 A 以及通过对 B 的依赖项使用 C 的任何其他目标都会损坏。实际上,目标的依赖项成为其公共协定的一部分,并且永远无法安全地更改。这意味着,依赖项会随着时间的推移而累积,Google 的 build 速度也开始变慢。
Google 最终在 Bazel 中引入了“严格的传递依赖项模式”,从而解决了这个问题。在此模式下,Bazel 会检测目标是否尝试引用符号但未直接依赖于它。如果是,则会失败并显示错误消息,以及可用于自动插入依赖项的 shell 命令。我们花了数年时间,在 Google 的整个代码库中推行这项变更,并重构了数百万个 build 目标,以明确列出它们的依赖项,但这一切都是值得的。由于目标的非必要依赖项更少,我们的构建速度现在快了很多,工程师可以移除不需要的依赖项,而无需担心破坏依赖于这些依赖项的目标。
一如既往,强制执行严格的传递依赖项需要做出权衡。这会使 build 文件更加冗长,因为现在需要在许多地方明确列出常用的库,而不是附带拉入,并且工程师需要花更多的精力向 BUILD
文件添加依赖项。此后,我们开发了多项工具,可自动检测许多缺失的依赖项并将其添加到 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 的一致性,就像它们始终是从同一源代码构建的一样。这是 Google 内部使用的策略,并且 Bazel 可以配置为使用远程缓存。
外部依赖项的安全性和可靠性
依赖于第三方来源的工件本身就存在风险。如果第三方来源(例如工件仓库)发生故障,则会存在可用性风险,因为如果整个 build 无法下载外部依赖项,则可能会停滞不前。这还存在安全风险:如果第三方系统遭到攻击者入侵,攻击者可能会将引用的工件替换为自己设计的工件,从而将任意代码注入您的 build。这两种问题都可以通过以下方式缓解:将您依赖的任何工件镜像到您控制的服务器,并阻止构建系统访问 Maven Central 等第三方工件代码库。但缺点是,这些镜像需要投入精力和资源进行维护,因此是否使用这些镜像通常取决于项目的规模。您还可以要求在源代码库中指定每个第三方工件的哈希值,以便在工件被篡改时导致构建失败,从而以很少的开销完全防止安全问题。另一种完全规避此问题的方法是将项目的依赖项作为供应商提供。当项目封装其依赖项时,会将其作为源代码或二进制文件与项目的源代码一起签入源代码控制系统。这实际上意味着,项目的所有外部依赖项都会转换为内部依赖项。Google 在内部使用此方法,会将 Google 中引用的每个第三方库签入 Google 源代码树根目录下的 third_party
目录。但是,Google 之所以能采用这种做法,只是因为 Google 的源代码控制系统是为处理超大型 monorepo 而定制的,因此并非所有组织都可以使用 vendor。