Bazel 代码库

报告问题 查看来源 Nightly · 8.3 · 8.2 · 8.1 · 8.0 · 7.6

本文档介绍了代码库和 Bazel 的结构。它面向的是愿意为 Bazel 做出贡献的人,而不是最终用户。

简介

Bazel 的代码库很大(约 35 万行生产代码和约 26 万行测试代码),没有人熟悉整个代码库:每个人都非常了解自己特定的山谷,但很少有人知道各个方向的山丘后面是什么。

为了避免中途加入的人员迷失在黑暗的森林中,找不到直截了当的道路,本文档尝试概述代码库,以便更轻松地开始使用它。

Bazel 源代码的公开版本位于 GitHub 上,网址为 github.com/bazelbuild/bazel。这不是“事实来源”;它源自包含在 Google 外部无用的额外功能的 Google 内部源代码树。从长远来看,目标是将 GitHub 作为可靠来源。

我们接受通过常规 GitHub 拉取请求机制提交的贡献,并由 Google 员工手动将其导入到内部源代码树中,然后再重新导出到 GitHub。

客户端/服务器架构

Bazel 的大部分内容都位于一个在 build 之间保留在 RAM 中的服务器进程中。这样一来,Bazel 就可以在构建之间保持状态。

因此,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 刚刚构建的内容,但它无法从服务器进程执行此操作,因为服务器进程没有终端。因此,它会告知客户端应运行哪个二进制文件 exec() 以及使用哪些实参。

当用户按下 Ctrl-C 时,客户端会将其转换为 gRPC 连接上的 Cancel 调用,从而尽快终止命令。在第三次按 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 实例。

输出目录包含以下内容(但不限于此):

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

执行命令的过程

一旦 Bazel 服务器获得控制权并得知需要执行的命令,就会发生以下一系列事件:

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

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

  3. 系统会解析命令行选项。每条命令都有不同的命令行选项,这些选项在 @Command 注释中进行了说明。

  4. 创建事件总线。事件总线是构建期间发生的事件的流。其中一些在 Build Event Protocol 的保护下导出到 Bazel 之外,以便告知外界 build 的进展情况。

  5. 命令获取控制权。最有趣的命令是运行 build 的命令:build、test、run、coverage 等;此功能由 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++ include 扫描)是在执行阶段读取的,但这始终需要显式管道,因为 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 在单代码库上运行,也就是说,单个源代码树包含用于运行 build 的所有源代码。相比之下,Bazel 支持源代码跨多个代码库的项目。调用 Bazel 所基于的代码库称为“主代码库”,其他代码库称为“外部代码库”。

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

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

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

软件包

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

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

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

软件包加载期间的大部分复杂性都与 globbing 有关:Bazel 不要求明确列出每个源文件,而是可以运行 glob(例如 glob(["**/*.java"]))。与 shell 不同,它支持递归 glob,可以深入到子目录(但不能深入到子软件包)。这需要访问文件系统,但由于访问文件系统可能很慢,因此我们实现了各种技巧,以尽可能并行且高效地运行。

以下类中实现了 globbing:

  • LegacyGlobber,一种快速且完全不了解 Skyframe 的 globber
  • SkyframeHybridGlobber,一个使用 Skyframe 并恢复为旧版 globber 的版本,以避免“Skyframe 重启”(如下所述)

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

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

理想情况下,解析“外部”软件包与解析常规软件包之间应有更多分隔,这样 Package 就无需同时满足这两种需求。遗憾的是,由于这两者紧密交织在一起,因此很难做到这一点。

标签、目标和规则

软件包由目标组成,目标具有以下类型:

  1. 文件:build 的输入或输出。在 Bazel 术语中,我们称之为制品(将在其他地方讨论)。并非所有在 build 期间创建的文件都是目标;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 规则类需要在 BUILD 文件的开头使用 load() 语句进行导入,而 Java 规则类由于已注册到 ConfiguredRuleClassProvider,因此 Bazel 可以“自然而然”地识别它们。

规则类包含以下信息:

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

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

Skyframe

Bazel 的底层评估框架称为 Skyframe。其模型是,在 build 期间需要构建的所有内容都组织成一个有向无环图,其中边从任何数据块指向其依赖项,即构建该数据块需要了解的其他数据块。

图中的节点称为 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 使用 getState()StateMachine 来计算已配置目标可能存在的庞大直接依赖项集,否则可能会导致代价高昂的重新启动。

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

Starlark

Starlark 是一种领域专用语言,人们使用它来配置和扩展 Bazel。它被视为 Python 的受限子集,具有更少的类型、对控制流的更多限制,最重要的是,具有强大的不可变性保证,可实现并发读取。它不是图灵完备的,这会阻止部分(但并非所有)用户尝试在该语言中完成常规编程任务。

Starlark 在 net.starlark.java 软件包中实现。它还有一个独立的 Go 实现,请点击此处查看。Bazel 中使用的 Java 实现目前是解释器。

Starlark 可用于多种情境,包括:

  1. BUILD 文件。新 build 目标在此处定义。在此上下文中运行的 Starlark 代码只能访问 BUILD 文件本身的内容以及由该文件加载的 .bzl 文件。
  2. MODULE.bazel 文件。用于定义外部依赖项。在此上下文中运行的 Starlark 代码只能非常有限地访问一些预定义的指令。
  3. .bzl 文件。新 build 规则、repo 规则、模块扩展在此处定义。此处的 Starlark 代码可以定义新函数并从其他 .bzl 文件加载。

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

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

加载/分析阶段

在加载/分析阶段,Bazel 会确定构建特定规则所需的操作。它的基本单元是“已配置的目标”,顾名思义,它是一个(目标、配置)对。

之所以称为“加载/分析阶段”,是因为它可以分为两个不同的部分,这两个部分以前是串行化的,但现在可以在时间上重叠:

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

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

  1. 配置。(如何构建该规则;例如,目标平台,以及用户希望传递给 C++ 编译器的命令行选项等内容)
  2. 直接依赖项。其传递信息提供程序可用于正在分析的规则。之所以这样称呼,是因为它们提供了配置的目标的传递闭包中的信息汇总,例如 classpath 上的所有 .jar 文件或需要链接到 C++ 二进制文件中的所有 .o 文件)
  3. 目标本身。这是加载目标所在软件包的结果。对于规则,这包括其属性,而这通常才是最重要的。
  4. 已配置目标的实现。对于规则,这可以是 Starlark 或 Java。所有非规则配置的目标平台均在 Java 中实现。

分析已配置的目标的输出为:

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

提供给 Java 规则的 API 是 RuleContext,它相当于 Starlark 规则的 ctx 实参。其 API 功能更强大,但与此同时,也更容易做出“坏事”™,例如编写时间或空间复杂度为二次方(或更差)的代码,通过 Java 异常使 Bazel 服务器崩溃,或违反不变量(例如,无意中修改 Options 实例或使配置的目标可变)

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

配置

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

同一目标可以针对同一 build 中的多个配置进行构建。例如,当同一代码用于在 build 期间运行的工具和目标代码,并且我们正在进行交叉编译时,或者当我们正在构建一个胖 Android 应用(包含适用于多种 CPU 架构的原生代码)时,此功能非常有用

从概念上讲,配置是一个 BuildOptions 实例。不过,在实践中,BuildOptionsBuildConfiguration 封装,后者提供各种额外的功能。它从依赖关系图的顶部传播到底部。如果发生更改,则需要重新分析 build。

这会导致一些异常情况,例如,如果请求的测试运行次数发生变化(即使这只会影响测试目标),也必须重新分析整个 build(我们计划“修剪”配置,以便避免这种情况,但目前尚未准备就绪)。

如果规则实现需要部分配置,则需要在其定义中使用 RuleClass.Builder.requiresConfigurationFragments() 进行声明。这样做既是为了避免错误(例如 Python 规则使用 Java fragment),也是为了方便配置精简,以便在 Python 选项发生更改时,无需重新分析 C++ 目标。

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

  1. 在依赖关系边上。这些过渡在 Attribute.Builder.cfg() 中指定,是从 Rule(发生过渡的位置)和 BuildOptions(原始配置)到 BuildOptions(输出配置)的函数。
  2. 在任何指向已配置目标的入边上。这些参数在 RuleClass.Builder.cfg() 中指定。

相关类为 TransitionFactoryConfigurationTransition

配置转换的用途包括:

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

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

还可以在 Starlark 中实现配置转换(文档请参阅此处

传递信息提供方

传递信息提供程序是配置的目标了解其所依赖的其他配置的目标的方式(也是唯一的方式),也是配置的目标向依赖于自身的其他配置的目标告知自身信息的方式。之所以在名称中包含“传递性”,是因为这通常是配置的目标的传递闭包的某种汇总。

Java 传递信息提供程序与 Starlark 传递信息提供程序通常是一一对应的(例外情况是 DefaultInfo,它是 FileProviderFilesToRunProviderRunfilesProvider 的合并,因为该 API 被认为比直接音译的 Java API 更具 Starlark 风格)。其密钥是以下内容之一:

  1. Java 类对象。这仅适用于无法从 Starlark 访问的提供程序。这些提供程序是 TransitiveInfoProvider 的子类。
  2. 字符串。这是旧版功能,由于容易发生名称冲突,因此强烈不建议使用。此类传递性信息提供程序是 build.lib.packages.Info 的直接子类。
  3. 提供商符号。可以使用 provider() 函数从 Starlark 创建此对象,这是创建新提供程序的推荐方式。该符号在 Java 中由 Provider.Key 实例表示。

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

配置的目标

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

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

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

Runfiles

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

一组 runfile 表示为一个 Runfiles 实例。从概念上讲,它是一个从 runfiles 树中文件的路径到表示该文件的 Artifact 实例的映射。这比单个 Map 要复杂一些,原因有以下两点:

  • 在大多数情况下,文件的 runfiles 路径与其 execpath 相同。 我们使用此功能来节省一些 RAM。
  • 在 runfiles 树中,有各种旧版类型的条目也需要表示。

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

二进制文件的 runfile 表示为 RunfilesSupport 的实例。这与 Runfiles 不同,因为 RunfilesSupport 能够实际构建(与仅为映射的 Runfiles 不同)。这需要以下额外的组件:

  • 输入 runfiles 清单。这是对 runfiles 树的序列化描述。它用作 runfiles 树内容的代理,并且 Bazel 假设 runfiles 树仅在清单内容发生更改时才会发生更改。
  • 输出运行文件清单。处理 runfiles 树的运行时库(尤其是在有时不支持符号链接的 Windows 上)会使用此属性。
  • 用于运行 RunfilesSupport 对象所表示的二进制文件的命令行实参

切面

方面是一种“沿依赖关系图向下传播计算”的方式。有关这些标志的说明,请参阅面向 Bazel 用户的此处。一个很好的激励性示例是 Protocol Buffer:proto_library 规则不应了解任何特定语言,但在任何编程语言中构建 Protocol Buffer 消息(Protocol Buffer 的“基本单元”)的实现都应与 proto_library 规则相关联,以便如果同一语言中的两个目标依赖于同一 Protocol Buffer,则只会构建一次。proto_library

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

使用 Attribute.Builder.aspects() 函数为每个属性指定沿依赖关系图向下传播的方面集。有几个名称容易混淆的类参与了此过程:

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

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

AspectCollection 中捕获了方面对方面的复杂性。

平台和工具链

Bazel 支持多平台构建,即构建操作运行的架构可能不止一种,构建的代码所面向的架构也可能不止一种。在 Bazel 术语中,这些架构称为平台(完整文档请参阅此处

平台通过从限制设置(例如“CPU 架构”的概念)到限制值(例如特定 CPU,如 x86_64)的键值对映射来描述。我们在 @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()

这些规则是一种旧版机制,并未得到广泛使用。

所有 build 规则都可以声明它们可以针对哪些“环境”进行 build,其中“环境”是 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 命令行选项将 build 限制为特定环境。

限制检查的实现位于 RuleContextConstraintSemanticsTopLevelConstraintSemantics 中。

平台限制

目前描述目标平台兼容性的“官方”方式是使用与描述工具链和平台相同的限制条件。此功能是在拉取请求 #10945 中实现的。

公开范围

如果您与许多开发者(例如在 Google)一起处理大型代码库,则需要注意防止其他人随意依赖您的代码。否则,根据 Hyrum 定律,人们开始依赖您认为是实现细节的行为。

Bazel 通过称为公开范围的机制来支持此功能:您可以使用 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 规则编译或运行所需的 classpath 中的一组 .jar 文件
  • Python 规则的传递闭包中的 Python 文件集

如果我们采用简单的方式(例如使用 ListSet)来实现此目的,最终会导致内存使用量呈二次方增长:如果存在 N 条规则的链,并且每条规则都会添加一个文件,那么我们最终将有 1+2+...+N 个集合成员。

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

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

制品和操作

实际的 build 包含一组需要运行的命令,以生成用户所需的输出。命令表示为类 Action 的实例,文件表示为类 Artifact 的实例。它们以二分有向非循环图(称为“操作图”)的形式排列。

制品分为两种:源制品(在 Bazel 开始执行之前可用的制品)和派生制品(需要构建的制品)。派生制品的种类本身就有很多:

  1. 常规制品。这些文件的最新状态通过计算其校验和(以 mtime 作为快捷方式)来检查;如果文件的 ctime 没有变化,我们不会计算其校验和。
  2. 未解决的符号链接制品。系统会通过调用 readlink() 来检查这些工件是否为最新版本。与常规工件不同,这些工件可能是悬空符号链接。通常用于将一些文件打包到某种归档中的情况。
  3. 树状制品。这些不是单个文件,而是目录树。系统会检查其中的文件集及其内容,以确定它们是否为最新版本。它们表示为 TreeArtifact
  4. 常量元数据制品。对这些制品所做的更改不会触发重新构建。此属性仅用于 build stamp 信息:我们不希望仅因当前时间发生变化而重新构建。

从根本上来说,源制品并非不能是树制品或未解析的符号链接制品,只是我们尚未实现这一点(不过我们应该实现这一点 - 在 BUILD 文件中引用源目录是 Bazel 少数几个长期存在的已知不正确问题之一;我们有一个大致可行的实现,可通过 BAZEL_TRACK_SOURCE_DIRECTORIES=1 JVM 属性启用)

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

  • 需要运行的命令行
  • 所需的输入制品
  • 需要设置的环境变量
  • 用于描述需要运行的环境(例如平台)的注释 \

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

我们最终希望将所有内容都迁移到 SpawnActionJavaCompileAction 已经非常接近了,但由于 .d 文件解析和 include 扫描,C++ 有点特殊。

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

  • 派生制品没有自己的 SkyValue。而是使用 Artifact.getGeneratingActionKey() 来查找生成该操作的键
  • 嵌套集有自己的 Skyframe 键。

共享操作

某些操作由多个已配置的目标生成;Starlark 规则的限制更多,因为它们只能将其派生操作放入由其配置和软件包确定的目录中(但即便如此,同一软件包中的规则也可能会发生冲突),而以 Java 实现的规则可以将派生制品放入任何位置。

这被认为是一个错误功能,但要摆脱它非常困难,因为当需要以某种方式处理源文件并且该文件被多个规则引用时,它可以显著节省执行时间(手势)。但这样会占用一些 RAM:共享操作的每个实例都需要单独存储在内存中。

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

执行阶段

此时,Bazel 实际上会开始运行 build 操作,例如生成输出的命令。

在分析阶段之后,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() 方法检查此缓存是否命中。

与名称相反,它是从派生制品的路径到发出该制品的 action 的映射。相应操作的说明如下:

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

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

输入发现和输入剪枝

有些操作比只提供一组输入要复杂得多。对操作输入集所做的更改有两种形式:

  • 操作可能会在执行之前发现新的输入,或者确定某些输入实际上是不必要的。一个规范示例是 C++,最好从 C++ 文件的传递闭包中合理猜测该文件使用的头文件,这样我们就无需将每个文件都发送到远程执行程序;因此,我们可以选择不将每个头文件都注册为“输入”,而是扫描源文件以查找传递性包含的头文件,并仅将 #include 语句中提及的头文件标记为输入(我们进行过高估,因此无需实现完整的 C 预处理器)。此选项目前在 Bazel 中硬编码为“false”,仅在 Google 中使用。
  • 操作可能会发现,在执行期间,某些文件未被使用。在 C++ 中,这称为“.d 文件”:编译器事后会告知使用了哪些头文件,为了避免增量构建效果比 Make 差的尴尬情况,Bazel 会利用这一事实。与 include 扫描器相比,这种方法可提供更好的估计,因为它依赖于编译器。

这些操作通过 Action 的方法实现:

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

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

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

运行操作的各种方式:策略/ActionContext

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

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

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

策略可以自由调用其他策略来完成其工作;例如,动态策略会同时在本地和远程启动操作,然后使用先完成的操作。

一种值得注意的策略是实现持久性工作器进程 (WorkerSpawnStrategy)。这种策略的理念是,某些工具的启动时间较长,因此应在操作之间重复使用,而不是为每个操作重新启动一个(这确实存在潜在的正确性问题,因为 Bazel 依赖于工作器进程的承诺,即它不会在各个请求之间携带可观测的状态)

如果工具发生更改,则需要重启工作器进程。工作器是否可重复使用取决于使用 WorkerFilesHash 计算所用工具的校验和。它依赖于了解操作的哪些输入表示工具的一部分,哪些表示输入;这由操作的创建者确定: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),可以在事件总线上获取已执行测试的结果。这些结果会转储到 Build Event Protocol,并由 AggregatingTestListener 发送到控制台。

覆盖面收集

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

为了收集覆盖率,每次测试执行都封装在一个名为 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,但建议规则生成自己的基准覆盖率文件,其中包含比源文件名更有意义的内容。

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

已插桩的文件集就是一组要插桩的文件。对于在线覆盖率运行时,可以在运行时使用此功能来决定要检测哪些文件。它还用于实现基准覆盖率。

插桩元数据文件集是指测试生成 LCOV 文件(Bazel 需要)所需的一组额外文件。实际上,这包括特定于运行时的文件;例如,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 添加模块来扩展它。每个模块都必须继承 BlazeModule(该名称是 Bazel 过去称为 Blaze 时的遗留名称),并在命令执行期间获取有关各种事件的信息。

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

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

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

事件总线

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

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

其中一些事件在 Bazel 之外通过build 事件协议表示(它们是 BuildEvent)。这样一来,不仅 BlazeModule,而且 Bazel 进程之外的事物也能观察到 build。它们可以作为包含协议消息的文件进行访问,也可以通过 Bazel 连接到服务器(称为 Build Event Service)来流式传输事件。

这是在 build.lib.buildeventservicebuild.lib.buildeventstream Java 软件包中实现的。

外部代码库

虽然 Bazel 最初设计用于单代码库(包含构建所需的一切内容的单个源代码树),但 Bazel 如今所处的环境并非一定如此。“外部代码库”是一种用于连接这两个世界的抽象概念:它们表示构建所需的代码,但这些代码不在主源代码树中。

WORKSPACE 文件

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

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

存储区中名为 @foo 的结果可供使用。复杂之处在于,您可以在 Starlark 文件中定义新的代码库规则,然后使用这些规则加载新的 Starlark 代码,而这些代码又可用于定义新的代码库规则,依此类推…

为了处理这种情况,WORKSPACE 文件(在 WorkspaceFileFunction 中)的解析会拆分为由 load() 语句分隔的多个块。块索引由 WorkspaceFileKey.getIndex() 表示,计算 WorkspaceFileFunction 直到索引 X 意味着评估它直到第 X 个 load() 语句。

提取代码库

在代码库的代码可供 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) 为键。这要求校验和在 WORKSPACE 文件中可用,但无论如何,这都有利于密封性。同一工作站上的每个 Bazel 服务器实例都会共享此目录,无论它们在哪个工作区或输出库中运行。
  2. 系统会为 $OUTPUT_BASE/external 下的每个代码库写入一个“标记文件”,其中包含用于提取相应代码库的规则的校验和。如果 Bazel 服务器重启但校验和未更改,则不会重新提取。这是在 RepositoryDelegatorFunction.DigestWriter 中实现的。
  3. --distdir 命令行选项用于指定另一个缓存,该缓存用于查找要下载的制品。这在企业设置中非常有用,因为 Bazel 不应从互联网上提取随机内容。这是通过 DownloadManager 实现的。

下载存储库后,其中的制品将被视为源制品。这会带来一个问题,因为 Bazel 通常会通过对源制品调用 stat() 来检查其是否为最新版本,并且当包含这些制品的代码库的定义发生更改时,这些制品也会失效。因此,外部代码库中制品的 FileStateValues 需要依赖于其外部代码库。这由 ExternalFilesHelper 处理。

代码库映射

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

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

或者,您也可以使用此功能加入钻石。如果一个代码库依赖于 @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),系统会注意不更改标准输出。

简短的消息(错误、警告等)通过 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 的速度也很慢,因为构建往往会不断增长,直到达到可承受的极限。为此,Bazel 包含一个分析器,可用于分析 build 和 Bazel 本身。它是在一个名为 Profiler 的类中实现的。此功能默认处于开启状态,但仅记录简略数据,因此开销可以接受;命令行 --record_full_profiler_data 会让它记录所有可以记录的数据。

它会以 Chrome 分析器格式发出配置文件;最好在 Chrome 中查看。 其数据模型是任务堆栈:用户可以开始任务和结束任务,并且这些任务应该整齐地嵌套在一起。每个 Java 线程都有自己的任务堆栈。待办事项:这如何与操作和延续传递样式协同运作?

性能剖析器分别在 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 文件,然后各种辅助方法可以请求配置的目标、更改配置并断言分析结果的各种内容。