在浏览前面的页面时,有一个主题反复出现:管理您自己的代码非常简单,但管理其依赖项要困难得多。依赖项有很多种:有时是任务依赖项(例如“在将版本标记为已完成之前推送文档”),有时是工件依赖项(例如“我需要拥有最新版本的计算机视觉库才能构建代码”)。有时,您会对代码库的其他部分有内部依赖项,有时会对其他团队(您组织中的团队或第三方)拥有的代码或数据有外部依赖项。但无论如何,“我 “需要先有条件,然后才能获得这个东西” 构建系统设计,以及管理依赖项可能是 是构建系统的基本任务
处理模块和依赖项
使用 Bazel 等基于工件的构建系统的项目会拆分为一系列模块,模块通过 BUILD
文件表达彼此之间的依赖关系。适当地组织这些模块和依赖项
对构建系统的性能的影响
维护。
使用精细化模块和 1:1:1 规则
构建基于工件的构建时,出现的第一个问题是
确定单个模块应包含多少功能。在 Bazel 中,模块由指定可构建单元(例如 java_library
或 go_binary
)的目标表示。一种极端情况就是,整个项目
包含在单个模块中,方法是将一个 BUILD
文件放在根目录下,
以递归方式将该项目的所有源文件汇集在一起。在另一个
几乎每个源文件都可以制作成自己的模块,
要求每个文件在 BUILD
文件中列出所依赖的每个文件。
大多数项目都介于这两种极端情况之间,因此在选择时需要在性能和可维护性之间进行权衡。针对
整个项目可能意味着你永远无需处理 BUILD
文件,
但这意味着构建系统必须
始终一次构建整个项目。这意味着它将无法
并行处理或分布 build 的各个部分,也无法缓存部分
它已经构建好了。一个模块是另一个模块:构建系统
在缓存和安排构建步骤方面具有极大的灵活性,
无论何时,
它们会更改哪些文件引用哪些文件
尽管确切的粒度因语言而异(通常甚至在
语言),Google 更倾向于使用比普通模块小得多的模块
通常在基于任务的构建系统中编写。Google 中典型的正式版二进制文件通常依赖于数万个目标,即使是中型团队,其代码库中也可能有数百个目标。对于
具有强大内置打包概念的 Java,通常每个目录
包含单个软件包、目标和 BUILD
文件(Pants、另一个构建系统
将它称为 1:1:1 规则)。打包强度较弱的语言
惯例通常会为每个 BUILD
文件定义多个目标。
较小的 build 目标的好处在规模扩大后才会真正显现,因为它们可以加快分布式 build 速度,并减少重新构建目标的频率。经过测试后,这些优势就显得更有说服力了,因为
更精细的目标意味着构建系统可以更智能
只运行可能受特定规则影响的一小部分测试
更改。由于 Google 相信使用较小目标文件具有系统性优势,因此我们投资开发了自动管理 BUILD
文件的工具,以避免给开发者带来负担,从而取得了一些成效,减少了使用较小目标文件的弊端。
buildifier
和 buildozer
等一些工具支持
Deployment 规范中的
buildtools
目录。
尽量减少模块可见性
Bazel 和其他构建系统允许每个目标指定可见性,即
属性来确定哪些其他目标可能依赖于它。私有目标只能在其自己的 BUILD
文件中引用。目标可能会更广泛地授予
对明确定义的 BUILD
文件列表的目标可见,或在
向工作区中的每个目标公开。
与大多数编程语言一样,通常最好将可见性降至最低,
尽可能多。通常,Google 团队仅在目标代表可供 Google 中的任何团队使用的广泛使用的库时,才会将目标设为公开。如果某个团队要求其他团队在使用其代码之前与其协调,则该团队将维护一个客户目标许可名单,作为其目标的公开范围。每个
团队的内部实施目标仅限目录
归团队所有,而且大多数 BUILD
文件只有一个目标
私密。
管理依赖项
模块需要能够相互引用。将代码库拆分为精细模块的缺点在于,您需要管理这些模块之间的依赖项(不过,工具可以帮助您自动执行此操作)。表达这些内容
依赖项通常最终成为 BUILD
文件中的大部分内容。
内部依赖项
在拆分为精细模块的大型项目中,大多数依赖项都可能是内部依赖项;也就是说,是在同一源代码库中定义和构建的其他目标上。内部依赖项与外部依赖项的区别在于 它们是在源代码的基础上构建的,而不是作为预构建工件下载 。这也意味着,不存在“版本”的概念, 内部依赖项 - 目标及其所有内部依赖项始终 在代码库中的同一提交/修订版本上构建而成。关于内部依赖项,一个需要谨慎处理的问题是如何处理传递依赖项(图 1)。假设目标 A 依赖于目标 B 依赖于通用库目标 C。目标 A 应能够使用类 目标 C 中的定义?
图 1. 传递依赖项
从底层工具的角度来看,这没有任何问题;在构建目标 A 时,B 和 C 都会被链接到其中,因此 A 知道 C 中定义的任何符号。多年来,Bazel 一直让这一切成为可能,但随着 Google 的发展,我们 开始发现问题。假设 B 已重构,因此不再需要依赖于 C。如果随后移除了 B 对 C 的依赖,则 A 和 通过对 B 的依赖项使用 C 的目标会中断。实际上,目标的 依赖项已成为其公共合同的一部分, 已更改。这意味着,依赖项会随着时间的推移而累积,Google 上的 build 开始变慢。
Google 最终在 Bazel 中引入了“严格的传递依赖项模式”,从而解决了这个问题。在此模式下,Bazel 会检测目标是否尝试 在不直接依赖于某个符号的情况下,引用该符号;如果是,则会失败并显示 以及一个可用于自动插入 依赖项。在 Google 的整个代码库和 重构了我们数百万构建目标中的每一个,以明确列出它们的 依赖项开发需要多年的努力,但物有所值。由于目标的非必要依赖项更少,我们的构建速度现在快了很多,工程师可以移除不需要的依赖项,而无需担心破坏依赖于这些依赖项的目标。
与往常一样,强制执行严格的传递依赖项需要做出权衡。它带来了
更冗长的 build 文件,因为现在需要列出常用的库
而不是无意间拉入,而工程师
需要花费更多精力向 BUILD
文件添加依赖项。从那时起,
开发了多项工具,通过自动检测许多缺少
依赖项,并在没有任何开发者的情况下将它们添加到 BUILD
文件中
进行干预。但即使没有此类工具,我们也发现,权衡利弊
值得一试:向 BUILD
文件明确添加依赖项
是一次性成本,但处理隐式传递依赖项可能会导致
持续的问题。Bazel
会强制执行严格的传递依赖项
Java 代码。
外部依赖项
如果依赖项不是内部的,则必须为外部。外部依赖项是指在构建系统之外构建和存储的工件。通过 依赖项是从工件代码库直接导入的(通常访问 并且按原样使用,而不是在源代码的基础上构建。以下之一: 外部依赖项和内部依赖项之间最大的区别在于, 外部依赖项有不同的版本,并且这些版本独立于 项目的源代码
自动与手动依赖项管理
构建系统可以允许管理外部依赖项的版本
(手动或自动)。手动管理时,buildfile 会明确列出其要从工件仓库下载的版本,通常使用 1.1.4
等语义版本字符串。自动管理时,源文件会指定一系列可接受的版本,而构建系统始终会下载最新版本。例如,Gradle 允许将依赖项版本声明为“1.+”,以指定只要依赖项的主要版本为 1,则任何次要版本或补丁版本均可接受。
自动管理的依赖项对小型项目来说可能很方便,但对于规模较大或由多名工程师共同开发的项目来说,它们通常会造成灾难性后果。自动管理的依赖项存在的问题是,您无法控制版本更新的时间。我们无法保证外部方不会发布破坏性更新(即使他们声称使用语义版本控制),因此昨天正常运行的 build 可能在明天就无法正常运行,而且没有简单的方法来检测发生了哪些变化或将其回滚到正常运行状态。即使 build 没有崩溃,也可能会出现无法跟踪的细微行为或性能变化。
与之相反,由于手动管理的依赖项需要更改源代码控制,因此可以轻松发现和回滚,并且可以检出旧版代码库以使用旧版依赖项进行构建。Bazel 要求手动指定所有依赖项的版本。即使在规模适中的情况下,手动版本管理带来的开销也值得,因为它可以提供稳定性。
单一版本规则
库的不同版本通常由不同的工件表示, 因此从理论上说,不应让相同外部资源的不同版本 依赖项不能在构建系统中以不同的名称声明。 这样,每个目标都可以选择想要安装的依赖项版本 。这在实践中会带来很多问题,因此 Google 实施了严格的 单一版本规则 代码库中的所有第三方依赖项
允许多个版本的最大问题是菱形依赖项 问题。假设目标 A 依赖于目标 B 和外部库的 v1。如果目标 B 稍后重构为添加对同一外部库的 v2 的依赖项,目标 A 将会破坏,因为它现在隐式依赖于同一库的两个不同版本。实际上,添加 新依赖项与具有多个版本的任何第三方库相关联; 因为该目标的任何用户可能都依赖于 版本。如果遵循单一版本规则,如果 target 添加对第三方库的依赖项,任何现有依赖项 已采用同一版本,因此可以放心地共存。
传递外部依赖项
处理外部依赖项的传递依赖项可能是 尤其困难。许多工件仓库(例如 Maven Central)都允许工件指定对仓库中其他工件的特定版本的依赖项。默认情况下,Maven 或 Gradle 等构建工具通常会递归下载每个传递依赖项,这意味着在项目中添加一个依赖项可能会导致总共下载数十个工件。
这样非常方便:在向新库添加依赖项时,如果必须跟踪该库的每个传递依赖项并手动添加它们,将会非常麻烦。但这也有一个巨大的缺点: 库可以依赖于同一个第三方库的不同版本, 策略必然违背了“一版法则”,并最终导致 依赖项问题。如果您的目标依赖于两个外部库,这些库使用 也无从得知您将哪个版本 get。这也意味着,如果新版本开始拉取其某些依赖项的冲突版本,更新外部依赖项可能会导致整个代码库中似乎不相关的失败。
因此,Bazel 不会自动下载传递依赖项。
而且,没有什么良方,Bazel 的替代方案是要求
这个全局文件会列出存储库的每个外部
以及用于该依赖项的显式版本
存储库幸运的是,Bazel 提供了一些工具,能够自动生成包含一组 Maven 工件的传递依赖项的此类文件。您可以运行此工具一次,为项目生成初始 WORKSPACE
文件,然后手动更新该文件以调整每个依赖项的版本。
同样,这里的选择是在便利性和可伸缩性之间做出选择。小型项目可能不希望自行管理传递依赖项,并且可能能够使用自动传递依赖项。随着组织的发展,这种策略的吸引力 越来越多,冲突和意外结果也越来越多 频率。在较大的规模下,手动管理依赖项的成本非常高, 低于处理自动依赖项所导致的问题的费用 管理。
使用外部依赖项缓存构建结果
外部依赖项通常由发布 API 的第三方 库的稳定版本,或许无需提供源代码。部分 组织也可能选择将自己的一些代码作为 工件,从而使其他代码段可以作为第三方代码使用 而不是内部依赖项从理论上讲,如果工件构建速度缓慢但下载速度较快,则可以加快构建速度。
不过,这也带来了大量开销和复杂性:需要有人 负责构建所有这些工件并将其上传至 工件库,而客户则需确保 最新版本。调试也变得更加困难,因为 整个系统的各个部分将根据 并且源代码树不再是一致的视图。
要解决构建耗时较长的工件问题,一个更好的方法是 如前所述,请使用支持远程缓存的构建系统。这种 构建系统将每次构建中生成的工件保存到某个位置 因此,如果开发者依赖于某个工件, 由其他人最近构建,则构建系统会自动下载 而不是构建它。这可提供 直接依赖于工件,同时仍能确保 就像它们始终基于同一源代码构建一样。这是 策略,而 Bazel 可配置为使用远程 缓存。
外部依赖项的安全性和可靠性
依赖于第三方来源的工件本身就存在风险。如果第三方来源(例如工件仓库)发生故障,则会存在可用性风险,因为如果整个 build 无法下载外部依赖项,则可能会停滞不前。这样做还存在安全风险
被攻击者破解后,攻击者可以用其替换
具有自己的设计的工件,允许它们注入任意代码
添加到您的 build 中。通过镜像复制您创建的任何工件
依赖于您控制并阻止构建系统访问的服务器
第三方工件库(如 Maven Central)。但缺点是,这些镜像需要投入精力和资源进行维护,因此是否使用这些镜像通常取决于项目的规模。您还可以要求在源代码库中指定每个第三方工件的哈希值,以便在工件被篡改时导致构建失败,从而以很少的开销完全防止安全问题。另一种完全规避此问题的方法是将项目的依赖项作为供应商提供。当项目封装其依赖项时,会将其作为源代码或二进制文件与项目的源代码一起签入源代码控制系统。这实际上意味着
确保项目的所有外部依赖项都会转换为内部依赖项
依赖项Google 在内部使用这种方法,
将 Google 中引用的库放入根目录下的 third_party
目录中
源代码树的一种结构不过,这之所以能对 Google 起作用,仅仅因为 Google 的
源代码控制系统是专门为处理超大型 monorepo 而构建的,
供应商不一定适用于所有组织。