Bazel 代码库

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

简介

Bazel 的代码库很大(约 35 万 KLOC 生产代码和约 260 KLOC 测试代码),没有人熟悉整个环境:所有人都非常清楚自己的具体山谷,但几乎不知道每个方向的小山丘

为了让用户在路径中途过关 not 将,无法走进森林中,却失去了直接衔接的通道,本文档会尝试简要介绍代码库,以便更轻松地上手{101 正在处理。

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 可执行文件。它使用以下步骤设置适当的服务器进程:

  1. 检查自身是否已提取。如果不是,它会实现。这是服务器实现的来源。
  2. 检查是否存在有效的服务器实例:该实例正在运行、具有合适的启动选项并使用正确的工作区目录。它通过查看目录 $OUTPUT_BASE/server(用于找到服务器正在侦听的端口文件)来查找正在运行的服务器。
  3. 如果需要,请终止旧服务器进程
  4. 如果需要,请启动新的服务器进程

准备好适当的服务器进程后,需要通过 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 的外部代码库。
  • 执行根目录,这是一个包含当前构建的所有源代码的符号链接的目录。位于 $OUTPUT_BASE/execroot。在构建期间,工作目录为 $EXECROOT/<name of main repository>。我们计划将其更改为 $EXECROOT,但这是一项长期计划,因为这是一项不相容的更改。
  • 构建期间构建的文件。

执行命令的过程

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

  1. 系统已收到关于新请求的 BlazeCommandDispatcher 通知。它用于确定该命令是否需要运行工作区(几乎除了命令之外,每个命令都与源代码无关,例如版本或帮助),以及另一个命令是否正在运行。

  2. 找到正确的命令。每个命令都必须实现 BlazeCommand 接口,并且必须具有 @Command 注释(这属于反模式,最好能够描述命令所需的所有元数据)根据 BlazeCommand 上的方法)

  3. 系统会解析命令行选项。每个命令都有不同的命令行选项,详见 @Command 注释。

  4. 创建事件总线。事件总线是在构建期间发生的事件流。其中有些会按照构建事件协议导出到 Bazel 之外,以便让世界各地的用户了解 build 是如何运行的。

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

  6. 系统会解析命令行中的一组目标模式,并解析 //pkg:all//pkg/... 等通配符。此操作在 AnalysisPhaseRunner.evaluateTargetPatterns() 中实现,并在 Skyframe 中更新为 TargetPatternPhaseValue

  7. 运行加载/分析阶段,生成操作图(需要针对构建执行的命令式无环图)。

  8. 执行阶段运行。这意味着需要运行构建构建顶级目标所需的每项操作。

命令行选项

Bazel 调用的命令行选项在 OptionsParsingResult 对象中进行描述,该对象又包含从“选项类”到选项值的映射。“选项类”是 OptionsBase 的子类,用于将彼此相关的命令行选项组合在一起。例如:

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

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

警告:我们假设 OptionsBase 实例不可变,并以这种方式使用它们(例如 SkyKeys 的一部分)。事实并非如此,修改这些参数是以非常难调试的方式破坏 Bazel 的好方法。遗憾的是,使其真正不可变是一项 large 巨的任务。(在构建完成之后,在任何其他机会获得对它的引用以及调用 equals()hashCode() 之前,可以立即修改 FragmentOptions。)

Bazel 可以通过以下方式了解选项类:

  1. 部分内容硬固定到 Bazel (CommonCommandOptions)
  2. 根据每个 Bazel 命令上的 @Command 注释
  3. ConfiguredRuleClassProvider(这些是与各个编程语言相关的命令行选项)
  4. Starlark 规则还可以定义自己的选项(请参阅此处

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

命令行选项值的 Java 类型通常是简单的字符串(字符串、整数、布尔值、标签等)。不过,我们还支持更复杂的类型选项;在此示例中,从命令行字符串转换为数据类型的作业属于 com.google.devtools.common.options.Converter 的实现。

Bazel 的源代码树

Bazel 属于构建软件的业务,通过阅读和解读源代码实现。Bazel 运行的源代码整体称为“工作区”,它构造成代码库、软件包和规则。

代码库

“代码库”是指开发者所执行操作的源代码树;通常代表一个项目。Bazel 的祖先 Blaze 运行在单一代码库上,即单一源代码树,其中包含用于运行构建的所有源代码。相比之下,Bazel 则支持其源代码跨多个代码库的项目。调用 Bazel 的代码库称为“主代码库”,其他代码库称为“外部代码库”。

代码库在其根目录中标有 WORKSPACE(或 WORKSPACE.bazel)的文件。此文件包含对整个构建来说“全局”的信息,例如可用的外部代码库集。它的工作方式与常规 Starlark 文件一样,这意味着一个用户可以load()其他 Starlark 文件。 这通常用于拉取明确引用的代码库所需的代码库(我们称之为“deps.bzl 模式”)

外部代码库的代码已符号链接或下载到 $OUTPUT_BASE/external 下。

运行 build 时,需要将整个源代码树组合在一起。此操作由 SymlinkForest 完成,SymlinkForest 会将主代码库中每个软件包与 $EXECROOT 进行符号链接,并将每个外部代码库与 $EXECROOT/external$EXECROOT/.. 相关联(后者当然是主代码库中无法包含名为 external 的软件包(正因如此,我们将从中弃用该软件包)

包裹

每个代码库都由软件包、相关文件集合以及依赖项规范组成。这些元数据由名为 BUILDBUILD.bazel 的文件指定。如果两者同时存在,Bazel 会首选 BUILD.bazel;之所以接受 BUILD 文件,是因为 Bazel 的祖先 Blaze 使用了此文件名。但事实是,它是一个常用的路径段,特别是在 Windows 上,文件名不区分大小写。

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

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

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

全球化在以下类中实现:

  • LegacyGlobber,一个快速、无人打扰的 Skyframe 玻璃器
  • SkyframeHybridGlobber,使用 Skyframe 并切换回旧版 globber 以避免“Skyframe 重启”(如下所述)的版本

Package 类本身包含一些成员,这些成员专门用于解析 WORKSPACE 文件,而不适用于实际软件包。这是一个设计缺陷,因为描述常规软件包的对象不应包含描述其他内容的字段。其中包括:

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

理想的情况是,解析 WORKSPACE 文件与解析常规软件包之间会更明显,这样 Package 就不必同时满足两者的需求。遗憾的是,由于两个维度相互交织在一起,所以难以完成操作。

标签、目标和规则

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

  1. 文件:属于 build 的输入或输出的内容。在 Bazel 中,我们将它们称为“工件”,在其他地方讨论。 不是在构建期间创建的所有文件都是目标; Bazel 的输出通常没有关联的标签。
  2. 规则:介绍从输入内容中获取其输出的步骤。它们通常与编程语言(例如cc_libraryjava_librarypy_library),但存在一些与语言无关的元素(例如genrulefilegroup
  3. 软件包组:在可见性部分讨论。

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

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

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

Starlark 规则类需要在 BUILD 文件开头使用 load() 语句导入,而 Java 规则类是通过在 ConfiguredRuleClassProvider 中注册的“先天之知”的中披露政府所要求信息的数量和类型。

规则类包含以下信息:

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

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

天空框架

Bazel 的评估框架称为 Skyframe。它所采用的模式是,构建期间需要构建的所有内容都将组织到一个有向无环图中,其边缘指向任何数据至其依赖项,也就是需要其他数据片段。才能被建造。

图中的节点称为 SkyValue,其名称称为 SkyKey。两者都是完全不可变的;只有不可变对象才可供通过它们访问。此不变性几乎总是保持不变,如果并非如此(例如,各个选项类 BuildOptions,它是 BuildConfigurationValue 及其 SkyKey 的一个成员),我们尝试很难改变它们,或者不能只从外面无法观察到的方式改变它们。 因此,在 Skyframe 中计算的所有内容(例如配置的目标)也必须不可变。

观察 Skyframe 图表的最便捷方法是运行 bazel dump --skyframe=detailed,它会转储图表,每行一个 SkyValue。最好针对较小的 build 执行此操作,因为 build 可能会非常大。

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

生成新的 SkyValue 涉及以下步骤:

  1. 运行关联的 SkyFunction
  2. 声明 SkyFunction 完成其作业所需的依赖项(例如 SkyValue)。这可通过调用 SkyFunction.Environment.getValue() 的各种过载来实现。
  3. 如果依赖项不可用,Skyframe 会通过从 getValue() 返回 null 来发出信号。在这种情况下,SkyFunction 应返回 null,从而控制 Skyframe,然后 Skyframe 会评估尚未评估的依赖项,并再次调用 SkyFunction:所以返回 (1)。
  4. 构造生成的 SkyValue

这样做的结果是,如果 (3) 中的所有依赖项都不可用,函数就需要完全重启,因此需要重新计算。这显然效率非常低下。我们通过以下几种方式解决这个问题:

  1. 在一个组中声明 SkyFunction 的依赖项,这样一来,如果一个函数有 10 个依赖项,它只需要重启一次,而不是十次。
  2. 拆分 SkyFunction,使得一个函数不需要多次重启。这样做会对将数据放入 SkySkyFunction 内部的 Skyframe 中造成负面影响,从而提高内存用量。
  3. 使用“在 Skyframe 后端后面”缓存来保持状态(例如 ActionExecutionFunction.stateMap 中正在执行操作的状态)。在极端情况下,这最终会导致以继续传递的方式(例如操作执行)编写代码,这样无法提高可读性。

当然,这仅仅是解决 Skyframe 限制的临时解决方法,原因主要在于 Java 不支持轻量线程,并且我们通常有数十万个运行中的 Skyframe{ 101} 个节点。

施塔尔克

Starlark 是网域用户用来配置和扩展 Bazel 的一种特定语言。它被构想为一个受限制的 Python 子集,其类型要少得多,对控制流有更多限制,最重要的是,具有强大的不变性保证可以启用并发读取。它不是图灵完成的,它阻止了一些(但不是所有)用户尝试用该语言完成常规编程任务。

Starlark 是在 com.google.devtools.build.lib.syntax 软件包中实现的。 它还在此处提供了独立的 Go 实现。Bazel 中使用的 Java 实现目前是一个解释器。

Starlark 用于四种情况:

  1. Build 语言。 此部分定义了新规则。在此上下文中运行的 Starlark 代码只能访问 BUILD 文件本身及其加载的 Starlark 文件的内容。
  2. 规则定义。 这是新规则(例如对新语言的支持)的定义方式。在此上下文中运行的 Starlark 代码可以访问其直接依赖项提供的配置和数据(稍后详细介绍)。
  3. WORKSPACE 文件。 这是定义外部代码库(不在主源代码树中的代码)的地方。
  4. 代码库规则定义。 这里定义了新的外部代码库类型。在此上下文中运行的 Starlark 代码可以在运行 Bazel 的机器上运行任意代码,并且可以在工作区之外访问。

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

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

加载/分析阶段

在加载/分析阶段,Bazel 会确定需要执行哪些操作来构建特定规则。它的基本单元是一个“已配置的目标”,从合理的程度来看,这是一个(目标、配置)对。

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

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

在命令行中请求配置的已配置目标的传递性关闭中,每个配置的目标都必须自下而上分析; (即叶节点),接着是命令行上的节点。分析单个已配置目标的输入如下:

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

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

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

为 Java 规则提供的 API 是 RuleContext,它相当于 Starlark 规则的 ctx 参数。它的 API 功能更强大,但与此同时,它更容易执行 Bad ThingsTM,例如编写时间或空间复杂度为二次(或更糟)的代码,以便使 Bazel 服务器崩溃 Java 异常或违反不变量(例如,无意中修改了 Options 实例或使配置的目标可变)

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

配置

配置是构建目标的“方式”,包括哪个平台以及哪些命令行选项等。

可以为同一 build 中的多个配置构建相同的目标。例如,当相同的代码用于在构建期间运行的工具和目标代码,而且我们正在进行交叉编译时,或者我们会在构建 a 的 Android 应用时(一个代码包含用于多个 CPU 架构的原生代码)

从概念上讲,配置是 BuildOptions 实例。但实际上,BuildOptionsBuildConfiguration 进行封装,后者提供了其他功能。它会从依赖关系图的顶部传播到底部。如果发生更改,则需要重新分析构建。

这会产生异常情况,例如,如果所请求的测试运行次数发生变化,则必须重新分析整个 build,尽管这仅会影响测试目标(我们计划“调整”配置,以便但情况并非如此。

当规则实现需要配置的一部分时,需要使用 RuleClass.Builder.requiresConfigurationFragments() 在其定义中声明该配置。这样做既是为了避免错误(例如使用 Java Fragment 的 Python 规则),又是为了简化配置调整(例如在 Python 选项发生更改时),因此无需重新分析 C++ 目标。

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

  1. 在依赖项边缘上。这些转换在Attribute.Builder.cfg()并且来自Rule(转换过程)和BuildOptions(原始配置)更改为一个或多个BuildOptions(输出配置)。
  2. 在已配置目标的任何传入边缘上。这些操作是在 RuleClass.Builder.cfg() 中指定的。

相关类为 TransitionFactoryConfigurationTransition

我们会使用配置转换,例如:

  1. 声明在构建过程中使用了特定依赖项,因此应在执行架构中构建该依赖项
  2. 声明必须针对多个架构构建特定依赖项(例如针对 Android Android APK 中的原生代码)

如果一个配置转换会生成多项配置,我们称之为“拆分转换”。

您还可在 Starlark 中实现配置转换(详见此处

传递信息提供程序

传递信息提供程序是已配置目标的一种方式(_只可_),_以便告知依赖于所配置的其他已配置目标。名称中采用“传递”的原因在于,这通常是对配置的目标的传递关闭的某种结束。

Java 传递信息提供程序与 Starlark 提供程序之间通常存在 1:1 的对应关系(DefaultInfoFileProviderFilesToRunProviderRunfilesProvider 的合并,因为而该 API 比直接对 Java 进行音译的方式更像 Starlark 语。 密钥包括下列其中一项:

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

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

配置的目标

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

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

  1. 他们的 filesToBuild,这是“此规则所代表的文件集”的 概念。 这些是所配置的目标在命令行中或 Ganrule 的 src 中构建的文件。
  2. 其 Runfile、常规和数据。
  3. 它们的输出组。这是规则可以构建的各种“其他文件集”。您可通过以下方式访问它们:在 BUILD 中使用 filegroup 规则的 output_group 属性,在 Java 中使用 OutputGroupInfo 提供程序。

Runfile

部分二进制文件需要数据文件才能运行。例如,需要输入文件的测试就属于这种情况。以“runfiles”的概念在 Bazel 中表示。“runfiles 树”是特定二进制文件的数据文件的目录树。它是在文件系统中以符号链接树形式创建的,其中包含各个符号链接,这些符号链接指向输出树源端的文件。

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

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

Runfile 是使用 RunfilesProvider 收集的:此类的实例表示已配置的目标(例如库)的 Runfile 及其传递性关闭需求,这些对象像嵌套集一样收集(实际上,它们使用封面下的嵌套集实现:每个目标会合并其依赖项的 runfile,添加一些自己的文件,然后将生成的设置在依赖项图中向上发送。一个 RunfilesProvider 实例包含两个 Runfiles 实例,一个实例在通过“data”特性依赖规则时,另一个实例在其他所有传入传入依赖项中使用。这是因为,在依赖数据文件时,目标有时会显示不同的 Runfile,而不是其他方式。这是我们想要移除的不希望出现旧版行为。

二进制文件的 Runfile 表示为 RunfilesSupport 的实例。这与 Runfiles 不同,因为 RunfilesSupport 实际上具有构建能力(与 Runfiles,后者只是一个映射)。这还需要以下其他组件:

  • 输入 runfiles 清单。 这是 Runfiles 树的序列化说明。它用作 runfiles 树内容的代理,而 Bazel 假定 runfiles 树只有在且该清单的内容发生更改时才会更改。
  • 输出 runfiles 清单。 由负责处理 runfiles 树的运行时库(尤其是在 Windows 上)通过此实现(有时不支持符号链接)。
  • runfiles 中间人。 为建立 runfiles 树,需要构建符号链接树和符号链接所指向的工件。为了减少依赖项边缘的数量,runfile 中间人可用于表示所有这些方面。
  • 命令行参数,用于运行其 RunfilesSupport 表示 runfiles 的二进制文件。

方面

式是一种“将依赖关系传递到依赖关系图上”的方法。此处为 Bazel 用户提供了说明。一个很好的动机示例是协议缓冲区:proto_library 规则应该不知道任何特定语言,但构建协议缓冲区消息(协议缓冲区的“基本单元”)的实现。任何编程语言应该都与 proto_library 规则结合,以便如果同一语言的两个目标依赖于相同的协议缓冲区,则仅构建一次。

与配置的目标一样,它们在 Skyframe 中表示为SkyValue它们的构建方式与已配置的目标的构建方式非常相似:它们都有一个名为ConfiguredAspectFactory有权访问RuleContext,但它与已配置的目标工厂不同,它也知道它所连接到的已配置目标及其提供商。

使用 Attribute.Builder.aspects() 函数为每个属性指定一系列传播的依赖关系图。下面列举了一些参与流程且难以命名的类:

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

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

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

平台和工具链

Bazel 支持多平台构建,即可能运行多个构建操作的架构和多个为其构建代码的架构。这些架构称为平台使用 Bazel 比较数据(完整文档)此处

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

工具链的概念源于以下事实:根据要运行的平台和目标平台,可能需要使用不同的编译器;例如,特定的 C++ 工具链可以在特定操作系统上运行,并且可以定位其他操作系统。Bazel 必须根据设定的执行和目标平台(工具链文档)确定所使用的 C++ 编译器。

为此,工具链需要使用它们支持的一组执行和目标平台限制条件进行注释。为此,工具链的定义分为两个部分:

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

我们之所以这样做,是因为我们需要了解每个工具链的限制条件,以得出工具链解析。另外,特定于语言的 *_toolchain() 规则包含的信息比这要多得多,因此它们需要更多{101 加载时间。

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

  1. 使用 register_execution_platforms() 函数在 WORKSPACE 文件中
  2. 在命令行上使用 --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 内部使用,用于 Java 规则;它们正在导出,无法使用 Bazel,但源代码可能包含对其的引用。控制此属性的特性称为 constraints=

Environment_group() 和 environment()

这些规则是旧版机制,未得到广泛应用。

所有构建规则均可声明可以为其构建哪些“环境”,其中“环境”是 environment() 规则的实例。

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

  1. 通过 restricted_to= 属性。这是最直接的规格形式;它会声明此规则组对该组支持的确切环境。
  2. 通过 compatible_with= 属性。这不仅声明了默认支持的“标准”环境,还声明了规则支持的环境。
  3. 通过软件包级属性 default_restricted_to=default_compatible_with= 实现。
  4. 通过 environment_group() 规则中的默认规范。每个环境都属于一组主题背景相关的对等体(例如“CPU 架构”、“JDK 版本”或“移动操作系统”)。环境组的定义包括“默认”应支持哪些环境(除非 restricted_to= / environment() 属性另行指定)。不含此类特性的规则会继承所有默认值。
  5. 通过规则类默认值。这会替换给定规则类的所有实例的全局默认值。例如,可以使用此方法来使所有 *_test 规则均可测试,而无需每个实例明确声明此功能。

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

可以使用 --target_environment 命令行选项将构建仅限于特定环境。

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

平台限制

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

可见状态

如果您与许多开发者(例如 Google 的员工)合作构建大型代码库,则不一定希望其他人可以依赖您的代码,这样您才可以自由地更改您认为属于实现细节(否则,根据 Hyrum 的法律,人们依赖于您代码的所有部分)。

Bazel 支持这一机制,名为 _visibility: _您可以声明特定规则只能依赖于使用可见性属性(此处参阅文档)。此属性有点特殊,因为它与其他属性不同,它生成的依赖项集不仅仅是列出的标签集(是,这存在设计缺陷)。

此 API 在以下位置实现:

  • RuleVisibility 接口表示可见性声明。它可以是常量(完全公开或完全私有),也可以是标签列表。
  • 标签既可引用软件包组(预定义的软件包列表),也可直接引用软件包 (//pkg:__pkg__) 或软件包的子树 (//pkg:__subpackages__)。这与使用 //pkg:*//pkg/... 的命令行语法不同。
  • 软件包组作为自己的目标和配置的目标类型(PackageGroupPackageGroupConfiguredTarget)实现。如有需要,我们可能将其替换为简单的规则。
  • 从可见性标签列表到依赖项的转换是在 DependencyResolver.visitTargetVisibility 以及其他一些位置进行的。
  • 实际检查在 CommonPrerequisiteValidator.validateDirectPrerequisiteVisibility() 中完成

嵌套集

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

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

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

为解决这一问题,我们制定了 NestedSet 的概念。这是一种数据结构,由其他 NestedSet 实例及其自身的某些成员组成,因而形成了一组有向无环图。它们是不可变的,其成员可以迭代。我们定义了多个迭代顺序 (NestedSet.Order):预序、后序、拓扑(节点始终在其祖先之后)和“随意”,但每次都应保持一致。

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

工件和操作

实际 build 包含一组需要运行的命令,用于生成用户所需的输出。该命令表示为 Action 类的实例,文件表示为 Artifact 类的实例。它们被安排在一个由两部分构成的有向无环图(称为“操作图”)中。

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

  1. {/0}常规工件。**计算 校验和(以 mtime 作为快捷方式)可检查其是否是最新的;如果文件的时间未更改,我们不会对其进行校验和。
  2. 无法解析的符号链接工件。 这些函数通过调用 readlink() 进行检查以确保最新。与常规工件不同,这些测试可能是悬挂符号链接。通常适合将一个文件打包成某种归档文件。
  3. 树工件: 这些不是单个文件,而是目录树。系统会通过检查文件中的文件集及其内容来检查文件是否处于最新状态。它们用 TreeArtifact 表示。
  4. 常量元数据工件。 对这些工件的更改不会触发重新构建。仅用于构建时间戳信息,我们不希望因当前时间发生变化而执行重建。

源工件不能是树工件或未解析的符号链接工件的根本原因,只是我们尚未实现它(不过,我们应该 - 在 BUILD 文件中引用源目录是一种)是 Bazel 长期存在的一些误判问题;我们通过 BAZEL_TRACK_SOURCE_DIRECTORIES=1 JVM 属性实现了此类工作的实现方法)

一个值得注意的 Artifact 是中间人。它们由 Artifact 实例指示,这些实例是 MiddlemanAction 的输出。它们用于在特殊情况下特殊操作:

  • 聚合中间件用于将工件组合在一起。这样一来,如果很多操作使用同样的大量输入,我们就没有 N*M 依赖关系边缘,而只有 N+M(它们被嵌套集所取代)
  • 调度依赖项中间层可确保操作在另一个之前运行。它们主要用于执行 lint 请求,而且还用于 C++ 编译(有关说明,请参阅 CcCompilationContext.createMiddleman()
  • Runfiles 中间件用于确保 Runfile 树的存在,以便不需要单独依赖输出文件清单以及 runfiles 树引用的每个工件。

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

  • 需要运行的命令行
  • 需要的输入工件
  • 需要设置的环境变量
  • 用于描述广告运行环境(例如平台)的注释

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

我们最终希望将所有内容迁移到SpawnActionJavaCompileAction非常相似,不过由于 .d 文件解析和包含扫描,C++ 是一种特殊情况。

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

  • 派生工件没有自己的 SkyValue。相反,Artifact.getGeneratingActionKey() 用于找出生成该字符串的操作的键
  • 嵌套集具有自己的 Skyframe 密钥。

共享操作

有些操作是由多个配置的目标生成; Starlark 规则的适用范围更广,因为系统只能将其派生的操作放入由其配置和软件包确定的目录中(但即使如此,同一软件包中的规则也可能发生冲突),但规则使用 Java 实现的通用工件。

这被视为错误功能,但将其清除很难,因为这样可以大幅缩短执行时间,例如,如果源文件需要经过某种处理,并且该文件被 101}多条规则(Handwave-Handwave)。这会消耗一些 RAM 的费用:共享操作的每个实例都需要分别存储在内存中。

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

执行阶段

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

Bazel 在分析阶段后首先执行的操作是确定需要构建工件。该过程的逻辑以 TopLevelArtifactHelper 进行编码;一般来说,它是命令行中已配置目标的 filesToBuild,以及特殊输出组的内容,目的是明确表示“如果此目标位于命令行中。构建这些工件”。

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

  • 当软件包从软件包路径条目移动到另一个路径(通常很常见)时,它会更改操作命令行
  • 如果操作是远程运行的(与在本地运行相比),则会导致不同的命令行
  • 它需要特定于所用工具的命令行转换(请考虑 Java 类路径与 C++ include 路径等之间的区别)
  • 更改操作的命令行会导致其操作缓存条目失效
  • --package_path正在逐步弃用

然后,Bazel 会开始遍历操作图(由操作及其输入和输出工件组成的有向双图)。每项操作的执行均由 SkyValueActionExecutionValue 的实例表示。

由于运行操作的成本高昂,因此我们有几个层的缓存可在 Skyframe 后面发挥作用:

  • ActionExecutionFunction.stateMap 包含一些数据,可用于以 ActionExecutionFunction 的价格便宜地重启 Skyframe
  • 本地操作缓存包含有关文件系统状态的数据
  • 远程执行系统通常也拥有自己的缓存

本地操作缓存

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

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

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

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

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

输入发现和输入剪

一些操作比仅接收一组输入更为复杂。操作集的输入变化有两种形式:

  • 操作可能会在执行之前发现新输入,或确定其部分输入实际上不必要。规范的例子是 C++,最好对 C++ 文件从其传递式闭包中使用哪些头文件进行有根据的推测,以免我们倾向于将每个文件发送给远程执行程序;因此,我们可选择不将每个头文件注册为“输入”,但扫描源文件以查找传递的头文件,且仅将这些头文件标记为#include声明(我们高估了这样我们不需要实现完整的 C 预处理器)。此选项目前在 Bazel 中硬性设置为“false”,并且仅供 Google 使用。
  • 操作可能会发现某些文件在执行期间未被使用。在 C++ 中,这称为“.d 文件”:编译器会在事后告知使用了哪些头文件,并为了避免比 Make 更糟糕的增量,Bazel 会使用以下事实。这种方法比 include 扫描程序更好,因为它依赖于编译器。

这些组件是用 Action 实现的:

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

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

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

运行操作的各种方式:Strategy/ActionContexts

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

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

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

出价策略可以随意调用其他策略来完成工作;例如,动态 IP 用于在本地和远程启动操作的动态策略中,然后再使用首先完成的操作。

一个显著的策略是实现永久性工作器进程 (WorkerSpawnStrategy)。其理念在于,某些工具启动时间较长,因此应在不同操作之间重复使用,而不是为每个操作重新开始(这实际上意味着潜在的正确性问题,因为 Bazel 依赖于 Promise) (在各个工作器之间不传递可观察状态的工作器进程)

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

有关策略(或操作上下文)的更多信息:

  • 如需了解运行操作的各种策略,请点击此处
  • 关于动态策略的信息,我们会在本地和远程运行某项操作,以查看在此处先完成哪些操作。
  • 点击此处,即可了解在本地执行操作的复杂性。

本地资源管理器

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

这在 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 中实现。令人吃惊的一点是,如果测试套件没有声明任何测试,就表示其软件包中的每个测试。 这是在 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 规则所在的终端。

通过观察各种事件(例如 TestAttemptTestResultTestingCompleteEvent),可以将执行的测试的结果发送到事件总线。它们会被转储到构建事件协议,由 AggregatingTestListener 发送到控制台。

覆盖率集合

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

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

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

collect_coverage.sh 的交互由测试策略完成,并且要求 collect_coverage.sh 位于测试的输入上。该过程通过隐式属性 :coverage_support 完成,该属性会解析为配置标志 --coverage_support 的值(请参阅 TestConfiguration.TestOptions.coverageSupport

有些语言会执行离线插桩,也就是说,在插桩时添加了覆盖率插桩(例如 C++),还有一些语言会进行在线插桩。也就是说,会在执行时添加覆盖率插桩。

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

基准覆盖范围目前已损坏。

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

插桩文件集就是一组要插桩的文件。对于在线覆盖率运行时,这可用于在运行时确定要插桩的文件。它还可用于实现基准覆盖范围。

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

是否收集覆盖率存储在 BuildConfiguration 中。这很方便,因为能够根据此位轻松更改测试操作和操作图,但也意味着,如果此位被翻转,所有目标都需要重新分析(某些位) (如 C++)需要不同的编译器选项来发出可收集覆盖率的代码,这在一定程度上缓解了此问题,因为随后需要进行重新分析。

覆盖率支持文件依赖于隐式依赖项中的标签,以便调用政策可替换它们,从而使调用版本的不同 Bazel 版本不同。理想情况下,这些差异将被移除,并且我们将其中一个差异标准化。

我们还会生成一份“覆盖率报告”,它会合并在 Bazel 调用中为每个测试收集的覆盖率。此事件由 CoverageReportActionFactory 处理并从 BuildView.createResult() 调用。它通过查看第一个执行的测试的 :coverage_report_generator 属性来获取其所需的工具。

查询引擎

Bazel 使用的是少量语言,用于询问有关各种图的方方面面。系统提供了以下查询种类:

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

每个属性都是通过为 AbstractBlazeQueryEnvironment 创建子类来实现的。通过创建 QueryFunction 的子类可完成其他查询函数。为了允许流式查询结果,而非将其收集到某些数据结构,系统会将 query2.engine.Callback 传递给 QueryFunction,后者会调用它来请求返回的结果。

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

某些查询输出格式(proto,当然是 proto)的细微要求是 Bazel 需要发出(_全部)软件包加载提供的信息,以便用户可以比较输出并确定特定目标是否已更改。{101 }因此,属性值需要可序列化,因此,只有没有任何属性具有复杂 Starlark 值的特性类型也是如此。通常的解决方法是使用标签,并将复杂的信息附加到带有该标签的规则中。该解决方案不是非常令人满意的解决方案,如果满足此要求,也会非常好。

模块系统

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

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

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

BlazeModule 提供的扩展点集有点儿危险。请勿将其用作良好的设计原则的示例。

活动巴士

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

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

其中某些事件在 Build Event Protocol 中不是 Bazel 的(即 BuildEvent)。这不仅允许 BlazeModule,还允许 Bazel 过程之外的方法来观察构建。您可将其作为包含协议消息的文件进行访问,也可将 Bazel 连接到服务器(称为“构建事件服务”)以流式传输事件。

这是在 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() 来检查最新,并且这些工件在存储库定义发生更改时也会失效。因此,外部代码库中的工件的 FileStateValue 需要依赖于其外部代码库。这由 ExternalFilesHelper 处理。

托管目录

有时,外部代码库需要修改工作区根目录下的文件(例如,将下载的软件包保存在源代码树的子目录中的文件包管理器)。这与 Bazel 假设使源文件仅受用户修改(而非自行修改)并允许软件包引用工作区根目录下每个目录的情况并不相符。为了使此类外部代码库正常运行,Bazel 会执行以下两项操作:

  1. 允许用户指定工作区 Bazel 不允许访问的子目录。它们列在名为 .bazelignore 的文件中,相关功能在 BlacklistedPackagePrefixesFunction 中实现。
  2. 我们将从工作区的子目录到它处理的外部代码库的映射编码成 ManagedDirectoriesKnowledge,并处理引用 FileStateValue 的方式,就像引用常规常规目录的外部代码库。

代码库映射

可能会发生这样的情况:多个代码库依赖于同一个代码库,但版本不同(这是“dia 形依赖项问题”)。例如,如果 build 中位于不同代码库中的两个二进制文件都依赖于 Guava,则它们可能都引用 Guava,标签以 @guava// 开头,并希望指其不同版本。

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

您也可以使用这种方式加入“join 形”。 如果某个代码库依赖于 @guava1//,另一个依赖于 @guava2//,则借助代码库映射,其中一个可以重新映射两个代码库,以使用规范化的 @guava// 代码库。

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

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

JNI 位

Bazel 服务器主要采用 Java 编写。例外情况是,Java 在实现它时无法单独执行或无法单独执行。这主要限于与文件系统的交互、进程控制以及各种其他低级别事项。

C++ 代码位于 src/main/native 下,并且采用原生方法的 Java 类有:

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

控制台输出

发出控制台输出的过程似乎很简单,但同时运行多个进程(有时远程执行)、精细的缓存、想要具有丰富多彩的终端输出和运行时间较长的服务器,这使得非常重要。

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

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

短消息(错误、警告等)通过 EventHandler 接口表示。特别是,这不同于向 EventBus 发布的帖子(这令人困惑)。每个 Event 都有一个 EventKind(错误、警告、信息和其他信息),并且还可能有一个 Location(源代码中导致事件的事件)出现)。

某些 EventHandler 实现会存储它们收到的事件。用于重放由各种类型的缓存处理引起的界面信息,例如由缓存的配置的目标发出的警告。

某些 EventHandler 还允许最终发布事件到达事件总线的事件(常规 Event _不会_在此处显示)。这些是 ExtendedEventHandler 的实现,其主要用途是重放缓存的 EventBus 事件。这些 EventBus 事件都会实现 Postable,但发布到 EventBus 的所有内容不一定都会实现此接口;只有那些由 ExtendedEventHandler 缓存的事件(实际为很多方面都是安全的;不过,它不会被强制执行

终端输出主要通过 UiEventHandler 发出,这由 Bazel 执行的所有酷炫输出格式和进度报告负责。它有两个输入:

  • 活动巴士
  • 通过报告程序连接到事件流

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

分析 Bazel

Bazel 非常快。Bazel 也很慢,因为构建往往增长到可以承载的边界。因此,Bazel 包含一个可用于分析构建性能的性能剖析器,以及 Bazel 本身。它是在名为 Profiler 的类中实现的。此功能默认处于启用状态,不过它只记录删节的数据,使其开销可以容忍;利用命令行 --record_full_profiler_data,它会尽可能记录所有内容。

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

性能剖析器分别在 BlazeRuntime.initProfiler()BlazeRuntime.afterCommand() 中启动和停止,并且会尝试保持活跃状态,以便我们可以剖析全部内容。如需向配置文件添加内容,请调用 Profiler.instance().profile()。它会返回一个 Closeable(其关闭代表任务结束)。最好与 try-with-resources 语句配合使用。

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

测试 Bazel

Bazel 有两种主要测试:将 Bazel 视为“黑盒”的测试,以及仅运行分析阶段的测试。两者分别称为“集成测试”和“集成测试”,它们更像是集成性测试,这类测试集成度较低。我们还进行了一些实际的单元测试,这是必要的。

集成测试分为两种类型:

  1. 使用 src/test/shell 下精心设计的 bash 测试框架实现的框架
  2. 使用 Java 实现。它们以 AbstractBlackBoxTest 的子类形式实现。

AbstractBlackBoxTest 的优点在于它也可以在 Windows 上运行,但我们的大多数集成测试都是在 bash 中编写的。

分析测试是以 BuildViewTestCase 的子类形式实现的。您可以使用暂存文件系统编写 BUILD 文件,然后各种帮助程序方法可以请求已配置的目标、更改配置并断言有关分析结果的各项内容。