本文档介绍了代码库以及 Bazel 的结构。它适用于愿意为 Bazel 做出贡献的用户,而不是面向最终用户。
简介
Bazel 的代码库很大(大约有 350 KLOC 生产代码,大约 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 可执行文件。它按照以下步骤设置适当的服务器进程:
- 检查是否已经自行提取。否则。这正是服务器实现的来源。
- 检查是否存在有效的服务器实例:实例正在运行、是否具有正确的启动选项以及使用了正确的工作区目录。它通过查看
$OUTPUT_BASE/server
目录来查看正在运行的服务器,其中具有服务器所监听的端口的锁定文件。 - 如果需要,终止原来的服务器进程
- 如果需要,启动新的服务器进程
合适的服务器进程准备就绪后,需要运行的命令会通过 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 实例。
输出目录包含以下内容:
- 在
$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
。运行加载/分析阶段以生成操作图(需要针对 build 执行的命令的有向无环图)。
该执行阶段在运行。这意味着,系统会运行构建请求的顶级目标所需的每项操作。
命令行选项
Bazel 调用的命令行选项在 OptionsParsingResult
对象中有所说明,而该对象包含从“选项类”到选项值的映射。“选项类”是 OptionsBase
的子类,用于将彼此相关的命令行选项组合在一起。例如:
- 与编程语言相关的选项(
CppOptions
或JavaOptions
)。这些选项应该是FragmentOptions
的子类,最终会封装到BuildOptions
对象中。 - 与 Bazel 执行操作的方式相关的选项 (
ExecutionOptions
)
这些选项在分析阶段和(通过 Java 中的 RuleContext.getFragment()
或 Starlark 中的 ctx.fragments
)中使用。其中的部分变量(例如,C++ 是否包含扫描)会在执行阶段读取,但由于 BuildConfiguration
当时不可用,这始终需要显式连接。如需了解详情,请参阅“配置”部分。
警告:我们喜欢假设 OptionsBase
实例不可变并以此方式(例如 SkyKeys
的一部分)使用它们。事实并非如此,修改这些实例是一种以很难调试的巧妙方式破坏 Bazel 的好方法。遗憾的是,使它们实际上不可变是一项庞大的工作。(在构建完成后立即修改 FragmentOptions
可以使其他人有机会保留对 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
进行符号关联或下载。
运行构建时,整个源代码树需要被拼凑在一起;这是由 SymlinkForest
完成的,它会将主代码库中的所有软件包都链接到 $EXECROOT
以及每个外部代码库到 $EXECROOT/external
或 $EXECROOT/..
(当然,前提使得主代码库中没有名为 external
的软件包;因此,我们会停止使用它)。
软件包
每个代码库都由软件包、相关文件的集合和依赖项规范组成。它们由名为 BUILD
或 BUILD.bazel
的文件指定。如果两者都存在,则 Bazel 更倾向于使用 BUILD.bazel
;因为 BUILD
文件仍然被接受的原因是 Bazel 的祖先 Blaze 使用了此文件名。但事实证明,这是一个常用的路径段,尤其是在 Windows 上,其中文件名不区分大小写。
软件包是相互独立的:更改软件包的 BUILD
文件不能导致其他软件包发生更改。添加或移除 BUILD
文件可以更改其他软件包,因为递归 glob 在软件包边界处停止,因此 BUILD
文件的存在会停止递归。
BUILD
文件的评估称为“软件包加载”。它在 PackageFactory
类中实现,通过调用 Starlark 解释器来工作,并需要了解可用的规则类集。软件包加载的结果是一个 Package
对象。它主要是指从字符串(目标名称)到目标本身的映射。
软件包加载期间出现大量复杂问题:观察:Bazel 不需要明确列出每个源文件,而是可以运行 glob(例如 glob(["**/*.java"])
)。与 shell 不同,它支持递归到子目录(但不属于子软件包)的递归 glob。这需要访问文件系统,由于速度可能较慢,因此我们实施了各种技巧以使其尽可能并行运行。
全球地图会在以下类中实现:
LegacyGlobber
,一种快速且幸福的 Skyframe-unaware globberSkyframeHybridGlobber
,它使用 Skyframe 并还原为旧版 globber 以避免“Skyframe restart”(如下所述)
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 中。
Starlark 规则类需要使用 load()
语句在 BUILD
文件开头导入;而 Java 规则类由于被注册到 ConfiguredRuleClassProvider
,所以“本身”就被 Bazel 知晓。
规则类包含如下信息:
- 它的属性(例如
srcs
、deps
):它们的类型、默认值、限制条件等。 - 附加到每个属性的配置转换和方面(如果有)
- 规则的实现
- 规则“通常”创建的传递信息提供程序
术语说明:在代码库中,我们通常使用“规则”来表示规则类创建的目标。但在 Starlark 和面向用户的文档中,“规则”应仅用于引用规则类本身;目标只是“目标”。另请注意,虽然 RuleClass
名称中包含“class”,但规则类与该类型的目标之间没有 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,请点击此处。
Skyframe 会将特定 SkyKey
评估为 SkyValue
,并调用与按键类型对应的 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 是用来配置和扩展 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 功能更强大,但同时,执行 Bad ThingsTM 更简单,例如,编写时间或空间复杂度为二次(或更差)的代码,使 Bazel 服务器崩溃并出现 Java 异常或违反不变(例如,无意中修改 Options
实例或将配置的目标设为可变)。
用于确定已配置目标的直接依赖项的算法位于 DependencyResolver.dependentNodeMap()
中。
配置
配置是指构建目标的“方式”:用于哪个平台,使用哪些命令行选项,等等。
可以为同一 build 中的多个配置构建相同的目标。例如,当在构建期间运行的工具和目标代码使用相同的代码时,这会非常有用;当我们进行交叉编译时,或当我们构建脂肪 Android 应用(包含适用于多个 CPU 架构的原生代码的应用)时
从概念上讲,该配置是 BuildOptions
实例。但实际上,BuildOptions
由 BuildConfiguration
封装,后者提供了额外的功能。它会从依赖项图的顶部传播到底部。如果 build 发生变化,则需要重新分析 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 提供程序之间通常是 1 对 1 的对应关系(DefaultInfo
除外,它是 FileProvider
、FilesToRunProvider
和 RunfilesProvider
的集合,因为此 API 被认为比直接 Java 音译更像 Starlark)。他们的密钥是以下其中一项:
- Java 类对象。这仅适用于无法从 Starlark 访问的提供程序。这些提供程序是
TransitiveInfoProvider
的子类。 - 一个字符串。这是旧式方法,并强烈建议不要这样做,因为它很容易命名冲突。此类传递信息提供程序是
build.lib.packages.Info
的直接子类。 - 提供商符号。此操作可通过
provider()
函数从 Starlark 创建,也是创建新提供程序的推荐方法。该符号以 Java 中的Provider.Key
实例表示。
在 Java 中实现的新提供程序应使用 BuiltinProvider
实现。
NativeProvider
已废弃(我们尚未有足够的时间将其移除),并且无法从 Starlark 访问 TransitiveInfoProvider
子类。
配置的目标
已配置的目标将作为 RuleConfiguredTargetFactory
实现。在 Java 中实现的每个规则类都有一个子类。Starlark 配置的目标是通过 StarlarkRuleConfiguredTargetUtil.buildRule()
创建的。
配置的目标工厂应使用 RuleConfiguredTargetBuilder
构造其返回值。它包含以下各项:
- 他们的
filesToBuild
,“此规则代表的文件集”的模糊概念。已配置的目标位于命令行中或 Gengen 规则的 src 中时,就会构建这些文件。 - 他们的运行文件、常规和数据。
- 用户的输出组。这些是规则可以构建的各种“其他文件集”。您可以在 build 中使用文件组规则的 output_group 属性访问,也可以使用 Java 中的
OutputGroupInfo
提供程序访问。
运行文件
某些二进制文件需要数据文件才能运行。一个明显的例子是需要输入文件的测试。它在 Bazel 中用“runfiles”的概念表示。“runfiles 树”是特定二进制文件的数据目录目录。 它在文件系统中创建为符号链接树,其中包含指向输出树源中文件的单个符号链接。
一组运行文件表示为一个 Runfiles
实例。从概念上来讲,它是从 runfiles 树中的文件路径到表示该文件的 Artifact
实例的映射。由于两个原因,它比单个 Map
略复杂:
- 在大多数情况下,runrunfile 路径与其 execpath 相同。我们使用此参数来节省一些 RAM。
- runfile 树中有各种旧版条目,也需要表示。
运行文件是使用 RunfilesProvider
收集的:该类的实例表示已配置的目标(例如库)的运行文件及其传递闭合需求,它们的收集方式类似于嵌套集(实际上,它们使用封面下的嵌套集来实现):每个目标都会合并其依赖项的运行文件,添加它自己的部分文件,然后在依赖项图中发送生成的设置。RunfilesProvider
实例包含两个 Runfiles
实例,一个用于通过“data”属性依赖规则,另一个用于各种其他类型的传入依赖项。这是因为,当通过数据属性依赖目标时,目标有时会显示不同的运行文件。这是我们尚未移除的不良行为。
二进制文件的运行文件表示为 RunfilesSupport
的实例。这与 Runfiles
不同,因为 RunfilesSupport
具有实际构建的功能(与 Runfiles
不同,后者只是一个映射)。这需要以下附加组件:
- 输入运行文件清单。这是 runfiles 树的序列化说明。它可用作 runfiles 树内容的代理,Bazel 假定当且仅当清单的内容发生更改时,runfiles 树才会更改。
- 输出运行文件清单。这用于处理运行时文件树(尤其是在 Windows 上,有时不支持符号链接)的运行时库。
- runfiles 中间人。为了存在 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,有关应构建协议缓冲区的信息应该沿着依赖关系图传播。Aspect
表示计算传播到依赖关系图的宽高比所需的所有数据。它由宽高比类、其定义及其参数组成。RuleAspect
是一个函数,用于确定特定规则应传播哪方面的数据。这是一个Rule
->Aspect
函数。
有点出人意料的是,方面可以附加到其他方面;例如,为 Java IDE 收集类路径的方面可能希望了解类路径上的所有 .jar 文件,但其中部分是协议缓冲区。在这种情况下,IDE 方面需要附加到(proto_library
规则 + Java proto 宽高比)对。
AspectCollection
类捕获了各个方面的复杂性。
平台和工具链
Bazel 支持多平台构建,即可能存在多个运行构建操作的架构以及构建代码的多个架构。在 Bazel 语言中,这些架构称为平台(如需查看完整文档,请点击此处)
平台由键值对从限制设置(例如“CPU 架构的概念”)到限制值(例如特定的 x86_64 等 CPU)进行描述。我们的 @platforms
代码库中提供了一个常用限制条件设置和值的“字典”。
工具链的概念源自以下因素:根据 build 运行平台以及面向哪些平台,可能需要使用不同的编译器;例如,特定的 C++ 工具链可能会在特定操作系统上运行,并且能够定位到其他操作系统。Bazel 必须根据设定的执行和目标平台确定所用的 C++ 编译器(此处介绍了工具链的文档)。
为此,工具链需要支持一组支持的执行和目标平台约束。为此,工具链的定义分为两部分:
toolchain()
规则,用于描述工具链支持的执行集和目标约束条件,并告知工具链是什么种类(例如 C++ 或 Java)(后者由toolchain_type()
规则表示)- 描述实际工具链的特定于语言的规则(例如
cc_toolchain()
)
之所以执行此操作,是因为我们需要知道每个工具链的约束条件,以便执行工具链解析,以及特定于语言的 *_toolchain()
规则包含的信息远远超过这些信息,因此加载它们所需的时间会更长。
您可以通过以下某种方式指定执行平台:
- 使用
register_execution_platforms()
函数在 WORKSPACE 文件中 - 在命令行中使用 --extra_execute_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 中用于 Java 规则;这些规则即将被废弃,在 Bazel 中不可用,但源代码可能包含对其的引用。控制此功能的属性称为 constraints=
。
environment_group() 和 environment()
这些规则是一种旧版机制,应用范围并不广。
所有构建规则都可以声明可以为哪些“环境”构建,其中“环境”是 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
命令行选项将构建限制为特定环境。
约束检查的实现位于 RuleContextConstraintSemantics
和 TopLevelConstraintSemantics
中。
平台限制
目前描述目标平台支持的“官方”方法是使用与描述工具链和平台相同的约束条件。正在拉取请求 #10945 中进行审核。
展示率
如果您在包含大量开发者(例如 Google)的大型代码库工作,则需要注意防止他人根据您的代码任意乱用。否则,根据 Hyrum 的法律,人们会依赖被视为实现细节的行为。
Bazel 通过一种称为“可见性”的机制来支持这一点:您可以使用公开范围属性声明特定目标只能依赖于可见性。此属性有点特殊,因为尽管它存储了标签列表,但这些标签可能会在软件包名称之上编码模式,而不是指向任何特定目标的指针。(是的,这是一种设计缺陷)。
它在以下位置实现:
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
):预订、后序、拓扑(节点始终位于其祖先实体之后)和“不关心,但每次都应该相同”。
相同的数据结构在 Starlark 中称为 depset
。
工件和操作
实际 build 包含一组命令,这些命令需要运行才能生成用户所需的输出。这些命令表示为 Action
类的实例,这些文件表示为 Artifact
类的实例。我们将其排列在二元化的有向无环图中,称为“操作图”。
工件分为两类:源工件(在 Bazel 开始执行之前可用的工件)和派生工件(需要构建的工件)。派生工件本身可以有多种类型:
- **常规制品。**系统会通过计算校验和(采用 mtime 作为快捷方式)检查其最新状态;如果文件时间未更改,我们就不会检查该文件。
- 未解析的符号链接工件。通过调用 readlink() 检查这些文件是否是最新的。与常规工件不同,这些工件可能是悬挂符号链接。通常用于以下场景:将某个文件打包成某种归档文件。
- 树制品。它们不是单个文件,而是目录树。它们会检查其中的一组文件及其内容,从而检查文件是否处于最新状态。它们表示为
TreeArtifact
。 - 元数据元数据工件。对这些工件所做的更改不会触发重新构建。此文件仅用于 build 时间戳信息:我们不希望仅仅因为当前时间发生了变化就进行重新构建。
源代码工件不能是树工件或未解析的符号链接工件没有根本原因,只是我们尚未实现它(不过,我们应该知道,在 BUILD
文件中引用源代码目录是 Bazel 长期以来的一些错误不正确问题之一;我们有一个实现,可通过 BAZEL_TRACK_SOURCE_DIRECTORIES=1
JVM 属性启用)
一种重要的 Artifact
是中间人。它们由 MiddlemanAction
的输出中的 Artifact
实例表示。它们用于以下特殊情况:
- 汇总中间人用于将工件组合在一起。这样一来,当许多操作使用同一大型输入集时,我们就不会有 N*M 依赖项边缘,只有 N+M(它们会被替换为嵌套集)
- 安排依赖项中间程序可确保操作先于另一个进程运行。它们主要用于 lint 检查,也用于 C++ 编译(如需了解说明,请参阅
CcCompilationContext.createMiddleman()
) - Runfile 中间件用于确保是否存在 runfiles 树,这样它就不需要单独依赖于输出文件清单和 runfiles 树引用的每个工件。
最好将操作理解为需要运行的命令、命令所需的环境及其生成的输出集。操作说明的主要组成部分包括:
- 需要运行的命令行
- 所需的输入工件
- 需要设置的环境变量
- 描述需要在其中运行的环境(例如平台)的注解 \
还有一些其他特殊情况,例如编写内容供 Bazel 使用的文件。它们是 AbstractAction
的子类。尽管 Java 和 C++ 有自己的操作类型(JavaCompileAction
、CppCompileAction
和 CppLinkAction
),但大多数操作都是 SpawnAction
或 StarlarkAction
(它们应该可以视为不同的类)。
我们最终想要将所有内容移至 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 中需要“全局”视图 build 的少数几个场景之一。
执行阶段
此时,Bazel 实际上会开始运行构建操作,例如生成输出的命令。
在分析阶段后,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++ 中,这称为“.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
的构造函数中。操作上下文类型由引用ActionContext
子接口的 JavaClass
实例标识,并且操作上下文必须实现哪个接口。 - 系统会从可用操作中选择适当的操作上下文,并将其转发到
ActionExecutionContext
和BlazeExecutor
。 - 操作使用
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>
如何确定与特定配置相关联的目录的名称?以下两个属性是相互冲突的:
- 如果同一 build 中可以出现两种配置,那么它们应该具有不同的目录,这样两者就可以拥有同一操作的同一版本;如果两者的配置不同,例如某项操作的命令行生成相同的输出文件,Bazel 就不知道该选择哪种操作(“操作冲突”)。
- 如果两个配置表示“大致”相同,则它们应具有相同的名称,以便在其中一个命令行中执行的操作重合在一起时,如果命令行匹配:例如,更改 Java 编译器的命令行选项不应该导致 C++ 编译操作重新运行。
到目前为止,我们尚未找到解决此问题的原则性方法,与配置剪辑的问题类似。如需详细了解各个选项,请点击此处。 主要有问题的方面是 Starlark 规则(其作者通常并不熟悉 Bazel)和方面,这些方面向可以生成“相同”输出文件的事物空间中添加了另一个维度。
当前方法是,配置的路径段为 <CPU>-<compilation mode>
(添加了各种后缀),以便在 Java 中实现的配置转换不会导致操作冲突。此外,我们还添加了 Starlark 配置转换的校验和,以使用户不会造成操作冲突。这还远远不够。这在 OutputDirectories.buildMnemonic()
中实现,并依赖于每个配置 fragment,将其自己的部分添加到输出目录的名称中。
测试
Bazel 提供丰富的运行测试功能。它支持:
- 远程运行测试(如果有远程执行后端可用)
- 并行运行多次测试(用于降低数据老化情况或收集时间数据)
- 分片测试(将同一个测试中的测试用例拆分为多个进程以实现速度)
- 重新运行不稳定的测试
- 将测试分组为测试套件
测试是配置有 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 表示成功,其他一切失败。
除了缓存状态文件之外,每个测试进程还会发出很多其他文件。这些日志会放入“test log directory”(该目录是目标配置的输出目录的 testlogs
子目录)中:
test.xml
:一个 JUnit 样式的 XML 文件,详细说明了测试分片中的各个测试用例test.log
- 测试的控制台输出。stdout 和 stderr 不会被分隔。test.outputs
,即“未声明的输出目录”;此测试供除了输出输出到终端的文件之外,还想输出文件的测试使用。
在测试执行期间可能会出现以下两种情况且无法在构建常规目标期间发生:测试执行和输出流式传输。
一些测试需要在独占模式下执行,例如,不与其他测试并行执行。可以通过在测试规则中添加 tags=["exclusive"]
或使用 --test_strategy=exclusive
运行测试来引发这个问题。每项独占测试都由单独的 Skyframe 调用运行,请求在“主”构建之后执行测试。这在 SkyframeExecutor.runExclusiveTest()
中实现。
与在操作完成时转储终端输出的常规操作不同,用户可以请求流式传输测试的输出,以便他们了解长时间运行的测试的进度。这由 --test_output=streamed
命令行选项指定,意味着独占测试执行,以确保不同测试的输出不分散。
该过程在适当命名的 StreamedTestOutput
类中实现,其运作方式是轮询相关测试的 test.log
文件的更改,并将新字节转储到 Bazel 规则所在的终端。
通过观察各种事件(例如 TestAttempt
、TestResult
或 TestingCompleteEvent
)在事件总线上获得已执行结果的结果。它们会转储到构建事件协议,并由 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
。如果您将 --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
的子类。
毫无疑问,某些查询输出格式(但肯定是)的细微要求是,Bazel 需要发出软件包加载所提供的所有信息,以便能够区分输出并确定特定目标是否已更改。因此,属性值需要可序列化,因此只有极少数的属性类型才会具有复杂的 Starlark 值。通常的解决方法是使用标签,并将复杂信息附加到带有该标签的规则。这样做不是很满意的解决方法 这样做对我们非常有帮助
模块系统
您可以通过向 Bazel 添加模块来对其进行扩展。每个模块都必须对 BlazeModule
进行子类化(它是 Bazel 原来名为 Blaze 的遗迹),并获取命令期间执行的各种事件的信息。
它们主要用于实现只有部分版本的 Bazel(例如我们在 Google 中使用的 Bazel)需要的各种“非核心”功能:
- 远程执行系统的接口
- 新命令
BlazeModule
提供的一组扩展点有些随意。请勿将其用作良好的设计原则示例。
活动公交车
BlazeModule 与 Bazel 其余部分进行通信的主要方式是事件总线 (EventBus
):为每个 build 创建一个新实例,Bazel 的各个部分都可以向其中发布事件,并且模块可以为其感兴趣的事件注册监听器。例如,以下内容表示为事件:
- 确定要构建的构建目标列表 (
TargetParsingCompleteEvent
) - 已确定顶级配置 (
BuildConfigurationEvent
) - 已成功构建目标 (
TargetCompleteEvent
) - 已运行测试(
TestAttempt
,TestSummary
)
在 Build Event Protocol 中,其中一些事件在 Bazel 之外表示(即 BuildEvent
)。这样,您不仅可以运行 BlazeModule
,还可以了解 Bazel 进程之外的内容,以便观察构建情况。它们既可以作为包含协议消息的文件进行访问,也可以由 Bazel 连接到服务器(称为“构建事件服务”)来流式传输事件。
这在 build.lib.buildeventservice
和 build.lib.buildeventstream
Java 软件包中实现。
外部代码库
然而,Bazel 最初的设计方案是单体式应用(一个源代码树,其中包含人们需要构建的一切资源),而 Bazel 生活在一个不一定如此的世界。“外部代码库”是一种抽象概念,用于桥接这两个世界:它们表示 build 所需的代码,但不在主源代码树中。
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 会执行两项操作:
- 允许用户指定工作区 Bazel 无法访问的子目录。它们列在名为
.bazelignore
的文件中,而相应功能在BlacklistedPackagePrefixesFunction
中实现。 - 我们将工作区从子目录到外部代码库的映射编码为
ManagedDirectoriesKnowledge
,并像处理常规外部代码库一样处理FileStateValue
引用它们的情况。
代码库映射
可能会出现多个代码库依赖于不同代码库(但版本不同)的情况(这是“菱形依赖项问题”的一个实例)。例如,如果 build 中单独的代码库中的两个二进制文件想要依赖于 Guava,它们可能会同时引用 Guava(带有以 @guava//
开头的标签),并预计该变量代表不同的版本。
因此,Bazel 允许重新映射外部代码库标签,以便字符串 @guava//
可以从一个二进制文件的代码库引用另一个 Guava 代码库(例如 @guava1//
)和另一个 Guava 代码库(例如 @guava2//
)的代码库。
或者,这也可用于联接钻石。如果一个代码库依赖于 @guava1//
,另一个代码库依赖于 @guava2//
,则代码库映射允许一个代码库重新映射这两个代码库以使用规范的 @guava//
代码库。
该映射在 WORKSPACE 文件中指定为各个代码库定义的 repo_mapping
属性。然后,它会在 Skyframe 中显示为 WorkspaceFileValue
的成员,并会被传送到:
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 线程都有自己的任务堆栈。待办事项:如何将此与操作和接续传递样式搭配使用?
分析器分别在 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
文件,然后各种辅助方法可以请求配置的目标、更改配置并就分析结果断言各种内容。