依赖项管理

在浏览前面的页面时,有一个主题会反复讨论:管理您自己的代码相当简单,但管理其依赖项则困难得多。有各种依赖项:有时需要依赖某个任务(如“在将版本标记为完成之前推送文档”),有时又要依赖某个工件(如“我需要安装最新版本的计算机视觉库才能构建代码)。有时,您对代码库的其他部分具有内部依赖项,并且有时您依赖于代码或数据的外部依赖项归其他团队(无论是您的组织还是第三方)所有。但在任何情况下,“我需要自己具备这些能力”的理念是在构建系统的设计中反复出现,并且管理依赖项或许是最基本的工作构建系统

处理模块和依赖项

使用基于 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,可用于在buildtools目录中披露政府所要求信息的数量和类型。

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

Bazel 和其他构建系统允许每个目标指定可见性:一种属性,用于指定其他目标可以依赖于它。目标可以是公共的,在这种情况下,工作区中的任何其他目标都可以引用它们;私有,在这种情况下,只能从同一个 BUILD 文件中引用它们;或仅对其他目标明确定义的列表可见。可见性本质上与依赖项相反:如果目标 A 想要依赖目标 B,则目标 B 必须使其自身对目标 A 可见。与大多数编程语言一样,通常最好尽可能降低可见性。一般来说,仅当目标代表 Google 的任何团队广泛使用的库时,Google 团队才会公开目标。如果团队要求其他人在使用他们代码之前与他们协调,将维持一份允许的客户列表,作为目标适用的目标范围。每个团队的内部实现目标仅限于团队拥有的目录,并且大多数 BUILD 文件只有一个非公开目标。

管理依赖项

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

内部依赖项

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

传递依赖项

图 1. 传递依赖项

就底层工具而言,它没有任何问题; B 和 C 都会在构建时链接到目标 A,因此 C 中定义的任何符号都是已知的。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。

自动管理的依赖项对小型项目来说很方便,但对于大大小小的项目或由多个工程师处理的项目,该方案通常是灾难的秘诀。自动管理依赖项的问题在于,您无法控制版本更新时间。无法保证外部方不会进行重大更新(即使他们声称使用语义版本控制),因此,有时无法正常运作,导致一天正常运行的 build 可能被破坏或将其回滚到工作状态。即使构建不会中断,也可能出现无法跟踪的细微行为或性能变化。

相比之下,由于手动管理的依赖项需要更改源代码控制系统,您可以轻松地找到和回滚这些依赖项,并且可以签出旧版代码库以使用旧版依赖项构建。Bazel 要求手动指定所有依赖项的版本。即使在中等规模下,手动版本管理的开销也非常值得,因为这样可以获得稳定性。

单版本规则

不同版本的库通常由不同的工件表示,因此在理论上,没有必要在构建系统中使用不同名称声明同一外部依赖项的不同版本。这样,每个目标就可以选择要使用的依赖项的版本。这会导致实践中遇到许多问题,因此 Google 会为代码库中的所有第三方依赖项强制实施严格的单版本规则

允许多个版本的最大问题是 the 形依赖项问题。假设目标 A 依赖于目标 B 以及外部库的 v1。如果稍后重构目标 B 以添加对同一外部库的 v2 的依赖项,则目标 A 将会中断,因为它现在隐式依赖于同一库的两个不同版本。实际上,从目标向具有多个版本的第三方库添加新依赖项的做法是不安全的,因为该目标的任何用户都可能依赖于不同的版本。遵循单版本规则会使这种冲突无法实现。如果目标添加对第三方库的依赖关系,则所有现有依赖项将已经采用相同的版本,以便和谐共存。

传递性外部依赖项

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

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

因此,Bazel 不会自动下载传递依赖项。然而,并没有万能的办法,Bazel 的替代方案是,使用全局文件列出代码库的每个外部依赖项以及用于整个代码库的相应依赖项的显式版本。幸运的是,Bazel 提供了能够自动生成此类文件(其中包含一组 Maven 工件的传递依赖项)的工具。您可以运行该工具一次,以生成项目的初始 WORKSPACE 文件;然后,您可以手动更新该文件,以调整每个依赖项的版本。

再次强调,此处的选择是一种方便性和可伸缩性的选择。小型项目可能本身无需担心管理传递依赖项,并且可能无需使用自动传递依赖项。随着组织和代码库的增长,冲突和意外结果变得越来越频繁,此策略变得越来越没有吸引力。在较大的范围内,手动管理依赖项的费用远低于处理自动依赖项管理引起的问题的费用,

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

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

但是,这种方法也带来了很多开销和复杂性:您需要负责构建每个工件并将其上传到工件代码库,并且客户端需要确保及时更新 101}。由于系统的不同部分将从存储库中的不同点构建,并且没有源代码树的一致视图,调试也变得更困难。

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

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

依赖于第三方来源的工件本身存在风险。如果第三方源代码(例如工件代码库)发生故障,则会有可用性风险,因为如果无法下载外部依赖项,整个构建可能会停止。还有一种安全风险:如果第三方系统遭到攻击者入侵,攻击者可以将引用的工件替换为他们自己的设计之一,从而将任意代码注入到您的 build 中。中披露政府所要求信息的数量和类型。您依赖的任何工件镜像到您控制的服务器,并阻止构建系统访问 Maven Central 等第三方工件代码库,可以解决这两个问题。需要权衡的是,这些镜像需要维护和维护资源,因此,是否使用它们通常取决于项目的规模。您还可以在源代码库中指定每个第三方工件的哈希,以完全阻止安全问题,同时防止工件被破坏(如果工件被篡改)。完全绕过问题的另一个方法是提供项目的依赖项。当项目提供其依赖项时,它会将这些依赖项和项目的源代码(源代码或二进制文件)签入源代码控制系统。这实际上意味着,项目的所有外部依赖项都会转换为内部依赖项。Google 在内部使用此方法,将整个 Google 中引用的每个第三方库签入 Google 源代码树根目录下的 third_party 目录。但是,这仅在 Google 有效,因为 Google 的源代码控制系统是为了处理超大单一代码库而专门构建的,因此供应商可能不适合所有组织。