依赖项管理

报告问题 查看源代码

在浏览前几页内容时,您会重复遇到一个主题:管理您自己的代码相当简单,但管理其依赖项要困难得多。依赖项有各种类型:有时依赖于任务(例如“在将版本标记为完成之前推送文档”),有时依赖于工件(例如“我需要有最新版本的计算机视觉库才能构建我的代码”)。有时,您的代码库具有其他部分(或某个代码位于您的外部组织中,或拥有某个组织内部的第三方代码)。但无论如何,“我需要先了解这项功能”这一想法在构建系统的设计中反复出现,而管理依赖项可能是构建系统最基本的工作。

处理模块和依赖项

使用基于工件的构建系统(例如 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)在 buildtools 目录中可用于 Bazel。

最大限度地降低模块可见性

Bazel 和其他构建系统允许每个目标指定一个可见性,此属性确定哪些其他目标可能依赖于可见性。专用目标只能在自己的 BUILD 文件中引用。目标可以更明确地显示明确定义的 BUILD 文件列表的目标,在公开公开的情况下,还可以向工作区中的每个目标公开目标。

与大多数编程语言一样,通常最好尽可能减少可见性。通常,只有当这些目标代表可供 Google 的任何团队使用的广泛库时,Google 的团队才会公开这些目标。如果团队需要其他人协调才能使用代码,那么该团队将维护一个将目标作为目标对象的许可名单。每个团队的内部实现目标将仅限于该团队拥有的目录,并且大多数 BUILD 文件都只有一个非公开的目标。

管理依赖项

各个模块需要相互引用。将代码库拆分为精细控制的缺点是,您需要管理这些模块之间的依赖关系(不过,工具有助于自动执行此过程)。表达这些依赖项通常只是 BUILD 文件中的大部分内容。

内部依赖项

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

传递依赖项

图 1. 传递依赖项

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

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

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

外部依赖项

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

自动依赖项管理与手动依赖项管理

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

自动管理的依赖项对小型项目来说很方便,但对于大型项目或由多个工程师处理的项目而言,它们通常是灾难的配方。自动管理的依赖项存在一个问题,那就是您无法控制版本何时更新。无法保证外部方不会进行重大更新(即使它们声称使用语义版本控制),因此一天运行的 build 可能会在第二天发生故障,而无法轻松检测更改的内容或将其回滚到正常运行状态。即使构建不中断,也可能出现无法跟踪的细微行为或性能变化。

相反,由于手动管理的依赖项需要更改源代码控制,因此可以轻松发现和回滚这些依赖项,并且可以签出旧版代码库以使用旧版依赖项进行构建。Bazel 要求手动指定所有依赖项的版本。即使规模适中,手动版本管理的开销也足以实现其稳定性。

单一版本规则

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

允许多个版本的最大问题是菱形依赖项问题。假设目标 A 依赖于目标 B 和外部库的 v1。如果目标 B 之后被重构以添加对同一外部库 v2 的依赖项,则目标 A 将发生破坏,因为它现在隐式依赖于同一库的两个不同版本。实际上,将任何目标中的新依赖项添加到任何具有多个版本的第三方库并不安全,因为此目标的任何用户可能已依赖于其他版本。遵循 One-Version 规则可防止出现此冲突 - 如果目标添加对第三方库的依赖项,则任何现有依赖项都将已在同一版本上,因此可以愉快地共存。

传递外部依赖项

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

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

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

同样,这里的选择是在便利性和可伸缩性之间进行选择。小项目可能不希望自己自行管理传递依赖项,或许可以使用自动传递依赖项来解决问题。随着组织和代码库的发展,这种策略会变得越来越有吸引力,冲突和意外结果也越来越频繁。在更大的范围内,手动管理依赖项的费用远低于处理由自动依赖项管理导致的问题的费用。

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

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

但是,这也会带来很大的开销和复杂性:有人需要负责构建每个工件并将其上传到工件代码库,而客户端需要确保这些工件是最新版本。调试也会变得非常困难,因为系统将从代码库中的不同点构建系统的不同部分,并且源代码树不再一致。

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

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

依赖于来自第三方来源的工件本身就存在风险。如果第三方来源(例如工件代码库)出现服务中断情况,则会面临可用性风险,因为如果无法下载外部依赖项,您的整个 build 可能会停止运行。这也存在安全风险:如果第三方系统遭到攻击者入侵,攻击者可以将引用的工件替换为自己的设计,从而将任意代码注入到您的 build。通过将您依赖的所有工件镜像到您控制的服务器,并阻止您的构建系统访问第三方工件代码库(如 Maven Central),可以缓解这两个问题。需要权衡的是,这些镜像需要花费精力和资源来维护,因此是否使用它们通常取决于项目的规模。通过要求在源代码库中指定每个第三方工件的哈希,导致构建失败,如果该工件遭到篡改,也可以完全避免安全问题。另一种完全规避问题的替代方法是提供项目的依赖项。当项目提供其依赖项时,它会将项目与源代码一起签入源代码控制系统(作为源代码或二进制文件)。这实际上意味着项目的所有外部依赖项都会转换为内部依赖项。Google 在内部使用此方法,将整个 Google 中引用的每个第三方库检查到 Google 源代码树的根目录下的 third_party 目录中。但是,这仅适用于 Google,因为 Google 的源代码控制系统是专为处理超大单声道代码库而定制的,因此并非所有组织都可以选择供应商。