Bazel 代码库

报告问题 查看源代码

本文档介绍了代码库以及 Bazel 的结构。面向愿意为 Bazel 贡献代码的人员,但不适用于最终用户。

简介

Bazel 的代码库非常庞大(大约 350KLOC 的生产代码和 260 的 KLOC 测试代码),没有人熟悉整个环境:每个人都非常了解自己特定的山谷,但很少有人知道山上每个方向都隐藏着什么。

为了让在进行中途的人们不会因为简单的路径丢失而在黑暗的森林中发现自己,本文档尝试对代码库进行概述,以便更轻松地开始构建代码库。

Bazel 的源代码的公开版本位于 GitHub (github.com/bazelbuild/bazel) 上。这不是“可信来源”;它源自 Google 内部源代码树,其中包含在 Google 之外没有用的其他功能。我们的长期目标是让 GitHub 成为可信来源。

贡献内容通过常规 GitHub 拉取请求机制接受,并由 Google 员工手动导入内部源代码树,然后重新导出到 GitHub。

客户端/服务器架构

大部分 Bazel 都驻留在服务器进程中,此进程保留在 build 之间的 RAM 中。这样 Bazel 可以在不同 build 之间维护状态。

因此,Bazel 命令行具有两种选项:启动和命令。在类似如下的命令行中:

    bazel --host_jvm_args=-Xmx8G build -c opt //foo:bar

有些选项 (--host_jvm_args=) 位于要运行的命令名称之前,还有一些选项位于 (-c opt) 之后;前一种选项称为“启动选项”,会影响整个服务器进程,而后一种(即“命令选项”)仅影响单个命令。

每个服务器实例都有一个关联的工作区(源代码树集合称为“代码库”),并且每个工作区通常有一个活跃的服务器实例。您可以通过指定自定义输出基准来规避此错误(如需了解详情,请参阅“目录布局”部分)。

Bazel 作为单个 ELF 可执行文件分发,也是有效的 .zip 文件。输入 bazel 时,在 C++ 中实现的上述 ELF 可执行文件(“客户端”)将获得控制权。它按照以下步骤设置适当的服务器进程:

  1. 检查它是否已提取自身。否则,它会执行该操作。这是服务器实现的来源。
  2. 检查是否有可正常运行的活跃服务器实例:该实例正在运行、具有正确的启动选项以及使用正确的工作区目录。它会通过查看 $OUTPUT_BASE/server 目录来查找正在运行的服务器,其中有一个锁文件,且该文件包含服务器正在监听的端口。
  3. 必要时终止旧的服务器进程
  4. 如果需要,启动新的服务器进程

合适的服务器进程准备就绪后,系统会通过 gRPC 接口将需要运行的命令与其通信,然后将 Bazel 的输出通过管道传回终端。一次只能运行一个命令。这是使用一种复杂的锁定机制实现的,其中部分采用 C++,部分采用 Java。有一些基础架构可用于并行运行多个命令,因为无法与另一个命令并行运行 bazel version 这有点令人尴尬。主要阻碍因素是 BlazeModule 的生命周期以及 BlazeRuntime 中的某种状态。

在命令结束时,Bazel 服务器会传输客户端应返回的退出代码。一个有趣的小问题是 bazel run 的实现:此命令的任务是运行 Bazel 刚刚构建的内容,但由于它没有终端,因此无法从服务器进程执行此操作。因此,它会告知客户端它应该 ujexec() 的二进制文件以及使用哪些参数。

当按下 Ctrl-C 时,客户端会将其转换为对 gRPC 连接进行的“取消”调用,以尝试尽快终止命令。在第三个 Ctrl-C 之后,客户端改为向服务器发送 SIGKILL。

客户端的源代码位于 src/main/cpp 下,用于与服务器通信的协议位于 src/main/protobuf/command_server.proto 下。

服务器的主要入口点是 BlazeRuntime.main(),来自客户端的 gRPC 调用由 GrpcServerImpl.run() 处理。

目录布局

在构建过程中,Bazel 会创建一组稍微复杂的目录。如需查看完整说明,请参阅输出目录布局

“主代码库”是运行 Bazel 的源代码树。它通常与您从源代码控制系统中签出的内容相对应。此目录的根目录称为“工作区根目录”。

Bazel 会将其所有数据放在“输出用户根目录”下。它通常为 $HOME/.cache/bazel/_bazel_${USER},但可以使用 --output_user_root 启动选项进行替换。

“安装库”是 Bazel 解压缩到的位置。此过程是自动完成的,每个 Bazel 版本都会根据安装库下的校验和获得一个子目录。它默认位于 $OUTPUT_USER_ROOT/install,可以使用 --install_base 命令行选项进行更改。

“输出库”是附加到特定工作区的 Bazel 实例写入内容的位置。在任何时间,每个输出库最多只能有一个正在运行的 Bazel 服务器实例。通常在$OUTPUT_USER_ROOT/<checksum of the path to the workspace>。您可以使用 --output_base 启动选项更改 Bazel 实例,该选项有助于避免在任何给定时间在任何工作区中只能运行一个 Bazel 实例。

输出目录包含许多内容,还包含以下内容:

  • $OUTPUT_BASE/external 上提取的外部代码库。
  • exec 根目录,一个目录,其中包含指向当前 build 的所有源代码的符号链接。它位于$OUTPUT_BASE/execroot。在构建期间,工作目录为 $EXECROOT/<name of main repository>。我们计划将其更改为 $EXECROOT,但这是一项长期计划,因为这种更改非常不兼容。
  • 构建期间构建的文件。

执行命令的过程

Bazel 服务器获得控制权并获知其需要执行的命令后,会按以下顺序发生事件:

  1. BlazeCommandDispatcher 收到了关于新请求的通知。它会确定命令是否需要在工作区中运行(几乎所有命令,与源代码无关的命令,例如 version 或 help)以及是否正在运行其他命令。

  2. 找到正确的命令。每个命令都必须实现 BlazeCommand 接口,并且必须包含 @Command 注解(这有点像反模式,如果命令需要的所有元数据都由 BlazeCommand 上的方法描述,那就更好)

  3. 系统会解析命令行选项。每个命令都有不同的命令行选项,如 @Command 注解中所述。

  4. 创建事件总线。事件总线是用于在构建期间发生的事件的数据流。其中一些数据在构建事件协议的保护下导出到 Bazel 外部,目的是让全世界知道构建是如何进行的。

  5. 命令获得控制权。最有趣的命令是运行 build(构建、测试、运行、覆盖率等)的命令:此功能由 BuildTool 实现。

  6. 系统会解析命令行中的目标格式集,并解析 //pkg:all//pkg/... 等通配符。这在 AnalysisPhaseRunner.evaluateTargetPatterns() 中实现,并在 Skyframe 中具体为 TargetPatternPhaseValue

  7. 运行加载/分析阶段以生成操作图(需要为构建执行的命令的有向无环图)。

  8. 执行阶段运行。这意味着,系统会运行构建所请求顶级目标所需的每项操作。

命令行选项

Bazel 调用的命令行选项以 OptionsParsingResult 对象描述,而该对象包含从“选项类”到选项值的映射。“选项类”是 OptionsBase 的子类,可将彼此相关的命令行选项归为一组。例如:

  1. 与编程语言(CppOptionsJavaOptions)相关的选项。这些选项应该是 FragmentOptions 的子类,并最终封装到 BuildOptions 对象中。
  2. 与 Bazel 执行操作方式相关的选项 (ExecutionOptions)

这些选项旨在于分析阶段使用(通过 Java 中的 RuleContext.getFragment() 或 Starlark 中的 ctx.fragments)。其中一些参数(例如,是否使用 C++ 扫描)会在执行阶段读取,但这始终需要显式连接,因为那时 BuildConfiguration 不可用。如需了解详情,请参阅“配置”部分。

警告:我们想假设 OptionsBase 实例是不可变的,并以此方式使用它们(例如,作为 SkyKeys 的一部分)。事实并非如此,修改它们是以难以调试的微妙方式破坏 Bazel 的好方法。遗憾的是,使它们实际上不可变是一项艰巨的任务。(在构建后立即修改 FragmentOptions,然后其他人才有机会保留对它的引用,以及对它调用 equals()hashCode() 之前。)

Bazel 通过以下方式学习选项类:

  1. 有些已通过硬连接的方式连接到 Bazel (CommonCommandOptions)
  2. 通过每个 Bazel 命令上的 @Command 注解添加
  3. ConfiguredRuleClassProvider 开始(这些是与各个编程语言相关的命令行选项)
  4. Starlark 规则还可以定义自己的选项(请参阅此处

每个选项(不包括 Starlark 定义的选项)都是具有 @Option 注解的 FragmentOptions 子类的成员变量,该注解指定命令行选项的名称和类型以及一些帮助文本。

命令行选项值的 Java 类型通常是简单内容(字符串、整数、布尔值、标签等)。不过,我们也支持更复杂的类型的选项;在这种情况下,从命令行字符串转换为数据类型的任务属于 com.google.devtools.common.options.Converter 的实现。

Bazel 所看到的源代码树

Bazel 的主要业务是构建软件,即通过读取和解读源代码来实现。Bazel 运行的所有源代码称为“工作区”,由多个代码库、软件包和规则组成。

仓库

“代码库”是开发者使用的源代码树;它通常代表单个项目。Bazel 的祖先实体 Blaze 在单一代码库上运行,也就是说,单个源代码树包含用于运行构建的所有源代码。相比之下,Bazel 支持源代码跨越多个代码库的项目。调用 Bazel 的代码库称为“主代码库”,其他代码库称为“外部代码库”。

代码库由其根目录中的代码库边界文件(MODULE.bazelREPO.bazel 在旧版上下文中是 WORKSPACEWORKSPACE.bazel)标记。主代码库是您从中调用 Bazel 的源代码树。外部代码库以各种方式定义;如需了解详情,请参阅外部依赖项概览

外部代码库的代码以符号链接形式或在 $OUTPUT_BASE/external 下下载。

运行 build 时,需要将整个源代码树拼合在一起;这由 SymlinkForest 完成,它会将主代码库中的每个软件包符号链接到 $EXECROOT,并将每个外部代码库符号链接到 $EXECROOT/external$EXECROOT/..

软件包

每个代码库由软件包、相关文件的集合和依赖项规范组成。这些内容由名为 BUILDBUILD.bazel 的文件指定。如果两者都存在,Bazel 会优先选择 BUILD.bazelBUILD 文件仍然接受的原因是 Bazel 的祖先 Blaze 使用此文件名。但事实证明,它是常用的路径段,尤其是在 Windows 上,因为 Windows 中的文件名不区分大小写。

软件包彼此独立:对软件包的 BUILD 文件所做的更改不能导致其他软件包更改。添加或移除 BUILD 文件可能会改变其他软件包,因为递归 glob 位于软件包边界处,因此存在 BUILD 文件会停止递归。

BUILD 文件的评估称为“软件包加载”。它是在类 PackageFactory 中实现的,通过调用 Starlark 解释器来工作,并且需要了解可用的规则类集。软件包加载的结果是一个 Package 对象。它主要是从字符串(目标名称)到目标本身的映射。

软件包加载期间的一大复杂问题就是“全局化”:Bazel 并不要求明确列出每个源文件,而是可以运行 glob(例如 glob(["**/*.java"]))。与 shell 不同,它支持下到子目录(但不支持子软件包)的递归 glob。这需要访问文件系统,由于速度可能很慢,因此我们实施了各种技巧,以使其尽可能并行且高效地运行。

Globbing 在以下类中实现:

  • LegacyGlobber,一个无法感知天空框架的飞船飞船
  • SkyframeHybridGlobber:使用 Skyframe 并还原为旧版 globber 的版本,以避免“Skyframe 重启”(如下所述)

Package 类本身包含一些专门用于解析“外部”软件包(与外部依赖项相关)的成员,而这些成员对实际软件包没有意义。这属于设计缺陷,因为描述常规软件包的对象不应包含描述其他内容的字段。其中包括:

  • 代码库映射
  • 已注册的工具链
  • 已注册的执行平台

理想情况下,解析“外部”软件包与解析常规软件包之间会有更大的分离,因此 Package 无需同时满足两者的需求。遗憾的是,这样做很难实现,因为两者密切相关。

标签、目标和规则

软件包由目标类型组成,目标类型如下:

  1. 文件:可以是 build 的输入或输出。在 Bazel 中,我们称之为“工件”(在其他地方讨论过)。并非在构建期间创建的所有文件都是目标;Bazel 的输出通常没有关联的标签。
  2. 规则:这些描述从输入中得出输出的步骤。它们通常与编程语言(如 cc_libraryjava_librarypy_library)相关联,但也有一些与语言无关的语言(如 genrulefilegroup
  3. 软件包组:在可见性部分进行了讨论。

目标的名称称为标签。标签的语法为 @repo//pac/kage:name,其中 repo 是标签所在的代码库的名称,pac/kage 是其 BUILD 文件所在的目录,name 是相对于软件包目录的文件的路径(如果标签引用了源文件)。在命令行中引用目标时,可以省略标签的某些部分:

  1. 如果省略代码库,则将该标签视为主代码库。
  2. 如果省略软件包部分(例如 name:name),系统会将标签视为当前工作目录的软件包(不允许使用包含升级引用 (..) 的相对路径)

一种规则(例如“C++ 库”)称为“规则类”。规则类可以在 Starlark(rule() 函数)或 Java(所谓的“原生规则”类型为 RuleClass)中实现。从长远来看,每个特定于语言的规则都将在 Starlark 中实现,但一些旧版规则系列(例如 Java 或 C++)目前仍在 Java 中。

Starlark 规则类需要使用 load() 语句在 BUILD 文件的开头导入,而 Java 规则类通过 ConfiguredRuleClassProvider 注册,因此 Bazel“本身”已知。

规则类包含以下信息:

  1. 其属性(例如 srcsdeps):其类型、默认值、限制条件等。
  2. 配置转换和附加到每个特性的切面(如果有)
  3. 规则的实施
  4. 规则“通常”创建的传递信息提供方

术语说明:在代码库中,我们通常使用“Rule”来表示规则类创建的目标。但在 Starlark 和面向用户的文档中,“规则”应仅用于引用规则类本身;目标只是一个“目标”。另请注意,尽管 RuleClass 的名称中包含“类”,但规则类与该类型的目标之间没有 Java 继承关系。

天框架

Bazel 的底层评估框架称为 Skyframe。它采用的模型是,在构建期间需要构建的所有内容都整理到一个有向无环图中,其边从任何数据指向其依赖项,即构建数据需要知道的其他数据片段。

图中的节点称为 SkyValue,其名称称为 SkyKey。两者都是绝对不可变的;只有不可变对象应该可以从中访问。这种不变性几乎始终适用,如果并非如此(例如,对于个别选项类 BuildOptions,它是 BuildConfigurationValue 及其 SkyKey 的成员),我们会尽量不更改它们,或者仅以无法从外部观察的方式更改它们。由此可以得出,在 Skyframe 中计算的所有内容(例如配置的目标)也必须不可变。

观察 Skyframe 图表的最便捷方式是运行 bazel dump --skyframe=deps,该命令会转储图表,每行一个 SkyValue。最好对小型 build 执行此操作,因为其可能会非常大。

Skyframe 包含在 com.google.devtools.build.skyframe 包中。名称相似的软件包 com.google.devtools.build.lib.skyframe 包含在 Skyframe 之上的 Bazel 实现。如需详细了解 Skyframe,请点击此处

为了将给定的 SkyKey 评估为 SkyValue,Skyframe 将调用与按键类型对应的 SkyFunction。在评估期间,该函数可能会通过调用 SkyFunction.Environment.getValue() 的各种过载向 Skyframe 请求其他依赖项。这样做的副作用是将这些依赖项注册到 Skyframe 的内部图中,以便 Skyframe 知道在其依赖项发生更改时重新评估函数。换句话说,Skyframe 的缓存和增量计算是以 SkyFunctionSkyValue 的粒度工作的。

每当 SkyFunction 请求不可用的依赖项时,getValue() 都将返回 null。这样一来,该函数本身会返回 null,从而将控制权交还给 Skyframe。稍后,Skyframe 将评估不可用的依赖项,然后从头重启该函数 - 只有这次 getValue() 调用会成功并返回非 null 结果。

这样做的结果是,重启之前在 SkyFunction 内执行的任何计算都必须重复。但这不包括为评估已缓存的依赖项 SkyValues 而执行的工作。因此,我们通常可通过以下方法解决此问题:

  1. (使用 getValuesAndExceptions())批量声明依赖项,以限制重启次数。
  2. SkyValue 拆分为由不同 SkyFunction 计算的单独部分,以便可以单独计算和缓存这些部分。由于这可能会增加内存用量,因此应采取策略性方式执行此操作。
  3. 在重启之间存储状态:使用 SkyFunction.Environment.getState(),或将临时静态缓存保留在“Skyframe 后面”。使用复杂的 SkyFunction 时,重启之间的状态管理可能会变得非常复杂,因此引入了 StateMachine 来实现逻辑并发的结构化方法,包括用于在 SkyFunction 中暂停和恢复分层计算的钩子。示例:DependencyResolver#computeDependencies 结合使用 StateMachinegetState() 来计算已配置目标的可能庞大的直接依赖项集,否则可能导致重启代价高昂。

从根本上说,Bazel 需要这些类型的解决方法,因为数十万个运行中的 Skyframe 节点很常见,并且截至 2023 年,Java 对轻量级线程的支持不如 StateMachine 实现。

星拉克星

Starlark 是用户用于配置和扩展 Bazel 的网域特定语言。它可被视为受限的 Python 子集,具有少得多的类型和对控制流的限制。最重要的是,强不可变性保证支持并发读取。它不是图灵完成机制,这会让部分(但非全部)用户尝试使用该语言完成常规编程任务。

Starlark 在 net.starlark.java 软件包中实现。它还具有独立的 Go 实现(点击此处)。Bazel 中使用的 Java 实现目前是一种解释器。

Starlark 可在多种情境中使用,包括:

  1. BUILD 文件。此处定义了新的构建目标。在此上下文中运行的 Starlark 代码只能访问 BUILD 文件本身及其加载的 .bzl 文件的内容。
  2. MODULE.bazel 文件。这是定义外部依赖项的位置。在此上下文中运行的 Starlark 代码仅对少数预定义指令拥有非常有限的访问权限。
  3. .bzl 文件。您可以在此处定义新的构建规则、Repo 规则和模块扩展。此处的 Starlark 代码可以定义新函数并从其他 .bzl 文件加载。

BUILD.bzl 文件可用的方言略有不同,因为它们表达的内容不同。如需查看差异列表,请点击此处

如需详细了解 Starlark,请点击此处

加载/分析阶段

加载/分析阶段是 Bazel 确定构建特定规则所需的操作。它的基本单元是“配置的目标”,合理来说就是一个(目标、配置)对。

之所以称之为“加载/分析阶段”,是因为它可以拆分为两个不同部分,曾经进行序列化,但现在它们可以在时间上重叠:

  1. 加载软件包,也就是说,将 BUILD 文件转换为代表软件包的 Package 对象
  2. 分析配置的目标,即运行规则的实现以生成操作图

对于在命令行中请求的已配置目标的传递闭包中的每个已配置目标,必须自下而上分析;也就是说,首先对叶节点进行分析,然后才到命令行中的叶节点。分析单个配置的目标所需的输入包括:

  1. 配置。(“如何”构建该规则;例如,目标平台,还包括用户想要传递给 C++ 编译器的命令行选项等)
  2. 直接依赖项。其传递信息提供方可用于要分析的规则。之所以能这样调用它们,是因为它们提供已配置目标的传递闭包中的信息“总览”,例如类路径上的所有 .jar 文件或需要链接到 C++ 二进制文件的所有 .o 文件)
  3. 目标本身。这是加载目标所在的软件包的结果。对于规则,这包括其属性(通常很重要)。
  4. 已配置目标的实现。对于规则,可以在 Starlark 或 Java 中执行此操作。所有非规则配置的目标都是在 Java 中实现。

分析已配置的目标的输出如下所示:

  1. 配置依赖目标的传递信息提供程序可以访问
  2. 它可以创建的工件以及生成这些工件的操作。

为 Java 规则提供的 API 是 RuleContext,它等效于 Starlark 规则的 ctx 参数。它的 API 功能更强大,但同时,执行 Bad ThingsTM 也变得更轻松,例如,编写时间或空间复杂度为二次(或更糟)的代码、导致 Bazel 服务器崩溃并引发 Java 异常,或者违反不可变特性(例如,无意中修改了 Options 实例或将配置的目标设为可变)

用于确定已配置目标的直接依赖项的算法位于 DependencyResolver.dependentNodeMap() 中。

配置

配置是构建目标的“方式”:适用于什么平台、使用哪些命令行选项等。

您可以针对同一 build 中的多个配置构建相同的目标。例如,以下情形非常有用:在构建期间运行的工具和目标代码使用相同的代码,我们正在进行交叉编译;或者要构建胖 Android 应用(应用包含多个 CPU 架构的原生代码)

从概念上讲,该配置是一个 BuildOptions 实例。不过,实际上,BuildOptionsBuildConfiguration 封装,后者提供了额外的功能。它会从依存关系图的顶部传播到底部。如果它发生变化,则需要重新分析 build。

这会导致出现异常情况,例如,当请求的测试运行次数发生变化时,必须重新分析整个 build,即使这只会影响测试目标(我们计划对配置进行“削减”处理,使得这种情况并尚未就绪)。

当规则实现需要配置的一部分时,需要使用 RuleClass.Builder.requiresConfigurationFragments() 在其定义中进行声明。这是为了避免错误(例如,使用 Java Fragment 的 Python 规则),以及便于配置删减,例如在 Python 选项发生更改时,就不需要重新分析 C++ 目标。

规则的配置不一定与其“父”规则的配置相同。更改依赖项边缘中的配置的过程称为“配置转换”。可能发生在以下两个位置:

  1. 在依赖项边缘。这些转换在 Attribute.Builder.cfg() 中指定,并且是从 Rule(发生转换的位置)和 BuildOptions(原始配置)到一个或多个 BuildOptions(输出配置)的函数。
  2. 在到已配置目标的任何传入边缘上。这些属性在 RuleClass.Builder.cfg() 中指定。

相关的类是 TransitionFactoryConfigurationTransition

使用配置转换,例如:

  1. 声明在构建期间使用了某个特定依赖项,并因此应在执行架构中进行构建
  2. 如需声明必须为多个架构构建特定的依赖项(如针对胖 Android APK 中的原生代码)

如果配置转换导致了多个配置,则称为拆分转换。

还可以在 Starlark 中实现配置转换(点击此处查看文档)

传递信息提供方

传递信息提供程序是已配置目标的一种方式(也是唯一方式),可了解依赖于它的其他已配置目标。其名称中之所以使用“传递”这个名称,是因为这通常是对已配置目标的传递闭包的某种总览。

Java 传递信息提供程序与 Starlark 传递信息提供程序之间通常是 1:1 的对应关系(DefaultInfo 例外,它是 FileProviderFilesToRunProviderRunfilesProvider 的组合,因为被认为该 API 比 Java 的直接音译更像 Starlark 音译)。其密钥为以下其中一项:

  1. 一个 Java 类对象。仅适用于无法通过 Starlark 访问的提供方。这些提供程序是 TransitiveInfoProvider 的子类。
  2. 一个字符串。这是一种遗留问题,强烈建议不要这样做,因为容易产生名称冲突。此类传递信息提供程序是 build.lib.packages.Info 的直接子类。
  3. 提供商符号。这可以使用 provider() 函数从 Starlark 创建,并且是创建新提供程序的推荐方法。该符号在 Java 中由 Provider.Key 实例表示。

采用 Java 实现的新提供程序应使用 BuiltinProvider 实现。NativeProvider 已废弃(我们还没来得及移除它),并且无法从 Starlark 访问 TransitiveInfoProvider 子类。

已配置的目标

已配置的目标以 RuleConfiguredTargetFactory 的形式实现。采用 Java 实现的每个规则类都有一个子类。Starlark 配置目标通过 StarlarkRuleConfiguredTargetUtil.buildRule() 创建。

已配置的目标工厂应使用 RuleConfiguredTargetBuilder 来构建其返回值。它包含以下内容:

  1. filesToBuild,即“此规则所代表的文件集”的模糊概念。这些是配置的目标在命令行或 genrule 的 src 中时构建的文件。
  2. 其 runfile、常规和数据。
  3. 其输出组。这些是规则可以构建的各种“其他文件集”。可以使用 BUILD 中文件组规则的 output_group 属性访问这些文件,在 Java 中则使用 OutputGroupInfo 提供程序访问。

Runfiles

某些二进制文件需要数据文件才能运行。一个突出的例子是需要输入文件的测试。这在 Bazel 中以“runfiles”的概念表示。“runfiles 树”是特定二进制文件的数据文件的目录树。它是在文件系统中以符号链接树的形式创建的,其中的各个符号链接指向输出树中的文件。

一组 Runfile 表示为一个 Runfiles 实例。从概念上讲,它是从 runfiles 树中的某个文件的路径到表示它的 Artifact 实例的映射。它比单个 Map 稍微复杂一点,原因有两个:

  • 大多数情况下,文件的 runfiles 路径与其执行路径相同。我们会使用它来节省一些 RAM。
  • Runfile 树中有各种旧版条目,也需要表示这些条目。

Runfile 是使用 RunfilesProvider 收集的:此类的实例表示配置目标(例如库)及其传递闭包需求,并且它们会像嵌套集一样收集(事实上,它们是使用底层的嵌套集实现的):每个目标都会联合其依赖项的 runfile,添加自己的一些运行文件,然后将生成的集向上发送到依赖关系图。一个 RunfilesProvider 实例包含两个 Runfiles 实例,一个用于在规则依赖于“数据”属性时实现,另一个实例用于所有其他类型的传入依赖项。这是因为,目标通过数据属性依赖时,目标有时会呈现不同的 runfile,而非通过其他方式。这是我们不希望移除的旧行为。

二进制文件的 Runfile 表示为 RunfilesSupport 的实例。这与 Runfiles 不同,因为 RunfilesSupport 能够实际构建(与 Runfiles 不同,后者只是一个映射)。这就需要以下额外的组件:

  • 输入 runfiles 清单。这是 Runfiles 树的序列化说明。它用作 runfiles 树内容的代理,Bazel 会在且仅当清单内容发生更改时,假定 runfiles 树发生更改。
  • 输出 runfiles 清单。用于处理 runfiles 树的运行时库会使用此字符串,尤其是在 Windows 上,后者有时不支持符号链接。
  • runfiles 中间人。为了存在 runfiles 树,需要构建符号链接树以及符号链接指向的工件。为了减少依赖项边缘的数量,可以使用 runfiles 中间程序来表示所有这些边缘。
  • 命令行参数:用于运行 RunfilesSupport 对象表示的运行文件所代表的二进制文件。

切面

切面是“沿着依赖关系图向下传播计算”的一种方法。如需了解 Bazel 用户,请点击此处。协议缓冲区是一个很好的推动因素:proto_library 规则不应该知道任何特定语言,但以任何编程语言构建协议缓冲区消息(协议缓冲区的“基本单元”)的实现应与 proto_library 规则相结合,这样一来,如果同一语言的两个目标依赖于同一协议缓冲区,则只会构建一次。

与配置的目标一样,它们在 Skyframe 中表示为 SkyValue,其构建方式与配置目标的构建方式非常相似:它们有一个名为 ConfiguredAspectFactory 的工厂类,可以访问 RuleContext,但与配置的目标工厂不同,它也知道它附加到的已配置目标及其提供程序。

您可以使用 Attribute.Builder.aspects() 函数为每个属性指定沿依赖关系图向下传播的一组切面。有几个类名称令人困惑,它们参与了该过程:

  1. AspectClass 是切面的实现。它可以采用 Java(在这种情况下,它是子类)或 Starlark(在此情况下是 StarlarkAspectClass 的实例)中。它类似于 RuleConfiguredTargetFactory
  2. AspectDefinition 是切面的定义;它包含所需的提供程序和提供的提供程序,并包含对其实现的引用,例如相应的 AspectClass 实例。它类似于 RuleClass
  3. AspectParameters 是一种对沿依赖关系图向下传播的切面进行参数化的方法。它当前是字符串到字符串映射。协议缓冲区就是一个很好的例子:如果一种语言有多个 API,关于应为哪个 API 构建协议缓冲区的信息应沿着依赖关系图向下传播。
  4. Aspect 表示计算沿依赖关系图传播的切面所需的所有数据。它包含切面类及其定义和参数。
  5. RuleAspect 是用于确定特定规则应该传播哪些方面的函数。它是 Rule -> Aspect 函数。

有点意外的复杂情况是,各个方面可以附加到其他方面;例如,收集 Java IDE 的类路径的方面可能想要知道类路径上的所有 .jar 文件,但其中一些是协议缓冲区。在这种情况下,IDE 切面将附加到(proto_library 规则 + Java proto 切面)对。

AspectCollection 类捕获了切面方面切面的复杂性。

平台和工具链

Bazel 支持多平台构建,也就是说,可能有多个架构运行的构建操作以及多个构建代码的架构。这些架构在 Bazel 术语中称为平台(点击此处查看完整文档)

平台由从约束条件设置(例如“CPU 架构的概念”)到约束条件值(例如 x86_64 的特定 CPU)的键值对映射。我们在 @platforms 代码库中提供了一个包含最常用的限制条件设置和值的“字典”。

工具链的概念源自以下事实:根据 build 运行的平台和针对的平台,需要使用不同的编译器;例如,特定的 C++ 工具链可以在特定的操作系统上运行,并且能够以其他操作系统为目标。Bazel 必须根据设置的执行和目标平台确定使用的 C++ 编译器(有关工具链的文档,请点击此处)。

为了做到这一点,工具链由其支持的执行和目标平台约束条件集进行注解。为此,工具链的定义分为两部分:

  1. toolchain() 规则,用于描述工具链支持的一组执行和目标限制条件,并告知工具链的类型(例如 C++ 或 Java),后者由 toolchain_type() 规则表示)
  2. 描述实际工具链的特定语言的规则(例如 cc_toolchain()

以这种方式完成的原因是,我们需要了解每个工具链的限制,以便解析工具链,并且特定于语言的 *_toolchain() 规则包含的信息远不止这些,因此加载它们需要更多时间。

可通过以下方式之一指定执行平台:

  1. 在 MODULE.bazel 文件中,使用 register_execution_platforms() 函数
  2. 在命令行(使用 --extra_execution_platforms 命令行选项)中

可用的执行平台集在 RegisteredExecutionPlatformsFunction 中计算。

已配置目标的目标平台由 PlatformOptions.computeTargetPlatform() 确定。它是一个平台列表,因为我们最终希望支持多个目标平台,但尚未实现。

要用于已配置目标的工具链集由 ToolchainResolutionFunction 确定。它是以下函数之一:

  • 一组已注册的工具链(位于 MODULE.bazel 文件和配置中)
  • 所需的执行平台和目标平台(在配置中)
  • 已配置的目标所需的工具链类型集(在 UnloadedToolchainContextKey) 中)
  • 已配置目标(exec_compatible_with 属性)和配置 (--experimental_add_exec_constraints_to_targets) 的一组执行平台限制条件,位于 UnloadedToolchainContextKey

其结果是 UnloadedToolchainContext,本质上是从工具链类型(表示为 ToolchainTypeInfo 实例)到所选工具链标签的映射。之所以称为“卸载”,是因为它不包含工具链本身,只有其标签。

然后,系统会实际使用 ResolvedToolchainContext.load() 加载工具链,以供请求这些工具链的已配置目标的实现使用。

我们还有一个旧版系统,该系统依赖于单个“主机”配置和目标配置由各种配置标志(例如 --cpu)表示。我们正在逐步过渡到上述系统。为了应对用户依赖旧版配置值的情况,我们实现了平台映射,以在旧版标志和新式平台限制条件之间进行转换。其代码采用 PlatformMappingFunction 语言,并使用非 Starlark“小语言”。

限制条件

有时,用户希望将某个目标指定为仅与少数几个平台兼容。遗憾的是,Bazel 有多种机制来实现这一目标:

  • 针对特定规则的限制条件
  • environment_group()/environment()
  • 平台限制

特定于规则的约束主要在 Google 的 Java 规则中使用;它们即将停用,它们在 Bazel 中不可用,但源代码可能包含对其的引用。控制此行为的属性称为 constraints=

Environment_group() 和 environment()

这些规则是一种旧机制,使用并不广泛。

所有构建规则都可以声明可以针对哪些“环境”构建它们,其中“环境”是指 environment() 规则的实例。

您可以通过多种方式为规则指定支持的环境:

  1. 通过 restricted_to= 属性。这是最直接的规范形式;它声明了该规则支持的一组确切环境。
  2. 通过 compatible_with= 属性。这会声明除了默认支持的“标准”环境之外,规则还支持的环境。
  3. 通过软件包级属性 default_restricted_to=default_compatible_with=
  4. 通过 environment_group() 规则中的默认规范。每个环境都属于一组主题相关的对等项(例如“CPU 架构”、“JDK 版本”或“移动操作系统”)。环境组的定义包括“默认”中应支持下列哪些环境(如果未通过 restricted_to= / environment() 属性另外指定)。没有此类属性的规则将继承所有默认值。
  5. 通过规则类别默认值。这会覆盖给定规则类的所有实例的全局默认值。例如,这可用于使所有 *_test 规则可测试,而无需每个实例明确声明此功能。

environment() 作为常规规则实现,而 environment_group()Target 的子类,而不是 Rule (EnvironmentGroup),并且是 Starlark (StarlarkLibrary.environmentGroup()) 默认提供的函数,后者最终会创建一个同名目标。这是为了避免出现的循环依赖项,因为每个环境都需要声明其所属的环境组,而每个环境组都需要声明其默认环境。

使用 --target_environment 命令行选项,可将构建限制在特定环境下。

约束检查的实现位于 RuleContextConstraintSemanticsTopLevelConstraintSemantics 中。

平台限制

目前,用于描述目标与哪些平台的“官方”方法是,使用与描述工具链和平台相同的限制条件。它正在拉取请求 #10945 中接受审核。

公开范围

如果您与许多开发者一起开发大型代码库(比如在 Google 内部),则需要小心,防止其他人根据您的代码随意依赖。否则,根据 Hyrum 定律,人们将会依赖您认为属于实现细节的行为。

Bazel 通过名为 visibility 的机制来支持这一点:您可以使用 visibility 属性来声明特定目标只能依赖于某个目标。这个属性有点特殊,因为虽然它包含一个标签列表,但这些标签可以对软件包名称进行编码,而不是对指向任何特定目标的指针进行编码。(没错,这属于设计缺陷。)

这在以下位置实现:

  • RuleVisibility 接口表示可见性声明。它可以是常量(完全公开或完全私有),也可以是标签列表。
  • 标签可以指软件包组(预定义的软件包列表),直接引用软件包 (//pkg:__pkg__) 或软件包的子树 (//pkg:__subpackages__)。这与使用 //pkg:*//pkg/... 的命令行语法不同。
  • 软件包组作为自己的目标 (PackageGroup) 和配置的目标 (PackageGroupConfiguredTarget) 来实现。如果需要,我们可以将这些组替换为简单的规则。它们的逻辑是借助以下函数实现的:PackageSpecification(对应于单个模式,例如 //pkg/...)、PackageGroupContents(对应于单个 package_grouppackages 属性)和 PackageSpecificationProvider(通过 package_group 及其传递的 includes 进行聚合)。
  • 从可见性标签列表到依赖项的转换是在 DependencyResolver.visitTargetVisibility 和其他几个位置完成的。
  • 实际检查在 CommonPrerequisiteValidator.validateDirectPrerequisiteVisibility() 中完成

嵌套集

通常,配置的目标会聚合其依赖项中的一组文件,添加自己的文件集,并将聚合集封装到传递信息提供程序中,以便依赖于它的已配置目标可以执行相同的操作。示例:

  • 用于 build 的 C++ 头文件
  • 表示 cc_library 的传递闭包的对象文件
  • 需要位于类路径才能编译或运行 Java 规则的一组 .jar 文件
  • Python 规则传递闭包中的 Python 文件集

如果使用 ListSet 等简单的方式执行此操作,最终会导致二次内存使用:如果有 N 条规则链,并且每条规则都添加一个文件,我们将有 1+2+...+N 个集合成员。

为了解决这个问题,我们提出了 NestedSet 的概念。它是一个由其他 NestedSet 实例和自身的一些成员组成的数据结构,因此形成了一个集合的有向无环图。它们是不可变的,其成员可以迭代。我们定义了多个迭代顺序 (NestedSet.Order):preorder、postorder、拓扑(节点始终位于其祖先之后)和“随意,但每次都应该相同”。

同样的数据结构在 Starlark 中称为 depset

工件和操作

实际的构建操作包含一组命令,为了生成用户想要的输出,需要运行这些命令。命令表示为 Action 类的实例,文件表示为 Artifact 类的实例。它们排列在一个称为“动作图”的两部分有向无环图中。

工件分为两种:源工件(在 Bazel 开始执行之前就提供的工件)和派生工件(需要构建的工件)。派生工件本身可以有多种类型:

  1. **常规工件。**通过计算其校验和来检查它们是否是最新的,使用 mtime 作为快捷方式;如果文件的 ctime 未更改,我们不会对文件进行校验和。
  2. 未解析的符号链接工件。系统会通过调用 readlink() 检查这些文件是否是最新的。与常规工件不同,它们可以是悬挂符号链接。通常用于随后将一些文件打包到某种归档中的情形。
  3. 树工件。它们不是单个文件,而是目录树。系统会检查这些文件集及其内容,以检查它们是否是最新的。它们以 TreeArtifact 表示。
  4. 常量元数据工件 -对这些工件的更改不会触发重新构建。这专门用于构建戳记信息:我们不希望仅仅因为当前时间发生变化就重新构建。

没有根本原因为何源代码工件不能是树工件或未解析的符号链接工件,只是我们还没有实现它(不过,我们应该。在 BUILD 文件中引用源代码目录是 Bazel 长期存在的少数已知错误问题之一;我们有一种由 BAZEL_TRACK_SOURCE_DIRECTORIES=1 JVM 属性启用的实现)。

一种明显的 Artifact 类型是中间人。它们由 Artifact 实例(即 MiddlemanAction 的输出)指示。它们用于对某些情况进行特殊处理:

  • 聚合中间人用于将工件组合在一起。如此一来,如果许多操作使用相同的大量输入,我们就没有 N*M 个依存关系,只有 N+M 个(它们将被替换为嵌套集)
  • 调度依赖项中间人可确保某项操作先于另一项操作运行。它们主要用于 lint 检查,但也用于 C++ 编译(如需了解相关说明,请参阅 CcCompilationContext.createMiddleman()
  • Runfiles 中间程序用于确保 runfiles 树的存在,这样便无需单独依赖于输出清单以及 runfiles 树引用的每个工件。

最好将操作理解为需要运行的命令、操作所需的环境以及生成的一组输出。以下是操作说明的主要组成部分:

  • 需要运行的命令行
  • 它所需的输入工件
  • 需要设置的环境变量
  • 描述应用需要在其中运行的环境(如平台)的注解\

此外,还有一些其他特殊情况,例如写入 Bazel 已知内容的文件。它们是 AbstractAction 的子类。虽然 Java 和 C++ 都有自己的操作类型(JavaCompileActionCppCompileActionCppLinkAction),但大多数操作都是 SpawnActionStarlarkAction(同样,它们应该不是单独的类)。

我们最终希望将所有内容移至 SpawnActionJavaCompileAction 非常接近,但由于 .d 文件解析和包含扫描,C++ 有点特殊情况。

操作图主要“嵌入”到 Skyframe 图中:从概念上讲,操作的执行表示为 ActionExecutionFunction 的调用。ActionExecutionFunction.getInputDeps()Artifact.key() 中介绍了从操作图依赖关系边缘到 Skyframe 依赖关系边缘的映射,并进行了一些优化以确保 Skyframe 边缘数量保持在较低水平:

  • 派生工件没有自己的 SkyValue。而是使用 Artifact.getGeneratingActionKey() 来查找生成该密钥的操作所对应的键
  • 嵌套集都有自己的 Skyframe 键。

共享操作

有些操作是由多个已配置的目标生成的;Starlark 规则的限制更为严格,因为它们只能将其派生操作放入由其配置及其软件包决定的目录中(但同一软件包中的规则也会发生冲突),但在 Java 中实现的规则可以将派生工件放入任何位置。

这被认为是一个错误特征,但要去掉它却非常困难,因为这可以大大节省执行时间,例如,源文件需要以某种方式进行处理,并且该文件由多个规则引用 (Handwave-handwave)。这以一些 RAM 为代价:共享操作的每个实例都需要单独存储在内存中。

如果两项操作生成相同的输出文件,则它们必须完全相同:具有相同的输入、相同的输出并运行相同的命令行。这种等同关系在 Actions.canBeShared() 中实现,并通过查看每个 Action 在分析阶段和执行阶段之间进行验证。这是在 SkyframeActionExecutor.findAndStoreArtifactConflicts() 中实现的,它是 Bazel 中需要构建的“全局”视图的少数几个位置之一。

执行阶段

这是 Bazel 实际开始运行构建操作的时间,例如生成输出的命令。

在分析阶段之后,Bazel 首先确定需要构建哪些工件。其逻辑在 TopLevelArtifactHelper 中编码;大致来说,它是命令行中已配置目标的 filesToBuild 以及特殊输出组的内容,用于明确表示“如果此目标位于命令行上,则构建这些工件”。

下一步是创建执行根。由于 Bazel 可以选择从文件系统 (--package_path) 中的不同位置读取源代码软件包,因此它需要为本地执行的操作提供具有完整源代码树。此操作由 SymlinkForest 类处理,其工作原理是,记录分析阶段中使用的每个目标,并构建一个目录树,以符号链接每个软件包(从实际位置使用已用目标)。另一种方法是将正确的路径传递给命令(要考虑到 --package_path)。这种情况是不可取的,原因如下:

  • 当软件包从软件包路径条目移至另一个路径条目(过去经常发生)时,它会更改操作命令行
  • 如果某项操作远程运行与在本地运行,会产生不同的命令行
  • 它需要特定于所用工具的命令行转换(请考虑 Java 类路径和 C++ 包含路径等之间的区别)
  • 更改某项操作的命令行会使其操作缓存条目失效
  • 我们正在逐步稳步弃用 --package_path

然后,Bazel 开始遍历操作图(由操作及其输入和输出工件组成的两部分有向图)并运行操作。每个操作的执行由 SkyValueActionExecutionValue 的实例表示。

由于运行操作的开销很高,因此在 Skyframe 背后存在一些缓存层:

  • ActionExecutionFunction.stateMap 包含用于降低 ActionExecutionFunction 的 Skyframe 重启的数据。
  • 本地操作缓存包含有关文件系统状态的数据
  • 远程执行系统通常也包含自己的缓存

本地操作缓存

该缓存是位于 Skyframe 之后的另一个层;即使在 Skyframe 中重新执行某项操作,该操作仍有可能在本地操作缓存中发生命中。它表示本地文件系统的状态,并已序列化到磁盘,这意味着当启动新的 Bazel 服务器时,即使 Skyframe 图为空,您也可以获得本地操作缓存命中。

系统会使用 ActionCacheChecker.getTokenIfNeedToExecute() 方法检查此缓存是否存在命中。

与其名称相反,它是从派生工件的路径到发出工件的操作的映射。操作的说明如下:

  1. 该对象的输入和输出文件集及其校验和
  2. 它的“操作键”(通常是执行的命令行),但一般而言,表示输入文件的校验和未捕获的所有内容(例如,对于 FileWriteAction,它是所写入数据的校验和)

此外,还有一个高度实验性的“自上而下操作缓存”,它仍在开发中,它使用传递哈希来避免多次进入缓存。

输入发现和输入剪枝

有些操作比只有一组输入更复杂。操作输入集的更改有两种形式:

  • 操作可能会在执行之前发现新输入,或者确定操作的某些输入实际上不是必要的。这个规范化示例是 C++,最好根据 C++ 文件的传递闭包对 C++ 文件使用的头文件做出有根据的猜测,这样我们便不会留意将每个文件都发送给远程执行程序;因此,我们可以选择不将每个头文件注册为“输入”,但需要扫描源文件来传递性包含的头文件,我们只需在 #include 中将这些头文件标记为
  • 某项操作可能会意识到某些文件在执行过程中并未使用。在 C++ 中,这称为“.d 文件”:编译器会告知事后使用了哪些头文件,为了避免增量性能低于 Make 的尴尬,Bazel 利用了这一点。与包含扫描器相比,这种方法的估算效果更好,因为它依赖于编译器。

这些是通过针对 Action 的方法实现的:

  1. 调用 Action.discoverInputs()。它应该返回一组确定为必需的嵌套工件。这些指令必须是源工件,这样操作图中就没有在配置的目标图中没有等效项的依赖关系边缘。
  2. 通过调用 Action.execute() 执行操作。
  3. Action.execute() 结束时,操作可以调用 Action.updateInputs(),以告知 Bazel 并不需要它的所有输入。如果已使用的输入报告为未使用,则会导致增量 build 不正确。

当操作缓存返回对新的 Action 实例(例如在服务器重启后创建)的命中时,Bazel 会自行调用 updateInputs(),以便这组输入反映之前执行输入发现和删减的结果。

Starlark 操作可以利用该工具通过 ctx.actions.run()unused_inputs_list= 参数将某些输入声明为未使用。

运行操作的各种方法:策略/ActionContexts

某些操作可通过不同方式运行。例如,命令行可以在本地、本地(但在各种沙盒中)执行,也可以远程执行。体现这一点的概念称为 ActionContext(或 Strategy,因为我们只是成功重命名了一半...)

操作上下文的生命周期如下所示:

  1. 执行阶段开始时,系统会询问 BlazeModule 实例它们具有什么操作上下文。这发生在 ExecutionTool 的构造函数中。操作上下文类型由 Java Class 实例标识,该实例引用了 ActionContext 的子接口,并且操作上下文必须实现该接口。
  2. 系统会从可用操作上下文中选择适当的操作上下文,并将其转发到 ActionExecutionContextBlazeExecutor
  3. Action 使用 ActionExecutionContext.getContext()BlazeExecutor.getStrategy() 请求上下文(实际上应该只有一种方式来执行此操作...)

策略可随意调用其他策略来完成其工作;例如,这适用于在本地和远程启动操作,然后使用最先完成的操作的动态策略。

一种值得注意的策略是实现永久性工作器进程 (WorkerSpawnStrategy)。其思路是,某些工具的启动时间很长,因此应在操作之间重复使用,而不是对每个操作都重新启动一个工具(这确实意味着潜在的正确性问题,因为 Bazel 依赖于工作器进程的承诺,即它在单个请求之间不支持可观察的状态)

如果工具发生变化,则需要重启工作器进程。能否重复使用工作器取决于使用 WorkerFilesHash 计算所用工具的校验和。它依赖于知道该操作的哪些输入代表工具的一部分,哪些输入代表输入;这一点由 Action: Spawn.getToolFiles() 的创建者确定,Spawn 的 runfile 会被视为工具的一部分。

详细了解策略(或操作环境!):

  • 如需了解运行操作的各种策略,请点击此处
  • 如需查看动态策略的相关信息,请参阅此处
  • 如需了解在本地执行操作的复杂性,请点击此处

本地资源管理器

Bazel 可以并行运行多项操作。并行运行的本地操作的数量因操作而异:操作需要的资源越多,同时运行的实例就越少,以免本地机器过载。

这在 ResourceManager 类中实现:每个操作都必须以 ResourceSet 实例(CPU 和 RAM)的形式估算所需的本地资源进行注释。然后,当操作上下文执行需要本地资源的操作时,它们会调用 ResourceManager.acquireResources() 并被屏蔽,直到所需资源可用为止。

如需详细了解本地资源管理,请点击此处

输出目录的结构

每项操作都需要在输出目录中有一个单独的位置来存放其输出。派生工件的位置通常如下所示:

$EXECROOT/bazel-out/<configuration>/bin/<package>/<artifact name>

如何确定与特定配置关联的目录的名称?有两个相互冲突的理想属性:

  1. 如果同一 build 中可以出现两种配置,那么它们应具有不同的目录,以便两者可以有自己的同一操作版本;否则,如果这两种配置存在分歧,例如产生相同输出文件的操作的命令行等,则 Bazel 不知道该选择哪项操作(“操作冲突”)
  2. 如果两个配置表示“大致”相同的内容,它们应具有相同的名称,以便在命令行匹配时,可以在其中一个中执行的操作可以重复用于另一个:例如,更改 Java 编译器的命令行选项不应导致重新运行 C++ 编译操作。

到目前为止,我们还没有找到解决此问题的原则性方法,该方法与配置修剪问题相似。如需查看关于各个选项的详细讨论,请点击此处。 主要存在问题的方面是 Starlark 规则(其作者通常不太熟悉 Bazel)和切面,它们向可以生成“相同”输出文件的内容空间添加了另一个维度。

当前方法是,配置的路径段是添加了各种后缀的 <CPU>-<compilation mode>,以便在 Java 中实现的配置转换不会导致操作冲突。此外,还添加了 Starlark 配置转换集的校验和,以确保用户不会导致操作冲突。这还远远不够。这在 OutputDirectories.buildMnemonic() 中实现,并依赖于每个配置 fragment 将自己的部分添加到输出目录的名称中。

测试

Bazel 为运行测试提供了丰富的支持。它支持:

  • 远程运行测试(如果远程执行后端可用)
  • 并行运行测试多次(用于消除时间数据的不稳定或收集时间数据)
  • 对测试进行分片(将同一测试中的测试用例拆分到多个进程中,以加快速度)
  • 重新运行不稳定的测试
  • 将测试划分到多个测试套件

测试是具有 TestProvider 的常规配置目标,用于描述应如何运行测试:

  • 其构建导致运行测试的工件。这是一个包含序列化 TestResultData 消息的“缓存状态”文件
  • 应运行测试的次数
  • 应将测试拆分成的分片数
  • 关于如何运行测试的一些参数(例如测试超时)

确定要运行的测试

确定要运行哪些测试的过程相当复杂。

首先,在目标模式解析期间,以递归方式扩展测试套件。此扩展在 TestsForTargetPatternFunction 中实现。有点令人惊讶的是,如果测试套件未声明任何测试,它就会引用其软件包中的每个测试。这是通过向测试套件规则添加一个名为 $implicit_tests 的隐式属性在 Package.beforeBuild() 中实现的。

然后,根据命令行选项,按大小、标记、超时和语言过滤测试。这在 TestFilter 中实现,并在目标解析期间从 TargetPatternPhaseFunction.determineTests() 调用,并将结果放入 TargetPatternPhaseValue.getTestsToRunLabels()。可过滤的规则属性不可配置的原因在于,这种情况发生在分析阶段之前,因此配置不可用。

然后,在 BuildView.createResult() 中进一步处理此操作:系统会过滤掉分析失败的目标,并将测试拆分为独占测试和非独占测试。然后,将其放在 AnalysisResult 中,这是 ExecutionTool 知道要运行哪些测试的方式。

为了让这个复杂的过程更清楚地共享,可使用 tests() 查询运算符(在 TestsFunction 中实现)来判断在命令行中指定特定目标时要运行哪些测试。遗憾的是,这属于重新实现,因此它可能在多种细微方面与上述不同。

运行测试

运行测试的方式是请求缓存状态工件。然后,系统会执行 TestRunnerAction,最终会调用 --test_strategy 命令行选项所选的 TestActionContext,以按请求的方式运行测试。

测试根据详细的协议运行,该协议使用环境变量来告知测试的预期结果。如需详细了解 Bazel 对测试的要求,以及 Bazel 可以有哪些测试,请点击此处。简单来说,退出代码 0 表示成功,其他任何值都表示失败。

除了缓存状态文件之外,每个测试进程都会发出许多其他文件。它们位于“测试日志目录”中,这是目标配置的输出目录的 testlogs 子目录:

  • test.xml:一个 JUnit 样式的 XML 文件,详细说明了测试分片中的各个测试用例
  • test.log - 测试的控制台输出。stdout 和 stderr 不分隔。
  • test.outputs,“未声明的输出目录”;除了输出到终端的内容之外,还希望输出文件的测试使用该目录。

在构建常规目标期间,测试执行期间可能发生以下两种情况:独占测试执行和输出流式传输。

某些测试需要在独占模式下执行,例如,不能与其他测试并行执行。可通过向测试规则添加 tags=["exclusive"] 或使用 --test_strategy=exclusive 运行测试来引发该异常。每项独占测试均由单独的 Skyframe 调用运行,以请求在“主”build 之后执行测试。这在 SkyframeExecutor.runExclusiveTest() 中实现。

与在操作完成时转储终端输出的常规操作不同,用户可以请求流式传输测试的输出,以便获悉长时间运行的测试的进度。此设置由 --test_output=streamed 命令行选项指定,这意味着独占测试执行,因此不同测试的输出不会交错显示。

这在适当命名的 StreamedTestOutput 类中实现,其工作原理是轮询相关测试的 test.log 文件的更改,并将新字节转储到 Bazel 规则所在的终端。

通过观察各种事件(如 TestAttemptTestResultTestingCompleteEvent),已执行测试的结果会显示在事件总线中。这些结果会转储到构建事件协议,并由 AggregatingTestListener 发送到控制台。

覆盖率集合

覆盖率由文件 bazel-testlogs/$PACKAGE/$TARGET/coverage.dat 中的测试以 LCOV 格式报告。

为了收集覆盖率,每个测试作业都封装在一个名为 collect_coverage.sh 的脚本中。

此脚本会设置测试环境以启用覆盖率收集并确定覆盖率运行时写入覆盖率文件的位置。然后,它会运行测试。测试本身可以运行多个子进程,并由使用多种不同的编程语言编写的部分(具有单独的覆盖率收集运行时)组成。封装容器脚本负责在必要时将生成的文件转换为 LCOV 格式,并将它们合并为单个文件。

插入 collect_coverage.sh 由测试策略完成,并且需要 collect_coverage.sh 是测试的输入。这是通过隐式属性 :coverage_support 实现的,该属性会解析为配置标志 --coverage_support 的值(请参阅 TestConfiguration.TestOptions.coverageSupport

某些语言会进行离线插桩,这意味着覆盖率插桩是在编译时添加的(例如 C++),另一些语言则会进行在线插桩,这意味着覆盖率插桩是在执行时添加的。

另一个核心概念是基准覆盖率。这是库、二进制文件或测试(如果没有运行其中代码)的覆盖率。它可解决的问题是,如果您想计算二进制文件的测试覆盖率,则仅合并所有测试的覆盖率是不够的,因为二进制文件中可能有未关联到任何测试的代码。因此,我们所做的是为每个二进制文件发出覆盖率文件,该文件仅包含我们收集覆盖率的文件,没有被覆盖的行。目标的基准覆盖率文件位于 bazel-testlogs/$PACKAGE/$TARGET/baseline_coverage.dat。如果将 --nobuild_tests_only 标志传递给 Bazel,除了测试之外,系统也会为二进制文件和库生成该文件。

基准覆盖率目前已损坏。

我们会针对每条规则跟踪两组文件以收集覆盖率:一组插桩文件和插桩元数据文件集。

插桩文件集就是一组要插桩的文件。对于在线覆盖率运行时,您可以在运行时使用此参数来确定要插桩的文件。它还可用于实现基准覆盖率。

插桩元数据文件集是测试生成 Bazel 所需的 LCOV 文件所需的一组额外文件。在实践中,这包括特定于运行时的文件;例如,gcc 在编译期间会发出 .gcno 文件。 如果启用了覆盖模式,系统会将这些内容添加到测试操作输入集内。

是否收集覆盖率存储在 BuildConfiguration 中。这非常方便,因为这是一种根据该位更改测试操作和操作图的简单方法,但这也意味着,如果该位被翻转,则需要重新分析所有目标(某些语言(如 C++)需要不同的编译器选项来发出可以收集覆盖率的代码,这在一定程度上缓解了这个问题,因为无论如何,都需要重新分析。

覆盖率支持文件通过隐式依赖项中的标签依赖,因此可以被调用政策替换,这使得它们在不同版本的 Bazel 之间有所不同。理想情况下,可以消除这些差异,然后对其中一项指标进行标准化。

此外,我们还会生成“覆盖率报告”,该报告合并了针对 Bazel 调用中的每个测试收集的覆盖率。该操作由 CoverageReportActionFactory 处理,并从 BuildView.createResult() 调用。它会查看执行的第一个测试的 :coverage_report_generator 属性,以访问所需的工具。

查询引擎

Bazel 有一种少量语言,用于向它询问有关各种图表的各种信息。提供以下查询种类:

  • bazel query 用于调查目标图表
  • bazel cquery 用于调查已配置的目标图表
  • bazel aquery 用于调查操作图

其中每一项都通过对 AbstractBlazeQueryEnvironment 进行子类化来实现。您可以通过对 QueryFunction 进行子类化来完成其他额外的查询函数。为了支持流式查询结果,而不是将这些结果收集到某个数据结构中,系统会将 query2.engine.Callback 传递给 QueryFunction,后者会调用它来获取它想要返回的结果。

查询结果可以通过多种方式发出:标签、标签和规则类、XML、protobuf 等等。它们作为 OutputFormatter 的子类实现。

某些查询输出格式(当然是 proto)有一个细微的要求,即 Bazel 需要发出软件包加载提供的所有信息,以便可以比较输出并确定特定目标是否已更改。因此,属性值需要可序列化,这就是为什么只有少数属性类型没有任何具有复杂 Starlark 值的属性。通常的解决方法是使用标签,并将复杂信息附加到具有此标签的规则中。这不是一种非常令人满意的解决方法,因此最好解除此要求。

模块系统

可以通过向 Bazel 添加模块来扩展 Bazel。每个模块都必须创建 BlazeModule 的子类(该名称在 Bazel 以前称为 Blaze 时是历史遗迹),并获取有关执行命令期间各种事件的信息。

它们主要用于实现各种“非核心”功能,只有部分版本的 Bazel(比如我们在 Google 使用的 Bazel 版本)需要的功能:

  • 远程执行系统的接口
  • 新指令

BlazeModule 提供的这组扩展点有些混乱。请勿将其用作良好设计原则的示例。

事件总线

BlazeModule 与 Bazel 的其余部分通信的主要方式是通过事件总线 (EventBus) 实现的:系统会为每个 build 创建一个新实例,Bazel 的各个部分可以向该实例发布事件,而模块可以注册其感兴趣的事件的监听器。例如,以下内容表示为事件:

  • 已确定要构建的构建目标的列表 (TargetParsingCompleteEvent)
  • 已确定顶级配置 (BuildConfigurationEvent)
  • 目标构建成功与否 (TargetCompleteEvent)
  • 运行了一项测试(TestAttemptTestSummary

其中一些事件在 Bazel 外部的 Build Event Protocol 中表示(它们为 BuildEvent)。这不仅允许 BlazeModule,还允许 Bazel 进程外部的组件观察构建。事件处理方式可以是包含协议消息的文件,而 Bazel 可以连接到服务器(称为“构建事件服务”)来流式传输事件。

此要求在 build.lib.buildeventservicebuild.lib.buildeventstream Java 软件包中实现。

外部代码库

Bazel 最初设计为在 monorepo(单个源代码树包含构建所需的一切)中使用,而 Bazel 的面世不一定如此。“外部代码库”是用于连接这两个世界的抽象形式:它们表示构建所必需的但不在主源代码树中的代码。

WORKSPACE 文件

外部代码库集通过解析 WORKSPACE 文件确定。例如,以下声明:

    local_repository(name="foo", path="/foo/bar")

名为 @foo 的代码库中的结果可用。复杂的情况是,您可以在 Starlark 文件中定义新的代码库规则,这些规则随后可用于加载新的 Starlark 代码,用于定义新的代码库规则,等等...

为了处理这种情况,对 WORKSPACE 文件(在 WorkspaceFileFunction 中)的解析被拆分为由 load() 语句分隔的区块。分块索引由 WorkspaceFileKey.getIndex() 指示,并计算 WorkspaceFileFunction,直到索引 X 表示计算该索引,直到第 X 个 load() 语句为止。

提取代码库

fetched代码库的代码,然后 Bazel 才能使用代码库。这会导致 Bazel 在 $OUTPUT_BASE/external/<repository name> 下创建目录。

代码库提取操作的步骤如下:

  1. PackageLookupFunction 意识到它需要代码库,并创建一个 RepositoryName 作为 SkyKey,从而调用 RepositoryLoaderFunction
  2. 由于原因不明,RepositoryLoaderFunction 会将请求转发给 RepositoryDelegatorFunction(代码表明,这是为了避免在 Skyframe 重启时重新下载某些内容,但这不是很可靠的理由)
  3. RepositoryDelegatorFunction 遍历 WORKSPACE 文件的分块,直到找到请求的代码库,从而找出要求它提取的代码库规则
  4. 找到了实现代码库提取的适当 RepositoryFunction;它是代码库的 Starlark 实现,或者是以 Java 实现的代码库的硬编码映射。

由于提取代码库的成本可能非常高,因此存在各种缓存层:

  1. 已下载文件有一个缓存,由其校验和 (RepositoryCache) 进行键控。这要求工作区文件中有校验和,但这仍然有利于确保封闭性。同一工作站上的每个 Bazel 服务器实例都会共享 Bazel 服务器实例,无论它们在哪个工作区或输出基准中运行。
  2. 系统会在 $OUTPUT_BASE/external 下为每个代码库写入一个“标记文件”,其中包含用于提取该文件的规则的校验和。如果 Bazel 服务器重启,但校验和未更改,则不会重新提取该服务器。这在 RepositoryDelegatorFunction.DigestWriter 中实现。
  3. --distdir 命令行选项指定另一个缓存,用于查找要下载的工件。在企业环境中,Bazel 不应从互联网上随机提取内容,这会非常有用。这由 DownloadManager 实现。

下载代码库后,其中的工件会被视为源工件。这会带来一个问题,因为 Bazel 通常通过对源代码工件调用 stat() 来检查它们的最新状态,并且当这些工件所在的代码库的定义发生更改时,它们也会失效。因此,外部代码库中工件的 FileStateValue 需要依赖于其外部代码库。这由 ExternalFilesHelper 处理。

代码库映射

可能会发生这样的情况:多个代码库希望依赖于同一个代码库,但依赖于不同的版本(这就是“菱形依赖项问题”的一个实例)。例如,如果 build 中不同代码库中的两个二进制文件想要依赖于 Guava,可能会都引用 Guava(标签以 @guava// 开头),并预期这意味着它的不同版本。

因此,Bazel 允许一个文件重新映射外部代码库标签,以便字符串 @guava// 可以引用一个二进制文件的代码库(如 @guava1//)中的另一个 Guava 代码库(如 @guava2//)的代码库。

或者,您也可以使用此操作join菱形。如果一个代码库依赖于 @guava1//,而另一个代码库依赖于 @guava2//,则代码库映射允许一个代码库重新映射这两个代码库,以使用规范的 @guava// 代码库。

映射在 WORKSPACE 文件中指定为各个代码库定义的 repo_mapping 属性。然后,它作为 WorkspaceFileValue 的成员在 Skyframe 中显示,在以下位置:

  • Package.Builder.repositoryMapping,用于通过 RuleClass.populateRuleAttributeValues() 转换软件包中规则的标签值属性
  • 在分析阶段使用的 Package.repositoryMapping(用于解决未在加载阶段解析的 $(location) 等内容)
  • BzlLoadFunction,用于解析 load() 语句中的标签

JNI 位

Bazel 的服务器主要使用 Java 编写。例外情况是,Java 自身无法独立完成或我们在实现它时无法自行完成某些部分。这主要仅限于与文件系统的交互、进程控制以及各种其他低级别任务。

C++ 代码位于 src/main/native 下,而具有原生方法的 Java 类如下所示:

  • NativePosixFilesNativePosixFileSystem
  • ProcessUtils
  • WindowsFileOperationsWindowsFileProcesses
  • com.google.devtools.build.lib.platform

控制台输出

发出控制台输出看似很简单,但同时运行多个进程(有时是远程)、精细的缓存、想要获得一个漂亮的彩色终端输出以及拥有长时间运行的服务器,这让这件事变得非常重要。

RPC 调用从客户端传入后,系统会立即创建两个 RpcOutputStream 实例(用于 stdout 和 stderr),以将输出到这些实例的数据转发到客户端。然后,它们会封装在一个 OutErr(一个(stdout、stderr)对)中。需要在控制台中输出的所有内容都经过这些流。然后,这些流会传递到 BlazeCommandDispatcher.execExclusively()

默认情况下,输出使用 ANSI 转义序列输出。如果不需要 (--color=no),则会被 AnsiStrippingOutputStream 删除。此外,System.outSystem.err 会被重定向到这些输出流。这样,调试信息就可以使用 System.err.println() 输出,并且仍然显示在客户端的终端输出中(不同于服务器的输出)。请注意,如果某个进程生成二进制输出(例如 bazel query --output=proto),则不会发生 stdout 输出。

短消息(错误、警告等)通过 EventHandler 接口表示。值得注意的是,这些内容与发布到 EventBus 的内容不同(令人感到困惑)。每个 Event 都有一个 EventKind(错误、警告、信息等等),也可能有一个 Location(源代码中导致事件发生的位置)。

某些 EventHandler 实现会存储自身收到的事件。这用于将各种缓存处理导致的信息重放到界面,例如,由缓存的配置目标发出的警告。

有些 EventHandler 还允许发布最终会找到事件总线的事件(常规 Event 不会在此处出现)。这些是 ExtendedEventHandler 的实现,它们的主要用途是重放缓存的 EventBus 事件。这些 EventBus 事件都会实现 Postable,但并非发布到 EventBus 的所有内容都一定会实现此接口;只有那些由 ExtendedEventHandler 缓存的事件(这很不错,大多数情况都可以实现;不过,系统不会强制执行此接口)

终端输出主要通过 UiEventHandler 发出,后者负责 Bazel 执行的所有花哨的输出格式设置和进度报告。它有两项输入:

  • 事件总线
  • 通过 Reporter 传入该渠道的事件流

命令执行机器(例如 Bazel 的其余部分)与客户端的 RPC 流的唯一直接连接是通过 Reporter.getOutErr(),从而直接访问这些流。仅在命令需要转储大量可能的二进制数据(例如 bazel query)时使用。

对 Bazel 进行性能分析

Bazel 速度很快。Bazel 的运行速度也很慢,因为 build 往往会一直增长到可以接受的边缘。因此,Bazel 包含一个性能分析器,可用于分析 build 和 Bazel 本身。它是在名为 Profiler 的类中实现的。它默认启用,但只记录删节的数据以便可以容忍其开销;命令行 --record_full_profiler_data 会记录所有可能的信息。

它会以 Chrome 性能分析器格式发出配置文件;在 Chrome 中查看效果最佳。 它的数据模型是任务堆栈:一个可以启动任务和结束任务,并且它们应整齐地嵌套在彼此内。每个 Java 线程都有自己的任务堆栈。TODO:它如何与操作和接续传递样式配合使用?

性能分析器会在 BlazeRuntime.initProfiler()BlazeRuntime.afterCommand() 中分别启动和停止,并尝试尽可能长时间保持运行状态,以便我们可以对所有内容进行性能分析。如需向配置文件添加内容,请调用 Profiler.instance().profile()。它会返回一个 Closeable,其闭包表示任务的结束。最好与 try-with-resources 语句搭配使用。

我们还在 MemoryProfiler 中进行基本的内存性能分析。它也始终处于开启状态,主要会记录最大堆大小和 GC 行为。

测试 Bazel

Bazel 主要有两种测试类型:一种将 Bazel 视为“黑盒”;另一种测试仅运行分析阶段。我们将前者称为“集成测试”,后者称为“单元测试”,尽管它们更像是集成测试,但集成度较低。此外,我们还有一些实际的单元测试,在必要情况下也进行这些测试。

集成测试分为两种:

  1. 测试是使用 src/test/shell 下非常复杂的 bash 测试框架实现的
  2. 使用 Java 实现的实现。它们作为 BuildIntegrationTestCase 的子类实现

BuildIntegrationTestCase 是首选的集成测试框架,因为它能够很好地满足大多数测试场景。由于它是一个 Java 框架,因此提供可调试性以及与许多常用开发工具的无缝集成。Bazel 代码库中有许多 BuildIntegrationTestCase 类的示例。

分析测试作为 BuildViewTestCase 的子类实现。您可以使用临时文件系统来写入 BUILD 文件,然后各种辅助方法可以请求配置的目标、更改配置以及断言有关分析结果的各种内容。