依赖项管理

报告问题 查看来源 每晚 · 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 文件中的大部分内容。

内部依赖项

在拆分为细粒度模块的大型项目中,大多数依赖项 很可能是内部的;也就是说,在同一网站上定义和构建的另一个目标上 源代码库内部依赖项与外部依赖项的不同之处在于,它们是从源代码构建的,而不是在运行 build 时作为预构建工件下载的。这也意味着,不存在“版本”的概念, 内部依赖项 - 目标及其所有内部依赖项始终 在代码库中的同一提交/修订版本上构建而成。关于内部依赖项,一个需要谨慎处理的问题是如何处理传递依赖项(图 1)。假设目标 A 依赖于目标 B,而目标 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 文件中,而无需任何开发者干预。不过,我们发现,即使没有此类工具,随着代码库的扩展,这种权衡也是值得的:向 BUILD 文件显式添加依赖项是一笔一次性开销,但只要 build 目标存在,处理隐式传递依赖项就可能会导致持续存在的问题。Bazel 会强制执行严格的传递依赖项 Java 代码。

外部依赖项

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

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

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

自动管理的依赖项对小型项目来说可能很方便,但对于规模较大或由多名工程师共同开发的项目来说,它们通常会造成灾难性后果。自动管理的依赖项存在的问题是,您无法控制版本更新的时间。我们无法保证外部方不会发布破坏性更新(即使他们声称使用语义版本控制),因此昨天正常运行的 build 可能在明天就无法正常运行,而且没有简单的方法来检测发生了哪些变化或将其回滚到正常运行状态。即使 build 没有崩溃,也可能会出现无法跟踪的细微行为或性能变化。

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

单版本规则

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

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

传递外部依赖项

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

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

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

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

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

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

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

要解决构建耗时较长的工件问题,一个更好的方法是 如前所述,请使用支持远程缓存的构建系统。这样的构建系统会将每次构建的生成工件保存到工程师共享的位置,因此,如果开发者依赖于其他人最近构建的工件,构建系统会自动下载该工件,而不是构建它。这样可以获得直接依赖工件的所有性能优势,同时确保 build 的一致性,就像它们始终是从同一源代码构建的一样。这是 Google 内部使用的策略,并且 Bazel 可以配置为使用远程缓存。

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

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