依赖项管理

报告问题 查看来源 每晚 · 7.2。 · 7.1敬上 · 7.0 · 6.5 · 6.4

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

处理模块和依赖项

使用基于工件的构建系统(如 Bazel)的项目可以分为一组 模块,其中模块通过 BUILD 表示相互依赖 文件。适当地组织这些模块和依赖项 对构建系统的性能的影响 维护。

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

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

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

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

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

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

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

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

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

管理依赖项

模块需要能够相互引用。打破 管理依赖项 (尽管工具可以帮助自动执行此操作)。表达这些内容 依赖项通常最终成为 BUILD 文件中的大部分内容。

内部依赖项

在拆分为细粒度模块的大型项目中,大多数依赖项 很可能是内部的;也就是说,在同一网站上定义和构建的另一个目标上 源代码库内部依赖项与外部依赖项的区别在于 它们是在源代码的基础上构建的,而不是作为预构建工件下载 。这也意味着,不存在“版本”的概念, 内部依赖项 - 目标及其所有内部依赖项始终 在代码库中的同一提交/修订版本上构建而成。一个应该解决的问题 谨慎处理内部依赖性问题时, 传递依赖项(图 1)。假设目标 A 依赖于目标 B 依赖于通用库目标 C。目标 A 应能够使用类 目标 C 中的定义?

传递依赖项

图 1. 传递依赖项

就底层工具而言,这不存在问题;两者都有 构建目标 A 时,B 和 C 将关联到目标 A,因此 C 是 A 已知的。多年来,Bazel 一直让这一切成为可能,但随着 Google 的发展,我们 开始发现问题。假设 B 经过重构,不再 必须依赖 C 语言。如果随后移除了 B 对 C 的依赖,则 A 和 通过对 B 的依赖项使用 C 的目标会中断。实际上,目标的 依赖项已成为其公共合同的一部分, 已更改。这意味着依赖项会随着时间的推移而累积,并在 Google 构建 开始变慢

Google 最终解决了这个问题,方法是引入“严格的及 依赖项模式”。在此模式下,Bazel 会检测目标是否尝试 在不直接依赖于某个符号的情况下,引用该符号;如果是,则会失败并显示 以及用于自动插入 依赖项。在 Google 的整个代码库和 重构了我们数百万构建目标中的每一个,以明确列出它们的 依赖项开发需要多年的努力,但物有所值。我们的版本 鉴于目标包含的不必要依赖项更少,并且速度快得多, 工程师有能力移除不需要的依赖项,而不必担心 如何破坏依赖于它们的目标。

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

外部依赖项

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

自动与手动依赖项管理

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

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

相比之下,由于手动管理的依赖项需要更改源代码, 很容易发现和回滚,而且可以 签出较旧版本的代码库以使用旧依赖项进行构建。 Bazel 要求手动指定所有依赖项的版本。均匀 中等规模,那么手动版本管理的开销对于 稳定性

单一版本规则

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

允许多个版本的最大问题是菱形依赖项 问题。假设目标 A 依赖于目标 B 以及外部 库。如果稍后重构目标 B,以添加对相同 外部库,目标 A 将会中断,因为它现在隐式依赖于两个 同一库的不同版本。实际上,添加 新依赖项添加到任何具有多个版本的第三方库; 因为该目标的任何用户可能都依赖于 版本。如果遵循单一版本规则,如果 target 添加对第三方库的依赖项,任何现有依赖项 已采用同一版本,因此可以愉快地共存。

传递外部依赖项

处理外部依赖项的传递依赖项可能是 尤其困难。Maven Central 等许多软件工件库 指定对特定版本的其他工件的依赖关系 代码库Maven 或 Gradle 等构建工具通常会以递归方式下载 传递依赖项,这意味着在代码中添加单个依赖项, 您的项目可能会导致系统通过 总计。

这非常方便:在添加新库的依赖项时, 跟踪这个库的每个传递依赖项带来的麻烦 然后手动添加所有标签但这也有一个巨大的缺点: 库可以依赖于同一个第三方库的不同版本, 策略必然违背了“一版法则”,并最终导致 依赖项问题。如果您的目标依赖于两个外部库,这些库使用 也无从得知您将哪个版本 get。这也意味着,更新外部依赖项可能会导致 如果新版本开始拉取,则整个代码库中会出现不相关的故障 某些依赖项的冲突版本。

因此,Bazel 不会自动下载传递依赖项。 而且,没有什么良方,Bazel 的替代方案是要求 这个全局文件会列出存储库的每个外部 以及用于该依赖项的显式版本 存储库幸运的是,Bazel 提供了能够自动 生成这样一个文件,其中包含一组 Maven 的传递依赖项 工件此工具可以运行一次,以生成初始 WORKSPACE 文件 之后您可以手动更新该文件,以调整其版本, 每个依赖项

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

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

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

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

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

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

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