本文档介绍了代码库以及 Bazel 的结构。它适用于愿意为 Bazel 贡献代码的人员,而不是最终用户。
简介
Bazel 的代码库很大(大约 35 万行生产代码和 26 万行测试代码),没有人熟悉整个环境:每个人都非常了解自己所在的特定山谷,但很少有人知道各个方向的山丘上有什么。
为了避免在探索过程中迷失在黑暗的森林中,本文档会尝试简要介绍该代码库,以便您更轻松地开始使用它。
Bazel 源代码的公开版本位于 GitHub 上的 github.com/bazelbuild/bazel。这不是“可信来源”;它从 Google 内部源代码树衍生而来,其中包含在 Google 之外无用的附加功能。我们的长期目标是将 GitHub 打造为可信来源。
通过常规 GitHub 拉取请求机制接受贡献内容,然后由 Google 员工手动将其导入内部源代码树,再重新导出回 GitHub。
客户端/服务器架构
大部分 Bazel 位于一个服务器进程中,该进程在两次构建之间保留在 RAM 中。这样,Bazel 就可以在构建之间维护状态。
因此,Bazel 命令行有两种选项:启动选项和命令选项。在类似如下的命令行中:
bazel --host_jvm_args=-Xmx8G build -c opt //foo:bar
有些选项 (--host_jvm_args=
) 位于要运行的命令名称之前,有些选项 (-c opt
) 位于后面;前者称为“启动选项”,会影响整个服务器进程,而后者称为“命令选项”,只会影响单个命令。
每个服务器实例都有一个关联的源代码树(“工作区”),每个工作区通常只有一个处于活动状态的服务器实例。您可以通过指定自定义输出基准来规避此问题(如需了解详情,请参阅“目录布局”部分)。
Bazel 作为单个 ELF 可执行文件进行分发,该可执行文件也是有效的 .zip 文件。
当您输入 bazel
时,上述以 C++ 实现的 ELF 可执行文件(“客户端”)会获得控制权。它会按照以下步骤设置适当的服务器进程:
- 检查它是否已自行解压缩。如果没有,则会执行此操作。服务器的实现就来自于此。
- 检查是否存在正常运行的活跃服务器实例:它正在运行、具有正确的启动选项并使用正确的工作区目录。它会通过查看目录
$OUTPUT_BASE/server
来查找正在运行的服务器,该目录中包含包含服务器监听的端口的锁定文件。 - 如有必要,终止旧服务器进程
- 根据需要启动新的服务器进程
适当的服务器进程准备就绪后,系统会通过 gRPC 接口与其通信,以便传达需要运行的命令,然后将 Bazel 的输出管道化回终端。一次只能运行一个命令。这是使用复杂的锁定机制实现的,该机制中的部分采用 C++ 语言,部分采用 Java 语言。由于无法将 bazel version
与其他命令并行运行,因此我们提供了一些基础架构来并行运行多个命令。主要阻碍因素是 BlazeModule
的生命周期和 BlazeRuntime
中的某些状态。
在命令结束时,Bazel 服务器会传输客户端应返回的退出代码。bazel run
的实现有一个有趣的差异:此命令的作用是运行 Bazel 刚刚构建的内容,但它无法通过服务器进程执行这项操作,因为它没有终端。因此,它会告知客户应使用哪个二进制文件执行 ujexec()
以及使用哪些参数。
当用户按 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
,尽管这是一项长期计划,因为它是一项非常不兼容的更改。 - 构建期间构建的文件。
执行命令的过程
当 Bazel 服务器获得控制权并获知其需要执行的命令后,会发生以下事件序列:
BlazeCommandDispatcher
会收到有关新请求的信息。它决定命令是否需要在工作区中运行(除了与源代码无关的命令,例如版本或帮助之外,几乎所有命令都需要在工作区中运行),以及是否有其他命令正在运行。找到了正确的命令。每个命令都必须实现接口
BlazeCommand
,并且必须具有@Command
注解(这有点反面,如果一个命令需要的所有元数据都通过BlazeCommand
中的方法进行描述就更好了)系统会解析命令行选项。每个命令都有不同的命令行选项,这些选项在
@Command
注解中进行了介绍。系统会创建一个事件总线。事件总线是构建期间发生的事件的数据流。其中一些事件会在 Build Event Protocol 的管理下导出到 Bazel 之外,以便向世界展示构建进度。
该命令会获得控制权。最有趣的命令是运行 build 的命令:构建、测试、运行、覆盖率等:此功能由
BuildTool
实现。系统会解析命令行上的一组目标模式,并解析
//pkg:all
和//pkg/...
等通配符。这在AnalysisPhaseRunner.evaluateTargetPatterns()
中实现,并在 Skyframe 中作为TargetPatternPhaseValue
重新实现。系统会运行加载/分析阶段,以生成操作图(包含构建所需执行的命令的有向无环图)。
运行执行阶段。这意味着,系统会运行构建请求的顶级目标所需的每项操作。
命令行选项
Bazel 调用的命令行选项在 OptionsParsingResult
对象中描述,该对象包含从“选项类”到选项值的映射。“选项类”是 OptionsBase
的子类,用于将彼此相关的命令行选项归为一组。例如:
- 与编程语言 (
CppOptions
或JavaOptions
) 相关的选项。这些选项应为FragmentOptions
的子类,并最终封装到BuildOptions
对象中。 - 与 Bazel 执行操作的方式相关的选项 (
ExecutionOptions
)
这些选项旨在供分析阶段使用(通过 Java 中的 RuleContext.getFragment()
或 Starlark 中的 ctx.fragments
)。其中一些(例如是否执行 C++ 包含扫描)会在执行阶段读取,但由于 BuildConfiguration
在该阶段不可用,因此始终需要显式管道。如需了解详情,请参阅“配置”部分。
警告:我们喜欢假装 OptionsBase
实例不可变,并以这种方式使用它们(例如作为 SkyKeys
的一部分)。事实并非如此,修改它们是破坏 Bazel 的绝佳方式,因为它们会以难以调试的微妙方式破坏 Bazel。遗憾的是,要使它们实际上不可变是一项艰巨的努力。(在构建后立即修改 FragmentOptions
是可以的,前提是其他任何人都没有机会保留对它的引用,并且在对它调用 equals()
或 hashCode()
之前。)
Bazel 通过以下方式了解选项类:
- 有些已硬编码到 Bazel (
CommonCommandOptions
) - 通过每个 Bazel 命令上的
@Command
注解 - 通过
ConfiguredRuleClassProvider
(这些是与各个编程语言相关的命令行选项) - Starlark 规则还可以定义自己的选项(请参阅此处)
每个选项(不包括 Starlark 定义的选项)都是具有 @Option
注解的 FragmentOptions
子类的成员变量,该注解用于指定命令行选项的名称和类型以及一些帮助文本。
命令行选项值的 Java 类型通常比较简单(字符串、整数、布尔值、标签等)。不过,我们还支持更复杂类型的选项;在这种情况下,从命令行字符串转换为数据类型的工作将落到 com.google.devtools.common.options.Converter
的实现上。
Bazel 看到的源代码树
Bazel 的用途是构建软件,通过读取和解释源代码来实现。Bazel 操作的所有源代码称为“工作区”,它被划分为代码库、软件包和规则。
代码库
“代码库”是开发者工作的源代码树;它通常代表单个项目。Bazel 的祖先 Blaze 在单一代码库(即包含用于运行 build 的所有源代码的单个源代码树)上运行。而 Bazel 支持源代码跨多个代码库的项目。调用 Bazel 的代码库称为“主代码库”,其他代码库称为“外部代码库”。
代码库由其根目录中名为 WORKSPACE
(或 WORKSPACE.bazel
)的文件标记。此文件包含对整个 build 而言是“全局”的信息,例如一组可用的外部代码库。它像普通的 Starlark 文件一样运行,这意味着您可以 load()
其他 Starlark 文件。这通常用于拉取被明确引用的仓库所需的仓库(我们称之为“deps.bzl
模式”)
外部代码库的代码会在 $OUTPUT_BASE/external
下建立符号链接或下载。
运行 build 时,需要将整个源代码树拼接在一起;这由 SymlinkForest
完成,它会将主代码库中的每个软件包符号链接到 $EXECROOT
,并将每个外部代码库符号链接到 $EXECROOT/external
或 $EXECROOT/..
(当然,前者会导致主代码库中无法包含名为 external
的软件包;因此,我们正在从中迁移)
软件包
每个代码库都由软件包、一组相关文件和依赖项规范组成。这些值由名为 BUILD
或 BUILD.bazel
的文件指定。如果这两种文件同时存在,Bazel 会优先选择 BUILD.bazel
;之所以仍接受 BUILD
文件,是因为 Bazel 的祖先 Blaze 使用了此文件名。不过,它后来成为了常用的路径片段,尤其是在 Windows 上,因为 Windows 不区分大小写。
软件包彼此独立:对某个软件包的 BUILD
文件所做的更改不会导致其他软件包发生更改。添加或移除 BUILD
文件可能会更改其他软件包,因为递归正则表达式会在软件包边界处停止,因此 BUILD
文件的存在会停止递归。
对 BUILD
文件的评估称为“软件包加载”。它在 PackageFactory
类中实现,通过调用 Starlark 解释器来工作,并且需要了解一组可用规则类。软件包加载的结果是一个 Package
对象。它通常是从字符串(目标名称)到目标本身的映射。
软件包加载过程中的大部分复杂性都来自全局通配:Bazel 不需要明确列出每个源文件,而是可以运行全局通配(例如 glob(["**/*.java"])
)。与 shell 不同,它支持递归全局通配,可深入子目录(但不能深入子软件包)。这需要访问文件系统,由于该过程可能速度很慢,因此我们实施了各种技巧,以使其尽可能并行运行。
以下类中实现了全局替换:
LegacyGlobber
,一个对 SkyFrame 一无所知的快速而欢快的星球SkyframeHybridGlobber
,此版本使用 Skyframe 并会回退到旧版全局变量,以避免“Skyframe 重启”(详见下文)
Package
类本身包含一些成员,这些成员专用于解析 WORKSPACE 文件,对真实软件包而言没有意义。这是一个设计缺陷,因为描述常规软件包的对象不应包含描述其他内容的字段。其中包括:
- 代码库映射
- 已注册的工具链
- 已注册的执行平台
理想情况下,解析 WORKSPACE 文件与解析常规软件包之间应有更明确的分隔,这样 Package
就不必同时满足这两种需求。很遗憾,这很难做到,因为这两者之间存在着非常紧密的联系。
标签、目标和规则
软件包由目标组成,目标有以下类型:
- 文件:build 的输入或输出内容。在 Bazel 的角度,我们将它们称为工件(在别处讨论)。在构建期间创建的文件并非全部都是目标;Bazel 的输出通常没有关联的标签。
- 规则:用于描述从输入中提取输出的步骤。它们通常与编程语言(例如
cc_library
、java_library
或py_library
)相关联,但也有一些与语言无关的函数(例如genrule
或filegroup
) - 文件包组:详见公开范围部分。
目标的名称称为标签。标签的语法为 @repo//pac/kage:name
,其中 repo
是标签所在的代码库的名称,pac/kage
是其 BUILD
文件所在的目录,name
是相对于软件包目录的文件路径(如果标签引用的是源文件)。在命令行中引用目标时,可以省略标签的某些部分:
- 如果省略代码库,则标签会被视为位于主代码库中。
- 如果省略软件包部分(例如
name
或:name
),系统会将标签视为位于当前工作目录的软件包中(不允许包含上级引用 (..) 的相对路径)
一种规则(如“C++ 库”)称为“规则类”。规则类可以在 Starlark(rule()
函数)或 Java 中实现(所谓的“原生规则”,类型为 RuleClass
)。从长远来看,每个特定于语言的规则都将在 Starlark 中实现,但一些旧版规则系列(例如 Java 或 C++)目前仍在 Java 中实现。
需要在 BUILD
文件开头使用 load()
语句导入 Starlark 规则类,而 Java 规则类因已向 ConfiguredRuleClassProvider
注册而被 Bazel“天生”知晓。
规则类包含以下信息:
- 其属性(例如
srcs
、deps
):类型、默认值、约束条件等。 - 附加到每个属性的配置转换和方面(如果有)
- 规则的实现
- 规则“通常”创建的传递信息提供程序
术语说明:在代码库中,我们经常使用“Rule”来表示由规则类创建的目标。但在 Starlark 和面向用户的文档中,“规则”应仅用于指代规则类本身;目标只是一个“目标”。另请注意,尽管 RuleClass
的名称中包含“class”,但规则类与该类型的目标之间没有 Java 继承关系。
Skyframe
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 的缓存和增量计算是在 SkyFunction
和 SkyValue
的粒度运行的。
每当 SkyFunction
请求不可用的依赖项时,getValue()
都会返回 null。然后,该函数应自行返回 null,将控制权交还给 Skyframe。稍后,Skyframe 会评估不可用的依赖项,然后从头开始重启函数 - 不过这次 getValue()
调用将会成功并返回非 null 结果。
因此,必须重复在重启之前在 SkyFunction
中执行的任何计算。但这不包括评估依赖项 SkyValues
所执行的工作,因为这些工作会被缓存。因此,我们通常通过以下方式解决此问题:
- 批量声明依赖项(使用
getValuesAndExceptions()
),以限制重启次数。 - 将
SkyValue
拆分为由不同的SkyFunction
计算的各个部分,以便它们可以独立计算和缓存。由于此操作可能会增加内存用量,因此应有策略地进行。 - 在重新启动之间存储状态,方法是使用
SkyFunction.Environment.getState()
或在“Skyframe 背后”保留临时静态缓存。
从根本上讲,我们需要这些类型的权宜解决方法,因为我们通常有数十万个正在执行的 Skyframe 节点,而 Java 不支持轻量级线程。
Starlark
Starlark 是一种领域专用语言,用于配置和扩展 Bazel。它被视为 Python 的受限子集,其类型要少得多,对控制流的限制更多,而且最重要的不可变性保证能够实现并发读取。这并非图灵完备,这会阻止部分(但不是全部)用户尝试使用某种语言完成常规编程任务。
Starlark 在 net.starlark.java
软件包中实现。它还具有独立的 Go 实现(点击此处)。Bazel 中使用的 Java 实现目前是一种解释器。
Starlark 可用于多种环境,包括:
BUILD
语言。您可以在此处定义新规则。在此上下文中运行的 Starlark 代码只能访问BUILD
文件本身的内容以及由其加载的.bzl
文件。- 规则定义。这就是定义新规则(例如支持新语言)的方式。在此上下文中运行的 Starlark 代码可以访问其直接依赖项提供的配置和数据(稍后会详细介绍)。
- WORKSPACE 文件。这是定义外部代码库(非主源代码树中的代码)的位置。
- 代码库规则定义。您可以在此处定义新的外部代码库类型。在此环境中运行的 Starlark 代码可以在运行 Bazel 的机器上运行任意代码,并且无法访问工作区。
适用于 BUILD
和 .bzl
文件的方言略有不同,因为它们表示不同的内容。如需查看差异列表,请点击此处。
如需详细了解 Starlark,请点击此处。
加载/分析阶段
在加载/分析阶段,Bazel 会确定构建特定规则所需执行的操作。它的基本单位是“配置目标”,也就是(目标、配置)对,非常合理。
之所以称为“加载/分析阶段”,是因为它可以拆分为两个不同的部分,这两个部分过去是序列化的,但现在它们的时间可能会重叠:
- 加载软件包,即将
BUILD
文件转换为代表它们的Package
对象 - 分析配置的目标,即运行规则实现以生成操作图
必须自下而上分析命令行上请求的已配置目标的传递闭包中的每个已配置目标;即,先分析叶节点,然后再分析命令行上的目标。分析单个已配置目标的输入包括:
- 配置。(“如何”构建该规则;例如目标平台,以及用户希望传递给 C++ 编译器的命令行选项等)
- 直接依赖项。其传递性信息提供程序可供所分析规则使用。之所以这样称呼它们,是因为它们会提供配置目标的传递闭包中信息的“汇总”,例如类路径中的所有 .jar 文件或需要关联到 C++ 二进制文件的所有 .o 文件
- 目标本身。这是加载目标所在软件包的结果。对于规则,这包括其属性,这通常是最重要的。
- 已配置目标的实现。对于规则,可以使用 Starlark 或 Java 代码。所有非规则配置的目标均在 Java 中实现。
分析已配置的目标的输出如下所示:
- 配置了依赖于它的目标的传递信息提供程序可以访问
- 它可以创建的工件以及生成这些工件的操作。
为 Java 规则提供的 API 是 RuleContext
,相当于 Starlark 规则的 ctx
参数。它的 API 更强大,但同时也更容易做“坏事”™,例如编写时间或空间复杂度为二次方(或更糟糕)的代码,导致 Bazel 服务器因 Java 异常而崩溃,或者违反不变性(例如无意中修改 Options
实例或使已配置的目标可变)
用于确定已配置目标的直接依赖项的算法位于 DependencyResolver.dependentNodeMap()
中。
配置
配置是构建目标的“方式”:针对哪个平台、使用哪些命令行选项等。
在同一个 build 中,可以针对多个配置构建相同的目标。例如,当我们在构建期间运行的工具和目标代码使用相同的代码时,并且我们进行交叉编译,或者在构建胖 Android 应用(包含适用于多种 CPU 架构的原生代码)时,这种方法非常有用
从概念上讲,配置是 BuildOptions
实例。不过,在实践中,BuildOptions
由 BuildConfiguration
封装,BuildConfiguration
提供其他各种各样的功能。它从依赖关系图的顶部传播到底部。如果发生更改,则需要重新分析 build。
这会导致异常情况,例如,当请求的测试运行数量发生变化时,即使这仅影响测试目标,也必须重新分析整个 build,即使这仅影响测试目标(我们计划“削减”配置,以避免情况并非如此,但尚未做好准备)。
当规则实现需要部分配置时,需要在其定义中使用 RuleClass.Builder.requiresConfigurationFragments()
声明该配置。这既是为了避免错误(例如使用 Java fragment 的 Python 规则),也是为了简化配置修剪,以便在 Python 选项发生变化时,无需重新分析 C++ 目标。
规则的配置不一定与其“父级”规则的配置相同。在依赖项边缘更改配置的过程称为“配置转换”。这可能发生在以下两个位置:
- 在依赖项边上。这些转换在
Attribute.Builder.cfg()
中指定,是从Rule
(转换发生的位置)和BuildOptions
(原始配置)到一个或多个BuildOptions
(输出配置)的函数。 - 在连接到配置目标的任何传入边缘上。这些方法在
RuleClass.Builder.cfg()
中指定。
相关类为 TransitionFactory
和 ConfigurationTransition
。
使用配置转换,例如:
- 声明在构建期间使用了特定依赖项,因此应在执行架构中构建该依赖项
- 声明必须针对多个架构(例如针对胖 Android APK 中的原生代码)构建特定依赖项
如果配置转换会导致多个配置,则称为分屏转换。
还可以在 Starlark 中实现配置转换(点击此处查看文档)
传递性信息提供程序
传递性信息提供程序是配置的目标告知依赖于它的其他配置目标的相关信息的一种方式(也是唯一的方式)。名称中之所以使用“传递”一词,是因为这通常是对已配置目标的传递闭包的某种汇总。
Java 传递性信息提供程序和 Starlark 传递性信息提供程序之间通常是一对一对应关系(DefaultInfo
是 FileProvider
、FilesToRunProvider
和 RunfilesProvider
的混合,因为该 API 被认为更像 Starlark,而不是 Java 的直接转写)。其密钥是以下其中一项:
- Java 类对象。此功能仅适用于无法通过 Starlark 访问的提供商。这些提供程序是
TransitiveInfoProvider
的子类。 - 字符串。这是旧版系统,强烈建议不要使用,因为它容易出现名称冲突。此类传递性信息提供程序是
build.lib.packages.Info
的直接子类。 - 提供商符号。这可以使用
provider()
函数从 Starlark 创建,这是创建新的提供程序的推荐方法。该符号在 Java 中由Provider.Key
实例表示。
应使用 BuiltinProvider
实现以 Java 语言实现的新提供程序。NativeProvider
已废弃(我们还没有时间将其移除),并且无法从 Starlark 访问 TransitiveInfoProvider
子类。
配置的目标
配置的目标会实现为 RuleConfiguredTargetFactory
。在 Java 中实现的每个规则类都有一个子类。Starlark 配置的目标是通过 StarlarkRuleConfiguredTargetUtil.buildRule()
创建的。
已配置的目标工厂应使用 RuleConfiguredTargetBuilder
来构建其返回值。它包含以下内容:
- 其
filesToBuild
,即“此规则所代表的一组文件”这一模糊概念。这些是配置的目标位于命令行或 genrule 的 src 中时构建的文件。 - 其运行文件(常规文件和数据文件)。
- 其输出组。这些是规则可以构建的各种“其他文件集”。您可以使用 BUILD 中 filegroup 规则的 output_group 属性以及 Java 中的
OutputGroupInfo
提供程序访问它们。
Runfile
某些二进制文件需要数据文件才能运行。一个典型的例子是需要输入文件的测试。在 Bazel 中,这通过“runfile”概念表示。“runfiles 树”是特定二进制文件的数据文件的目录树。它是在文件系统中创建的符号链接树,具有各个符号链接指向输出树中的文件。
一组 Runfile 表示为一个 Runfiles
实例。从概念上讲,它是从 runfiles 树中某个文件的路径到表示该文件的 Artifact
实例的映射。它比单个 Map
复杂一些,原因有二:
- 在大多数情况下,文件的 runfiles 路径与其 execpath 相同。我们用它来节省一些内存。
- runfile 树中存在各种旧式条目,也需要进行表示。
运行文件是使用 RunfilesProvider
收集的:此类的实例代表配置的目标(例如库)及其传递闭包所需的运行文件,并且它们像嵌套集一样收集(实际上,它们是在后台使用嵌套集实现的):每个目标都会合并其依赖项的运行文件,添加一些自己的运行文件,然后将生成的集向上发送到依赖项图中。一个 RunfilesProvider
实例包含两个 Runfiles
实例,一个用于通过“data”属性依赖于规则的情况,另一个用于每种其他类型的传入依赖项。这是因为,通过数据属性依赖于目标时,目标有时会显示不同的 runfile。这是我们尚未移除的不需要的旧版行为。
二进制文件的 runfile 表示为 RunfilesSupport
的实例。这与 Runfiles
不同,因为 RunfilesSupport
能够实际构建(而 Runfiles
只是一个映射)。这需要以下额外组件:
- 输入 runfile 清单。这是 runfiles 树的序列化说明。它用作 runfiles 树内容的代理,并且 Bazel 假定只有在清单内容发生更改时,runfiles 树才会发生更改。
- 输出 runfile 清单。这由处理 runfile 树的运行时库使用,特别是在 Windows 上,它有时不支持符号链接。
- Runfile 中间人。为了让 runfiles 树存在,需要构建符号链接树以及符号链接指向的工件。为了减少依赖项边的数量,可以使用 runfile 中介来表示所有这些依赖项。
- 用于运行
RunfilesSupport
对象所代表的二进制文件的命令行参数。
切面
切面是一种“沿依赖项图向下传播计算”的方法。此处介绍了面向 Bazel 用户的说明。一个很好的动机示例是协议缓冲区:proto_library
规则不应了解任何特定语言,但应与任何编程语言中的协议缓冲区消息(协议缓冲区的“基本单元”)实现的构建耦合,以便如果使用同一语言的两个目标依赖于同一协议缓冲区,则只会构建一次。proto_library
与配置的目标一样,它们在 Skyframe 中表示为 SkyValue
,并且其构建方式与构建配置的目标非常相似:它们有一个名为 ConfiguredAspectFactory
的工厂类,该类可以访问 RuleContext
,但与配置的目标工厂不同,它还知道自己所附加的配置的目标及其提供程序。
使用 Attribute.Builder.aspects()
函数为每个属性指定向下传播到依赖项图中的一组方面。该过程中有一些命名混乱的类:
AspectClass
是该方面实现。它可以在 Java 中(在本例中是一个子类)或 Starlark 中(在这种情况下,它是StarlarkAspectClass
的实例)。它类似于RuleConfiguredTargetFactory
。AspectDefinition
是此方面定义;它包含所需的提供程序、提供的提供程序,以及对其实现(例如适当的AspectClass
实例)的引用。它类似于RuleClass
。AspectParameters
是一种参数化沿依赖项图向下传播的方面的方式。它目前是字符串到字符串的映射。协议缓冲区是一个很好的例子,说明它为何有用:如果一种语言有多个 API,关于协议缓冲区应该为哪个 API 构建的信息应该沿依赖关系图向下传播。Aspect
表示计算沿依赖关系图向下传播的切面所需的所有数据。它由方面类、其定义和参数组成。RuleAspect
是一个函数,用于确定特定规则应传播哪些方面。这是一个Rule
->Aspect
函数。
一个意料之外的复杂性是,切面可以附加到其他切面;例如,收集 Java IDE 类路径的切面可能想要了解类路径上的所有 .jar 文件,但其中一些是协议缓冲区。在这种情况下,IDE 方面将希望附加到 (proto_library
规则 + Java proto 方面) 对。
AspectCollection
类中捕获切面切面的复杂性。
平台和工具链
Bazel 支持多平台构建,也就是说,此类构建可能有多个架构运行构建操作,也支持针对多个架构构建代码。在 Bazel 术语中,这些架构称为平台(完整文档参见此处)
平台由从限制条件设置(例如“CPU 架构概念”)到限制条件值(例如特定 CPU,如 x86_64)的键值对映射进行描述。我们在 @platforms
代码库中提供了一个包含最常用约束条件设置和值的“字典”。
工具链的概念源于以下事实:根据 build 的运行平台和目标平台,您可能需要使用不同的编译器;例如,特定的 C++ 工具链可能在特定操作系统上运行,并且能够定位到某些其他操作系统。Bazel 必须根据设置的执行平台和目标平台确定要使用的 C++ 编译器(此处提供了工具链文档)。
为此,工具链会带有注解,注解中包含它们支持的一组执行和目标平台约束条件。为此,工具链的定义分为两部分:
toolchain()
规则,用于描述工具链支持的一组执行和目标约束条件,并指明工具链的类型(例如 C++ 或 Java;后者由toolchain_type()
规则表示)- 特定于语言的规则,用于描述实际的工具链(例如
cc_toolchain()
)
之所以这样做,是因为我们需要知道每个工具链的约束条件才能进行工具链解析,而特定于语言的 *_toolchain()
规则包含比这更多的信息,因此需要更长时间才能加载。
执行平台可通过以下任一方式指定:
- 在 WORKSPACE 文件中使用
register_execution_platforms()
函数 - 在命令行中使用 --extra_execution_platforms 命令行选项
可用执行平台集在 RegisteredExecutionPlatformsFunction
中计算得出。
已配置目标的目标平台由 PlatformOptions.computeTargetPlatform()
决定。这是一个平台列表,因为我们最终想要支持多个目标平台,但该实现尚未实现。
要用于已配置目标的工具链集由 ToolchainResolutionFunction
确定。它是以下因素的函数:
- 一组已注册的工具链(在 WORKSPACE 文件和配置中)
- 所需的执行平台和目标平台(在配置中)
- 配置的目标所需的一组工具链类型(在
UnloadedToolchainContextKey)
UnloadedToolchainContextKey
中配置的目标 (exec_compatible_with
属性) 和配置 (--experimental_add_exec_constraints_to_targets
) 的一组执行平台约束条件
其结果是 UnloadedToolchainContext
,它本质上是从工具链类型(表示为 ToolchainTypeInfo
实例)到所选工具链的标签的映射。之所以称为“已卸载”,是因为它不包含工具链本身,而仅包含其标签。
然后,工具链实际上是使用 ResolvedToolchainContext.load()
加载的,并由请求它们的配置目标的实现使用。
我们还有一个旧版系统,该系统依赖于单一的“主机”配置,而目标配置由各种配置标志(例如 --cpu
)表示。我们正在逐步过渡到上述系统。为了处理用户依赖旧版配置值的情况,我们实现了平台映射,以便在旧版标志和新版平台限制之间进行转换。其代码采用 PlatformMappingFunction
格式,并使用非 Starlark“小语言”。
限制条件
有时,您可能希望将目标指定为仅与少数平台兼容。很遗憾,Bazel 有多个机制可以实现此目的:
- 特定于规则的约束条件
environment_group()
/environment()
- 平台限制
特定于规则的限制条件主要在 Google for Java 规则中使用;它们即将停用,并且在 Bazel 中不可用,但源代码可能包含对其的引用。用于控制此属性的属性称为 constraints=
。
environment_group() 和 environment()
这些规则是一种旧版机制,不广泛使用。
所有 build 规则都可以声明它们可用于构建哪些“环境”,其中“环境”是 environment()
规则的实例。
您可以通过多种方式为规则指定支持的环境:
- 通过
restricted_to=
属性。这是最直接的规范形式;它声明规则为此组支持的确切环境集。 - 通过
compatible_with=
属性。除了默认支持的“标准”环境之外,此命令还可声明规则支持的环境。 - 通过软件包级属性
default_restricted_to=
和default_compatible_with=
。 - 通过
environment_group()
规则中的默认规范。每个环境都属于一组主题相关的同类环境(例如“CPU 架构”“JDK 版本”或“移动操作系统”)。环境组的定义包含“默认”应支持的这些环境(如果未通过restricted_to=
/environment()
属性另行指定)。没有此类属性的规则会继承所有默认值。 - 通过规则类默认设置。这会替换给定规则类的所有实例的全局默认值。例如,这可用于使所有
*_test
规则均可测试,而无需每个实例明确声明此功能。
environment()
是作为常规规则实现的,而 environment_group()
既是 Target
(而非 Rule
[EnvironmentGroup
])的子类,也是 Starlark (StarlarkLibrary.environmentGroup()
) 中默认提供的函数,最终会创建同名目标。这样做是为了避免因每个环境都需要声明其所属的环境组,而每个环境组都需要声明其默认环境而导致的循环依赖。
您可以使用 --target_environment
命令行选项将 build 限制为特定环境。
限制检查的实现位于 RuleContextConstraintSemantics
和 TopLevelConstraintSemantics
中。
平台限制
目前,描述目标与哪些平台兼容的“官方”方法是使用描述工具链和平台的相同约束条件。该请求正在拉取请求 #10945 中接受审核。
公开范围
如果您要处理由许多开发者共同维护的大型代码库(例如 Google 的代码库),则需要注意,防止其他人任意依赖您的代码。否则,根据 Hyrum 定律,人们将会依赖您认为属于实现细节的行为。
Bazel 通过名为 visibility 的机制来支持这一点:您可以使用 visibility 属性来声明特定目标只能依赖。此属性有点特殊,因为虽然它包含一个标签列表,但这些标签可能会对软件包名称编码模式,而不是对任何特定目标的指针。(是的,这确实是一个设计缺陷。)
这是在以下位置实现的:
RuleVisibility
接口表示可见性声明。它可以是一个常量(完全公开或完全不公开)或标签列表。- 标签可以指向软件包组(预定义的软件包列表),也可以直接指向软件包 (
//pkg:__pkg__
) 或软件包的子树 (//pkg:__subpackages__
)。这与使用//pkg:*
或//pkg/...
的命令行语法不同。 - 软件包组会作为自己的目标 (
PackageGroup
) 和已配置的目标 (PackageGroupConfiguredTarget
) 实现。如果需要,我们或许可以用简单的规则替换这些规则。其逻辑的实现依赖于以下元素:PackageSpecification
(对应于//pkg/...
等单个模式);PackageGroupContents
(对应于单个package_group
的packages
属性);以及PackageSpecificationProvider
(对package_group
及其传递includes
进行汇总)。 - 从可见性标签列表转换为依赖项是在
DependencyResolver.visitTargetVisibility
和其他一些杂项位置完成的。 - 实际检查在
CommonPrerequisiteValidator.validateDirectPrerequisiteVisibility()
中完成
嵌套集
通常,配置的目标会聚合其依赖项中的一组文件,添加自己的文件,并将聚合集封装到传递信息提供程序中,以便依赖于它的已配置目标可以执行相同的操作。示例:
- 用于 build 的 C++ 头文件
- 表示
cc_library
的传递闭包的对象文件 - 需要位于类路径中才能编译或运行 Java 规则的一组 .jar 文件
- Python 规则的传递闭包中的 Python 文件集
如果我们通过使用 List
或 Set
等简单方式实现这一点,最终的内存用量将是二次:如果有 N 条规则链,并且每条规则都添加一个文件,那么我们将有 1+2+...+N 个集合成员。
为了解决此问题,我们想出了 NestedSet
的概念。它是一种数据结构,由其他 NestedSet
实例和自己的一些成员组成,从而形成一组有向无环图。它们是不可变的,它们的成员可以迭代。我们定义了多个迭代顺序 (NestedSet.Order
):preorder、postorder、topological(节点始终在其祖先实体之后)和“随意,但每次都应该相同”。
相同的数据结构在 Starlark 中称为 depset
。
工件和操作
实际 build 包含一组命令,需要运行这些命令才能生成用户想要的输出。命令表示为 Action
类的实例,文件表示为 Artifact
类的实例。它们排列在一个称为“操作图”的二分有向无环图中。
工件分为两种:源工件(在 Bazel 开始执行之前可用)和派生工件(需要构建)。派生工件本身可以有多种类型:
- **常规工件。**系统会通过计算其校验和(使用 mtime 作为快捷方式)来检查这些文件是否是最新的;如果文件的 ctime 未发生更改,我们不会对其进行校验和。
- 未解析的符号链接工件。系统会通过调用 readlink() 来检查这些文件是否是最新的。与常规工件不同,这些文件可能是悬空符号链接。通常用于将某些文件打包到某种归档文件中的情况。
- 树工件。这些不是单个文件,而是目录树。系统会通过检查其中的一组文件及其内容来检查它们是否是最新的。它们表示为
TreeArtifact
。 - 常量元数据工件。对这些工件所做的更改不会触发重新构建。这仅用于 build 戳信息:我们不希望仅仅因为当前时间发生变化就重新构建。
没有根本原因表明源工件不能是树工件或未解析的符号链接工件,只是我们尚未实现它(不过,我们应该实现它 - 在 BUILD
文件中引用源目录是 Bazel 的少数已知长期错误问题之一;我们有一个实现,这种实现由 BAZEL_TRACK_SOURCE_DIRECTORIES=1
JVM 属性启用)
值得注意的一种 Artifact
是中间人。它们由 Artifact
实例表示,这些实例是 MiddlemanAction
的输出。它们用于针对某些情况设置特殊情况:
- 汇总中介用于将工件分组在一起。这样,如果许多操作使用同一组大型输入,我们就不会有 N*M 个依赖项边,而只有 N+M 个(它们将被嵌套集取代)
- 调度依赖项中介可确保某个操作在另一个操作之前运行。它们主要用于 lint 检查,但也用于 C++ 编译(如需了解详情,请参阅
CcCompilationContext.createMiddleman()
) - runfiles 中介用于确保存在 runfiles 树,以便用户无需单独依赖于输出清单和 runfiles 树引用的每个工件。
最好将操作理解为需要运行的命令、所需的环境及其生成的一组输出。以下是操作说明的主要组成部分:
- 需要运行的命令行
- 所需的输入制品
- 需要设置的环境变量
- 用于描述其需要在哪种环境(例如平台)中运行的注解 \
此外,还有一些其他特殊情况,例如写入 Bazel 已知内容的文件。它们是 AbstractAction
的子类。大多数操作都是 SpawnAction
或 StarlarkAction
(相同,它们应该不是单独的类),但 Java 和 C++ 有自己的操作类型(JavaCompileAction
、CppCompileAction
和 CppLinkAction
)。
我们最终希望将所有内容都移至 SpawnAction
;JavaCompileAction
已经非常接近,但由于 .d 文件解析和包含扫描,C++ 有点特殊。
操作图大多“嵌入”到 Skyframe 图中:从概念上讲,操作的执行表示为对 ActionExecutionFunction
的调用。ActionExecutionFunction.getInputDeps()
和 Artifact.key()
中介绍了从操作图依赖边到 Skyframe 依赖边的映射,并进行了一些优化,以尽量减少 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 会开始遍历操作图(由操作及其输入和输出工件组成的二分有向图)和正在运行的操作。每个操作的执行都由 SkyValue
类 ActionExecutionValue
的实例表示。
由于运行操作的开销较大,因此我们在 Skyframe 后面提供了几层缓存:
ActionExecutionFunction.stateMap
包含用于降低ActionExecutionFunction
的 Skyframe 重启开销的数据- 本地操作缓存包含有关文件系统状态的数据
- 远程执行系统通常还包含自己的缓存
本地操作缓存
此缓存是 Skyframe 背后的另一个层;即使在 Skyframe 中重新执行操作,它仍可以在本地操作缓存中命中。它表示本地文件系统的状态,并会序列化到磁盘,这意味着,启动新的 Bazel 服务器时,即使 Skyframe 图表为空,也可以获得本地操作缓存命中。
系统会使用 ActionCacheChecker.getTokenIfNeedToExecute()
方法检查此缓存是否存在命中。
与其名称相反,它是从派生工件的路径到发出该工件的操作的映射。该操作的说明如下:
- 其输入和输出文件集及其校验和
- 其“操作键”(通常是已执行的命令行),但通常表示输入文件的校验和未捕获的所有内容(例如,对于
FileWriteAction
,它是已写入数据的校验和)
此外,我们还开发了一项极其实验性的“自上而下操作缓存”,该缓存仍处于开发阶段,它使用传递哈希来避免多次访问缓存。
输入发现和输入修剪
有些操作不仅仅包含一组输入,而是更为复杂。对操作的输入集所做的更改有两种形式:
- 操作可能会在执行之前发现新输入,或者决定其某些输入实际上并不必要。典型示例是 C++,在这种情况下,最好根据 C++ 文件的传递闭包推断出该文件使用了哪些头文件,以免我们需要将每个文件都发送到远程执行器;因此,我们可以选择不将每个头文件都注册为“输入”,而是扫描源文件以查找传递包含的头文件,并仅将
#include
语句中提及的头文件标记为输入(我们会过高估计,以免需要实现完整的 C 预处理器)。此选项目前在 Bazel 中设为“false”,并且仅在 Google 内部使用。 - 操作可能会发现在执行期间未使用某些文件。在 C++ 中,这称为“.d 文件”:编译器会在事后告知使用了哪些头文件,为了避免比 Make 的增量更差的尴尬局面,Bazel 会利用这一事实。与包含扫描器相比,这可以提供更好的估算值,因为它依赖于编译器。
这些操作是使用 Action 的方法实现的:
- 系统会调用
Action.discoverInputs()
。它应该返回一组确定为必需的嵌套工件。这些必须是源工件,以便操作图中没有在配置的目标图中没有等效项的依赖关系边。 - 通过调用
Action.execute()
执行操作。 - 在
Action.execute()
结束时,操作可以调用Action.updateInputs()
,告知 Bazel 不需要其中的所有输入。如果已使用的输入报告为未使用,这可能会导致增量 build 不正确。
当操作缓存返回新 Action 实例(例如在服务器重启后创建)的命中记录时,Bazel 会调用 updateInputs()
本身,以便一组输入反映之前执行的输入发现和修剪结果。
Starlark 操作可以利用该功能,使用 ctx.actions.run()
的 unused_inputs_list=
参数将某些输入声明为未使用。
运行操作的各种方式:策略/ActionContext
某些操作可以通过不同的方式执行。例如,命令行可以在本地执行,也可以在各种沙盒中本地执行,或者远程执行。体现这一点的概念称为 ActionContext
(或 Strategy
,因为我们只成功完成了重命名的半程...)
操作上下文的生命周期如下所示:
- 开始执行阶段时,系统会询问
BlazeModule
实例具有哪些操作上下文。这发生在ExecutionTool
的构造函数中。操作上下文类型由 JavaClass
实例标识,该实例引用ActionContext
的子接口,并且操作上下文必须实现该接口。 - 系统会从可用操作上下文中选择适当的操作上下文,并将其转发给
ActionExecutionContext
和BlazeExecutor
。 - 操作使用
ActionExecutionContext.getContext()
和BlazeExecutor.getStrategy()
请求上下文(应该只有一种方式...)
策略可以自由调用其他策略来执行其工作;例如,在动态策略中,系统会同时在本地和远程启动操作,然后使用先完成的操作。
一种值得注意的策略是实现永久性工作器进程 (WorkerSpawnStrategy
)。具体思路是,某些工具的启动时间很长,因此应该在操作之间重复使用,而不是为每个操作都重新开始(这确实存在潜在的正确性问题,因为 Bazel 依赖于以下承诺:工作器进程不会在各个请求之间保持可观察状态)
如果工具发生变化,则需要重启 worker 进程。工作器是否可重复使用取决于使用 WorkerFilesHash
计算所用工具的校验和。它依赖于知道操作的哪些输入代表工具的一部分,哪些输入代表输入;这由操作的创建者决定:Spawn.getToolFiles()
和 Spawn
的运行文件被计为工具的一部分。
详细了解策略(或操作情境!):
本地资源管理器
Bazel 可以并行运行许多操作。应并行运行的本地操作数量因操作而异:操作需要的资源越多,同时运行的实例就越少,以免本地计算机过载。
这在 ResourceManager
类中实现:每个操作都必须带有注解,其中包含估算的本地资源需求(以 ResourceSet
实例的形式表示,即 CPU 和 RAM)。然后,当操作上下文执行需要本地资源的操作时,它们会调用 ResourceManager.acquireResources()
并被屏蔽,直到所需的资源可用为止。
如需详细了解本地资源管理,请点击此处。
输出目录的结构
每个操作都需要在输出目录中有一个单独的位置来放置其输出。派生工件的路径通常如下所示:
$EXECROOT/bazel-out/<configuration>/bin/<package>/<artifact name>
如何确定与特定配置关联的目录的名称?理想属性有两个冲突:
- 如果同一 build 中可以出现两个配置,则它们应具有不同的目录,以便这两个配置可以拥有相同操作的各自版本;否则,如果这两个配置存在差异(例如,生成相同输出文件的操作的命令行),Bazel 将不知道选择哪个操作(即“操作冲突”)
- 如果两个配置代表“大致”相同的内容,则应具有相同的名称,以便在命令行匹配的情况下,在一个配置中执行的操作可在另一个配置中重复使用:例如,对 Java 编译器的命令行选项所做的更改不应导致重新运行 C++ 编译操作。
到目前为止,我们还没有找到解决此问题的原则性方法,这与配置修剪问题有相似之处。如需详细了解相关选项,请点击此处。主要有问题的方面是 Starlark 规则(其作者通常不太熟悉 Bazel)和切面,后者在可生成“相同”输出文件的内容空间中添加了另一个维度。
目前的方法是,配置的路径段为 <CPU>-<compilation mode>
,并添加了各种后缀,以便在 Java 中实现的配置转换不会导致操作冲突。此外,我们还添加了一组 Starlark 配置转换的校验和,以防止用户导致操作冲突。它远非完美无缺。这在 OutputDirectories.buildMnemonic()
中实现,并且依赖于每个配置 fragment 将其自己的部分添加到输出目录的名称中。
测试
Bazel 对运行测试提供了丰富的支持。它支持:
- 远程运行测试(如果有可用的远程执行后端)
- 并行运行测试多次(用于消除不稳定或收集时间数据)
- 将测试分片(将同一测试中的测试用例拆分到多个进程中以提高速度)
- 重新运行不可靠的测试
- 将测试分组到测试套件
测试是具有 TestProvider 的常规配置目标,该 TestProvider 用于描述应如何运行测试:
- 其构建导致运行测试的工件。这是一个包含序列化
TestResultData
消息的“缓存状态”文件 - 应运行测试的次数
- 测试应拆分成多少个分片
- 关于应如何运行测试的一些参数(例如测试超时)
确定要运行哪些测试
确定要运行哪些测试是一个复杂的过程。
首先,在目标模式解析期间,测试套件会递归展开。该展开是在 TestsForTargetPatternFunction
中实现的。有一点令人意外的是,如果测试套件未声明任何测试,则表示其指的是其软件包中的所有测试。这在 Package.beforeBuild()
中通过向测试套件规则添加一个名为 $implicit_tests
的隐式属性来实现。
然后,系统会根据命令行选项过滤测试大小、标记、超时和语言。此方法在 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 运行的终端。
通过观察各种事件(例如 TestAttempt
、TestResult
或 TestingCompleteEvent
),事件总线上会显示已执行测试的结果。这些结果会转储到 Build Event Protocol,并由 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++),而有些语言会进行在线插桩,即在执行时添加覆盖率插桩。
另一个核心概念是基准覆盖率。如果库、二进制文件或测试中没有任何代码运行,则其覆盖率为 0%。它解决的问题是,如果您想计算二进制文件的测试覆盖率,仅合并所有测试的覆盖率是不够的,因为二进制文件中可能包含未关联到任何测试的代码。因此,我们会为每个二进制文件生成一个覆盖率文件,其中仅包含我们收集覆盖率的文件,不包含任何已覆盖的行。目标的基准代码覆盖率文件位于 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
,而不是将其收集到某个数据结构,然后 QueryFunction
会调用它来获取要返回的结果。
查询结果可以通过多种方式发出:标签、标签和规则类、XML、Protobuf 等。这些方法以 OutputFormatter
的子类的形式实现。
某些查询输出格式(肯定是 proto)的一个细微要求是,Bazel 需要发出软件包加载提供的 _所有_ 信息,以便用户对输出进行差异比较并确定特定目标是否已更改。因此,属性值需要可序列化,这也是为什么只有少数属性类型没有具有复杂 Starlark 值的属性的原因。常见的解决方法是使用标签,并使用该标签将复杂信息附加到规则。这并不是一个非常令人满意的权宜解决方法,如果能够取消此要求,将会非常棒。
模块系统
您可以通过向 Bazel 添加模块来扩展 Bazel。每个模块都必须继承 BlazeModule
(此名称是 Bazel 历史遗留的产物,当时 Bazel 的名称为 Blaze),并在执行命令期间获取有关各种事件的信息。
它们主要用于实现只有部分 Bazel 版本(例如我们在 Google 使用的版本)需要的各种“非核心”功能:
- 与远程执行系统的接口
- 新增命令
BlazeModule
提供的这组扩展积分有点杂乱。请勿将其用作良好设计原则的示例。
事件总线
BlazeModule 与 Bazel 的其余部分通信的主要方式是通过事件总线 (EventBus
):系统会为每个 build 创建一个新实例,Bazel 的各个部分都可以向其发布事件,模块可以为其感兴趣的事件注册监听器。例如,以下内容会表示为事件:
- 要构建的 build 目标列表已确定 (
TargetParsingCompleteEvent
) - 顶级配置已确定 (
BuildConfigurationEvent
) - 目标构建成功(无论是否成功)(
TargetCompleteEvent
) - 已运行测试 (
TestAttempt
,TestSummary
)
其中一些事件在 Bazel 之外的 Build Event Protocol 中表示(它们是 BuildEvent
)。这样,不仅 BlazeModule
可以观察 build,Bazel 进程之外的其他内容也可以观察 build。您可以将其作为包含协议消息的文件进行访问,也可以让 Bazel 连接到服务器(称为 Build Event Service)以流式传输事件。
这是在 build.lib.buildeventservice
和 build.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>
下创建一个目录。
提取代码库会按以下步骤进行:
PackageLookupFunction
意识到它需要一个代码库,并创建了一个RepositoryName
作为SkyKey
,后者会调用RepositoryLoaderFunction
RepositoryLoaderFunction
出于不明原因将请求转发给RepositoryDelegatorFunction
(代码中说是为了避免在 Skyframe 重启时重新下载内容,但这并不是一个非常可靠的推理)RepositoryDelegatorFunction
会迭代 WORKSPACE 文件的块,直到找到请求的代码库,从而找出系统要求提取的代码库规则- 找到实现代码库提取的适当
RepositoryFunction
;它是代码库的 Starlark 实现,或者是使用 Java 实现的代码库的硬编码映射。
由于提取代码库的成本可能非常高昂,因此存在多个不同的缓存层:
- 下载的文件有一个缓存,其键是校验和 (
RepositoryCache
)。这需要 WORKSPACE 文件中提供校验和,但这对密封性来说总是有利的。同一工作站上的每个 Bazel 服务器实例都会共享此目录,无论它们在哪个工作区或输出基础目录中运行。 - 系统会为
$OUTPUT_BASE/external
下的每个代码库写入一个“标记文件”,其中包含用于提取该代码库的规则的校验和。如果 Bazel 服务器重启,但校验和没有更改,则系统不会重新获取校验和。这是在RepositoryDelegatorFunction.DigestWriter
中实现的。 --distdir
命令行选项会指定另一个缓存,用于查找要下载的工件。这在企业设置中非常有用,在企业设置中,Bazel 不应从互联网提取随机内容。这通过DownloadManager
实现。
下载某个代码库后,其中的工件会被视为源工件。这会带来一个问题,因为 Bazel 通常会通过对源代码工件调用 stat() 来检查其是否是最新的,并且当这些工件所在的代码库的定义发生变化时,这些工件也会失效。因此,外部代码库中工件的 FileStateValue
需要依赖于其外部代码库。这由 ExternalFilesHelper
处理。
受管理的目录
有时,外部代码库需要修改工作区根目录下的文件(例如,将下载的软件包存储在源代码树的子目录中的软件包管理器)。这与以下假设相悖:Bazel 使源文件只能由用户修改,而不能由用户自行修改,并且允许软件包引用工作区根目录下的每个目录。为了使此类外部代码库正常运行,Bazel 会执行以下两项操作:
- 允许用户指定工作区的子目录,但不允许 Bazel 访问。它们列在名为
.bazelignore
的文件中,功能在BlacklistedPackagePrefixesFunction
中实现。 - 我们将从工作区的子目录到其所由外部代码库的映射编码为
ManagedDirectoriesKnowledge
,并以与常规外部代码库相同的方式处理引用它们的FileStateValue
。
代码库映射
有时,多个代码库可能希望依赖于同一个代码库,但使用不同的版本(这属于“钻石依赖项问题”的一个示例)。例如,如果 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 类如下所示:
NativePosixFiles
和NativePosixFileSystem
ProcessUtils
WindowsFileOperations
和WindowsFileProcesses
com.google.devtools.build.lib.platform
控制台输出
发出控制台输出似乎是一件简单的事情,但由于需要同时运行多个进程(有时是远程进程)、进行精细缓存、希望获得漂亮且色彩丰富的终端输出,以及拥有长时间运行的服务器,因此这项工作并不简单。
从客户端收到 RPC 调用后,系统会立即创建两个 RpcOutputStream
实例(用于 stdout 和 stderr),并将输出到其中的数据转发给客户端。然后,这些内容会封装在 OutErr
(一个 (stdout, stderr) 对)中。需要在控制台上输出的任何内容都需要通过这些流。然后,这些数据流会交给 BlazeCommandDispatcher.execExclusively()
。
默认情况下,输出使用 ANSI 转义序列输出。如果不需要 (--color=no
),AnsiStrippingOutputStream
会将其剥离。此外,System.out
和 System.err
会重定向到这些输出流。这样,就可以使用 System.err.println()
输出调试信息,并且最终仍会显示在客户端的终端输出中(这与服务器的输出不同)。请务必注意,如果进程生成二进制输出(例如 bazel query --output=proto
),则不会对标准输出进行修改。
简短消息(错误、警告等)通过 EventHandler
接口表示。值得注意的是,这些与向 EventBus
发布的内容不同(这很容易让人混淆)。每个 Event
都有 EventKind
(错误、警告、信息等),并且可能还有 Location
(导致事件发生的源代码中的位置)。
某些 EventHandler
实现会存储它们收到的事件。此接口用于将各种缓存处理导致的信息重放到界面,例如缓存的配置目标发出的警告。
一些 EventHandler
还允许发布最终到达事件总线的事件(常规 Event
不会 _显示在此处)。这些是 ExtendedEventHandler
的实现,其主要用途是重放缓存的 EventBus
事件。这些 EventBus
事件全都实现 Postable
,但并非发布到 EventBus
的所有内容都一定会实现此接口;只有由 ExtendedEventHandler
缓存的事件(这很好,而且大多数功能都可实现;但并不强制执行)
终端输出大部分是通过 UiEventHandler
发出的,它负责处理 Bazel 执行的所有精美的输出格式设置和进度报告。它有两个输入:
- 事件总线
- 事件流通过报告程序导入
命令执行机器(例如其余的 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 视为“黑盒”进行观察,另一种是仅运行分析阶段。我们将前一种称为“集成测试”,后一种称为“单元测试”,但它们更像是集成程度较低的集成测试。我们还提供了一些必要的实际单元测试。
集成测试有两种:
- 在
src/test/shell
下使用非常精细的 bash 测试框架实现的测试 - 使用 Java 实现的。这些类以
BuildIntegrationTestCase
的子类形式实现
BuildIntegrationTestCase
是首选的集成测试框架,因为它可以很好地应对大多数测试场景。由于它是一个 Java 框架,因此可实现可调试性,并与许多常见的开发工具无缝集成。Bazel 代码库中包含许多 BuildIntegrationTestCase
类示例。
分析测试以 BuildViewTestCase
的子类形式实现。您可以使用一个预留文件系统来写入 BUILD
文件,然后各种辅助方法可以请求配置的目标、更改配置,以及断言分析结果的各种信息。