依赖项管理

报告问题 查看源代码 每夜 build · 7.4 . 7.3 · 7.2 · 7.1 · 7.0 · 6.5

在浏览前面的页面时,有一个主题反复出现:管理您自己的代码非常简单,但管理其依赖项要困难得多。存在各种依赖关系:有时 任务依赖项(例如“在将某个版本标记为 有时还依赖于某个工件(例如,“我需要 使用最新版本的计算机视觉库来构建代码”)。 有时,您在代码库的其他部分存在内部依赖项, 有时,您在外部依赖于其他团队拥有的代码或数据 (无论是贵组织还是第三方)。但无论如何,“我 “需要先有条件,然后才能获得这个东西” 构建系统设计,以及管理依赖项可能是 是构建系统的基本任务

处理模块和依赖项

使用 Bazel 等基于工件的构建系统的项目会拆分为一系列模块,模块通过 BUILD 文件表达彼此之间的依赖关系。正确整理这些模块和依赖项对构建系统的性能和维护工作量有很大影响。

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

在构建基于工件的 build 时,首先要解决的问题是确定单个模块应包含多少功能。在 Bazel 中 模块由一个目标表示,该目标用于指定可编译的单元,例如 java_librarygo_binary。一种极端情况就是,整个项目 包含在单个模块中,方法是将一个 BUILD 文件放在根目录下, 以递归方式将该项目的所有源文件汇集在一起。在另一个极端,几乎每个源文件都可以被制作成自己的模块,这实际上要求每个文件在 BUILD 文件中列出它依赖的每个其他文件。

大多数项目都介于这些极端之间,选择采用 性能和可维护性之间的权衡。针对 整个项目可能意味着你永远无需处理 BUILD 文件, 但这意味着构建系统必须 始终一次构建整个项目。这意味着,它无法并行处理或分发 build 的部分,也无法缓存已构建的部分。“每个文件一个模块”则相反:构建系统在缓存和调度构建步骤方面具有极大的灵活性,但工程师每次更改哪些文件引用哪些文件时,都需要花费更多精力来维护依赖项列表。

虽然确切的粒度因语言而异(甚至在同一语言中也可能不同),但 Google 通常更青睐比基于任务的构建系统中通常编写的模块小得多。Google 中典型的正式版二进制文件通常依赖于数万个目标,即使是中型团队,其代码库中也可能有数百个目标。对于 具有强大内置打包概念的 Java,通常每个目录 包含单个软件包、目标和 BUILD 文件(Pants、另一个构建系统 将它称为 1:1:1 规则)。打包强度较弱的语言 惯例通常会为每个 BUILD 文件定义多个目标。

较小的 build 目标的好处在规模扩大后才会真正显现,因为它们可以加快分布式 build 速度,并减少重新构建目标的频率。经过测试后,这些优势就显得更有说服力了,因为 更精细的目标意味着构建系统可以更智能 只运行可能受特定规则影响的一小部分测试 更改。因为 Google 深信,使用规模较小的应用 我们已经取得了一些进展,通过投资 工具自动管理 BUILD 文件,以避免开发者的负担。

buildifierbuildozer 等一些工具支持 Deployment 规范中的 buildtools 目录

尽量减少模块可见性

Bazel 和其他构建系统允许每个目标指定一个可见性,该属性用于确定哪些其他目标可能会依赖于它。私有目标只能在其自己的 BUILD 文件中引用。目标可能会更广泛地授予 对明确定义的 BUILD 文件列表的目标可见,或在 向工作区中的每个目标公开。

与大多数编程语言一样,通常最好将可见性降至最低, 尽可能多。一般来说,只有在以下情况下,Google 团队才会公开目标: 这些目标代表了广泛使用的库,可供 Google 的任何团队使用。 如果团队在使用代码前需要他人进行协调, 保留一份客户目标的许可名单,作为其目标的公开范围。每个 团队的内部实施目标仅限目录 归团队所有,而且大多数 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 构建 开始变慢

Google 最终在 Bazel 中引入了“严格的传递依赖项模式”,从而解决了这个问题。在此模式下,Bazel 会检测目标是否尝试引用符号但未直接依赖于它。如果是,则会失败并显示错误消息,以及可用于自动插入依赖项的 shell 命令。在 Google 的整个代码库和 重构了我们数百万构建目标中的每一个,以明确列出它们的 依赖项开发需要多年的努力,但物有所值。由于目标的非必要依赖项更少,我们的构建速度现在快了很多,工程师可以移除不需要的依赖项,而无需担心破坏依赖于这些依赖项的目标。

与往常一样,强制执行严格的传递依赖项需要做出权衡。它带来了 更冗长的 build 文件,因为现在需要列出常用的库 而不是无意间拉入,而工程师 需要花费更多精力向 BUILD 文件添加依赖项。从那时起, 开发了多项工具,通过自动检测许多缺少 依赖项,并在没有任何开发者的情况下将它们添加到 BUILD 文件中 进行干预。不过,我们发现,即使没有此类工具,随着代码库的扩展,这种权衡也是值得的:向 BUILD 文件显式添加依赖项是一笔一次性开销,但只要 build 目标存在,处理隐式传递依赖项就可能会导致持续存在的问题。Bazel 会强制执行严格的传递依赖项 Java 代码。

外部依赖项

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

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

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

自动管理的依赖项对于小型项目很方便, 它们往往会给规模不大的项目带来灾难 并由多个工程师共同处理。使用自动 因此,您无法控制 已更新。无法保证外部相关方不会造成破坏 更新(即使他们声称使用语义版本控制), 某天有效,第二天就可能损坏,没有简单的方法可以检测 或将其回滚到工作状态。即使构建没有中断, 可能是难以跟踪的细微行为或性能变化。

相比之下,由于手动管理的依赖项需要更改源代码, 很容易发现和回滚,而且可以 签出旧版代码库以使用旧版依赖项进行构建 Bazel 要求手动指定所有依赖项的版本。即使在规模适中的情况下,手动版本管理带来的开销也值得,因为它可以提供稳定性。

单版本规则

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

允许使用多个版本时最大的问题是钻石依赖项问题。假设目标 A 依赖于目标 B 和外部库的 v1。如果稍后重构目标 B,以添加对相同 外部库,目标 A 将会中断,因为它现在隐式依赖于两个 同一库的不同版本。实际上,从目标添加到具有多个版本的任何第三方库的新依赖项从来都不安全,因为该目标的任何用户都可能已经依赖于其他版本。遵循“一个版本”规则可避免此类冲突:如果目标添加了对第三方库的依赖项,则所有现有依赖项都将采用相同的版本,因此它们可以愉快地共存。

传递外部依赖项

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

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

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

同样,这里的选择是在便利性和可伸缩性之间做出选择。小型项目可能不希望自行管理传递依赖项,并且可能能够使用自动传递依赖项。随着组织和代码库不断扩大,这种策略的吸引力越来越小,冲突和意外结果也越来越频繁。在较大的规模下,手动管理依赖项的成本非常高, 低于处理自动依赖项所导致的问题的费用 管理。

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

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

不过,这也带来了大量开销和复杂性:需要有人 负责构建所有这些工件并将其上传至 工件库,而客户则需确保 最新版本。调试也变得更加困难,因为 整个系统的各个部分将根据 并且源代码树不再是一致的视图。

如前所述,解决工件构建时间过长问题的更好方法是使用支持远程缓存的构建系统。这种 构建系统将每次构建中生成的工件保存到某个位置 因此,如果开发者依赖于某个工件, 由其他人最近构建,则构建系统会自动下载 而不是构建它。这可提供 直接依赖于工件,同时仍能确保 就像它们始终基于同一源代码构建一样。这是 策略,而 Bazel 可配置为使用远程 缓存。

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

依赖来自第三方来源的工件本身就存在风险。这里有 如果第三方来源(例如工件代码库) 因为如果无法下载,整个 build 可能会停滞不前 外部依赖项这样做还存在安全风险 被攻击者破解后,攻击者可以用其替换 具有自己的设计的工件,允许它们注入任意代码 添加到您的 build 中。您可以通过将您依赖的所有工件镜像到您控制的服务器,并阻止构建系统访问 Maven Central 等第三方工件仓库,来缓解这两个问题。但缺点是,这些镜像需要投入精力和资源进行维护,因此是否使用这些镜像通常取决于项目的规模。安全问题 可以完全阻止每个 Pod 的哈希值 在源代码库中指定的第三方工件会导致构建 在工件被篡改时失败。另一种完全规避此问题的方法是将项目的依赖项作为供应商提供。当项目封装其依赖项时,会将其作为源代码或二进制文件与项目的源代码一起签入源代码控制系统。这实际上意味着 确保项目的所有外部依赖项都会转换为内部依赖项 依赖项Google 在内部使用这种方法, 将 Google 中引用的库放入根目录下的 third_party 目录中 源代码树的一种结构不过,这在 Google 上之所以可行,是因为 Google 的源代码控制系统是专门用于处理超大型单仓库的,因此供应商模式可能并不适合所有组织。