译|Dependency Management


  1. 应对模块和依赖关系
  2. 使用精细模块和 1:1:1 规则
  3. 最小化模块可见性
  4. 管理依赖项
    1. 内部依赖项
    2. 外部依赖项
    3. 自动 vs 手动 管理依赖项
    4. 单一版本规则
    5. 可传递外部依赖关系
    6. 使用外部依赖关系缓存构建结果
    7. 外部依赖的安全性和可靠性

译者序

本文为 Bazel 依赖管理的文章,介绍了大规模下依赖关系的复杂情况及其应对策略。从本文可以学到什么?

  1. 了解构建系统依赖管理的基本情况
  2. 理解 Golang 内置的构建工具和后续的发展方向
  3. 版本如何影响公司内部基础架构升级

在浏览前面的页面时,有一个主题反复提及:管理自己的代码相当简单,但管理其依赖关系则困难得多。存在各种各样的依赖关系:有时依赖于某个任务(如“将版本标记为完成之前推送文档”);有时依赖于某个制品(如“需要最新版本计算机视觉库才能构建代码);有时,内部依赖于代码库的另一部分,并且有时外部依赖于其他团队(无论是组织内还是第三方)的代码或数据。但无论如何,“欲此先彼”的观念在构建系统的设计中反复出现,管理依赖关系或许是构建系统最基本的工作

应对模块和依赖关系

使用基于制品的构建系统(如Bazel)的项目被分解为一组模块,模块通过 BUILD 文件表示彼此之间的依赖关系。正确组织模块和依赖关系会对构建系统的性能以及维护工作量产生巨大影响。

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

在组织基于工件的构建时,第一个问题是确定单个模块应该包含多少功能。在 Bazel 中,模块由描述说明可构建单元(如 java_library 或 go_binary)的目标表示。 一种极端情况下,整个项目可以包含在一个模块中,方法是将一个 BUILD 文件放在根目录下,并以递归方式将该项目的所有源文件合并在一起。另外一种极端情况下,几乎每个源文件都可以放到自己的模块中,实质上要求每个文件在 BUILD 文件中列出它所依赖的其他所有文件

大多数项目都处于极端情况之间,选择涉及性能和可维护性之间的权衡。整个项目使用一个模块意味着,除了从外部添加依赖项之外,再不需要更改 BUILD 文件,但构建系统必须始终一次性构建整个项目。这意味着它无法将各部分并行或分布式构建,也无法缓存已构建的部分。每个文件一个模块则相反:构建系统在构建的缓存和调度步骤方面具有最大的灵活性,但每当更改文件引用文件时,工程师需要花费更多精力来维护依赖项列表。

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

较小的构建目标的好处在大规模时开始显现出来,因为它们可以加快分布式构建的速度,减少重建目标的频率。 测试入场后,优势变得更加引人注目,因为更细粒度的目标意味着构建系统可以更智能地运行可能受给定更改影响的有限测试子集。由于 Google 认为使用较小的目标具有系统方面的优势,因此我们通过投资自动管理 BUILD 文件的工具,以避免给开发人员带来负担,从而在减轻不利影响方面迈出了一大步。

其中一些工具,如 buildizerbuildozer,可以放在 buildtools 目录中与 Bazel 一起使用

最小化模块可见性

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

管理依赖项

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

内部依赖项

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

transitive-dependencies.png
图 1. 可传递依赖项

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

Google 最终在 Bazel 中引入了“严格可传递依赖关系模式”,从而解决了此问题。在此模式下,Bazel 会检测目标是否试图直接引用符号,而不依赖于它;如果是的话,则失败,并显示错误以及一条可用于自动插入依赖项的 shell 命令。在 Google 的整个代码库中推广这一变化,并重构数百万个构建目标,以明确列出它们的依赖项,该项目花费了多年的努力,但非常值得。由于目标中不必要依赖项减少,现在构建要快得多。而且,工程师有权删除他们不需要的依赖项,而不用担心破坏依赖它们的目标。

与往常一样,强制执行严格的可传递依赖关系需要做出权衡。因为现在经常使用的库需要在许多位置显式列出,而不是被意外地拉取,使得构建文件更详细;而工程师需要花费更多精力在 BUILD 文件中添加依赖项。此后,我们开发了相关工具,可在不进行任何开发者干预的情况下,自动检测许多缺失的依赖项并将其添加到 BUILD 文件,从而减少此类繁重工作。但即使没有此类工具,我们也发现,在代码库扩大规模的情况下这样做非常值得:显式地将依赖项添加到构建文件是一次性的成本,但只要构建目标存在,处理隐式可传递依赖关系就会导致持续的问题。默认情况下,Bazel 会在 Java 代码中强制执行严格可传递依赖关系

外部依赖项

如果依赖项不是内部依赖项,它一定是外部依赖项。外部依赖项是指在构建系统之外构建和存储的制品。系统直接从制品库(通常通过互联网访问)导入依赖项,并按原样使用,而不是从源代码构建。外部依赖项与内部依赖项之间的最大差异之一是,外部依赖项有版本,并且版本独立于项目的源代码。

自动 vs 手动 管理依赖项

构建系统可以手动或自动管理外部依赖项的版本。手动管理时,构建文件会明确列出要从制品库下载的版本,通常使用 1.1.4 等语义版本字符串。自动管理时,源文件会指定可接受版本的范围,而构建系统始终会下载最新版本。例如,Gradle 将依赖项版本声明为“1.+”,以指定依赖项的主版本或补丁版本可以接受,前提是主版本为 1。

对小型项目来说,自动管理依赖项很方便,但它们通常是非一般规模的项目或由多个工程师处理的项目的灾难。自动管理依赖项的问题在于,无法控制版本更新。无法保证外部一方不会进行中断性的更新(即使他们声称使用语义化版本),因此,某一天工作过的构建版本可能会在第二天就被破坏,并且没有简单的方法来检测更改的内容或将其回滚到工作状态。即使构建不会中断,也可能出现无法跟踪的细微的行为或性能变化。

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

单一版本规则

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

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

可传递外部依赖关系

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

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

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

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

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

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

但是,这种方法也带来了很多开销和复杂性:需要负责构建每个制品并将其上传到制品库,并且客户需要确保自身保持最新版本。调试也变得更加困难,因为系统的不同部分是从存储库中的不同点构建的,并且不再有源代码库树的一致视图。

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

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

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

原文: Dependency Management

本文作者:cyningsun
本文地址https://www.cyningsun.com/06-08-2022/dependency-management-cn.html
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-ND 3.0 CN 许可协议。转载请注明出处!