Bazel 代码库

报告问题 查看源代码

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

简介

Bazel 的代码库非常庞大(生产代码和约 260 KLOC 测试代码),并且没有人熟悉整个环境:每个人都非常了解具体的山谷,但很少有人知道各个方向的山谷究竟有什么。

为避免在途中途中因为路线丢失而陷入黑暗的森林,本文档尝试概要介绍了代码库,以便更轻松地开始处理代码库。

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

执行命令的过程

在 Bazel 服务器获得控制权并被告知需要执行的命令后,将发生以下一系列事件:

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

  2. 找到了正确的命令。每个命令都必须实现接口 BlazeCommand,并且必须具有 @Command 注解(这有点反面,如果一个命令需要的所有元数据都通过 BlazeCommand 中的方法进行描述就更好了)

  3. 系统会解析命令行选项。每个命令都有不同的命令行选项,具体说明请参阅 @Command 注解。

  4. 创建了一个事件总线。事件总线是构建期间发生的事件的数据流。其中一些会以构建事件协议为基础导出到 Bazel 外部,以便告诉所有人构建是如何进行的。

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

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

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

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

命令行选项

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

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

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

警告:我们喜欢假设 OptionsBase 实例是不可变的,然后以这种方式使用它们(例如 SkyKeys 的一部分)。事实并非如此,修改它们是破坏 Bazel 的巧妙方式,而这种方式很难进行调试。遗憾的是,要使它们实际上不可变是一项艰巨的努力。(在构造完成后立即修改 FragmentOptions,这样其他人有机会保留对它的引用,在对 equals()hashCode() 调用之前也没关系。)

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

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

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

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

源代码树,如 Bazel 所示

Bazel 的用途是构建软件,通过读取和解释源代码来实现。运行 Bazel 的源代码的整体称为“工作区”,它的结构为代码库、软件包和规则。

仓库

“仓库”是开发者使用的源代码树,通常代表单个项目。Bazel 的祖先实体 Blaze 在 monorepo 上运行,即包含用于运行构建的所有源代码的单个源代码树。而 Bazel 支持源代码跨多个代码库的项目。调用 Bazel 的代码库称为“主代码库”,其他代码库称为“外部代码库”。

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

外部代码库的代码在 $OUTPUT_BASE/external 下进行符号链接或下载。

运行 build 时,需要将整个源代码树拼凑在一起;这由 SymlinkForest 完成,它会用符号链接将主代码库中的每个软件包链接到 $EXECROOT,并将每个外部代码库链接到 $EXECROOT/external$EXECROOT/..(当然,前者会使主代码库中不可有一个名为 external 的软件包;这就是我们迁离它的原因)

软件包

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

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

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

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

Globbing 在以下类中实现:

  • 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 规则类需要使用 load() 语句在 BUILD 文件的开头导入,而 Java 规则类“天生”为 Bazel 所知,因为它是向 ConfiguredRuleClassProvider 注册的。

规则类包含如下信息:

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

术语说明:在代码库中,我们经常使用“Rule”来表示由规则类创建的目标。但在 Starlark 和面向用户的文档中,“Rule”应专门用于指代规则类本身;目标只是一个“目标”。另请注意,尽管 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 的缓存和增量计算是在 SkyFunctionSkyValue 的粒度运行的。

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

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

  1. 批量声明依赖项(通过使用 getValuesAndExceptions())以限制重启次数。
  2. SkyValue 分解为由不同 SkyFunction 计算的单独部分,以便它们可以独立进行计算和缓存。应该策略性地这样做,因为这可能会增加内存用量。
  3. 通过使用 SkyFunction.Environment.getState() 或将临时静态缓存“在 Skyframe 的后面”保留在重启后存储状态。

从根本上说,我们需要这些类型的解决方法,因为我们通常有数十万个正在执行的 Skyframe 节点,而 Java 不支持轻量级线程。

星鸟星

Starlark 是用于配置和扩展 Bazel 的网域特定语言。它被视为 Python 的受限子集,其类型要少得多,对控制流的限制更多,而且最重要的不可变性保证能够实现并发读取。这并非图灵完备,这会阻止部分(但不是全部)用户尝试使用某种语言完成常规编程任务。

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

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

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

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

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

加载/分析阶段

在加载/分析阶段,Bazel 会确定构建特定规则所需的操作。它的基本单位是“配置的目标”,也就是(目标、配置)对,非常合理。

该阶段称为“加载/分析阶段”,因为它可以拆分为两个不同的部分,这两个部分过去已序列化,但现在可以在时间上重叠:

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

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

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

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

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

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

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

配置

配置决定着如何构建目标:面向什么平台、使用哪些命令行选项等。

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

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

这会导致异常情况,例如,当请求的测试运行数量发生变化时,即使这仅影响测试目标,也必须重新分析整个 build,即使这仅影响测试目标(我们计划“削减”配置,以避免情况并非如此,但尚未做好准备)。

如果规则实现需要配置的一部分,则需要使用 RuleClass.Builder.requiresConfigurationFragments() 在其定义中进行声明。这既可以避免错误(例如,使用 Java fragment 的 Python 规则),也可以方便配置调整,以便在 Python 选项发生变化时不需要重新分析 C++ 目标。

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

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

相关类为 TransitionFactoryConfigurationTransition

使用配置转换,例如:

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

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

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

传递信息提供方

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

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

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

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

配置的目标

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

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

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

Runfile

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

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

  • 大多数情况下,文件的 runfiles 路径与其 execpath 相同。我们用它来节省一些内存。
  • Runfile 树中有各种旧版条目,也需要予以表示。

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

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

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

切面

切面是一种“将计算沿依赖关系图向下传播”的方式。此处介绍了面向 Bazel 用户的说明。协议缓冲区是一个很好的激励示例:proto_library 规则应该不了解任何特定语言,但以任何编程语言构建协议缓冲区消息(协议缓冲区的“基本单位”)的实现都应与 proto_library 规则相结合,这样一来,当相同语言的两个目标依赖于同一个协议缓冲区时,该规则只会构建一次。

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

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

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

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

AspectCollection 类中捕获切面切面的复杂性。

平台和工具链

Bazel 支持多平台构建,也就是说,此类构建可能有多个架构运行构建操作,也支持针对多个架构构建代码。这些架构在 Bazel 术语中称为“平台”(如需查看完整文档,请点击此处

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

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

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

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

这是以这种方式完成的,因为我们需要了解每个工具链的约束条件才能进行工具链解析,而特定于语言的 *_toolchain() 规则包含的信息远不止于这些信息,因此它们需要更多的时间进行加载。

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

  1. 在 WORKSPACE 文件中,使用 register_execution_platforms() 函数
  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 for 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 版本”或“移动操作系统”)。环境组的定义包括“default”应支持其中哪些环境(如果未通过 restricted_to= / environment() 属性另行指定)。没有此类属性的规则将继承所有默认值。
  5. 通过规则类默认设置。这会替换给定规则类的所有实例的全局默认值。例如,这可用于使所有 *_test 规则均可测试,而无需每个实例明确声明此功能。

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

您可以使用 --target_environment 命令行选项将 build 限制在特定环境。

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

平台限制

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

可见性

如果您与许多开发者共同处理大型代码库(例如在 Google 工作),则需要小心谨慎,防止其他人任意根据您的代码随意做出贡献。否则,根据 Hyrum 定律,人们将会依赖您认为属于实现细节的行为。

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

这是在以下位置实现的:

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

嵌套集

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

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

如果我们通过使用 ListSet 等简单方式实现这一点,最终的内存用量将是二次的:如果存在由 N 条规则组成的链,并且每条规则都添加一个文件,那么我们将有 1+2+...+N 个集合成员。

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

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

制品和操作

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

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

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

源制品不能是树制品或未解析的符号链接制品,并没有根本原因,这只是因为我们尚未实现它(不过,我们应该 - 引用 BUILD 文件中的源目录是 Bazel 的已知长期错误之一;我们有一个这样的实现,其工作原理与 BAZEL_TRACK_SOURCE_DIRECTORIES=1 JVM 属性相一致)

值得注意的一种 Artifact 是中间人。它们由 Artifact 实例表示,这些实例是 MiddlemanAction 的输出。它们用于对某些情况进行特殊处理:

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

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

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

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

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

操作图大多“嵌入”到 Skyframe 图中:从概念上讲,操作的执行表示为对 ActionExecutionFunction 的调用。ActionExecutionFunction.getInputDeps()Artifact.key() 中介绍了从操作图依赖关系边缘到天帧依赖关系边缘的映射,并进行了几项优化,以尽量减少 Skyframe 边缘的数量:

  • 派生的工件没有自己的 SkyValue。不过,可以使用 Artifact.getGeneratingActionKey() 查找生成该标识符的操作对应的键。
  • 嵌套集拥有自己的 Skyframe 密钥。

共享操作

有些操作是由多个已配置的目标生成的;Starlark 规则只能将其派生的操作放入由其配置和软件包确定的目录中(但即使如此,同一软件包中的规则也可能发生冲突),但在 Java 中实现的规则可能会将派生的工件放在任何位置。

这被视为功能错误,但消除它真的很困难,因为它可以显著节省执行时间,例如,需要以某种方式处理源文件,并且该文件被多个规则(手波手波)引用时。这需要占用一些 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++ 文件的传递闭包,最好根据 C++ 文件使用的标头文件做出有根据的猜测,这样我们就不必将每个文件都发送给远程执行程序;因此,我们可以选择不将每个头文件注册为“输入”,而是扫描源文件中的传递性包含标头,并且仅将这些标头文件标记为“#include
  • 操作可能会意识到执行期间某些文件没有被使用。在 C++ 中,这称为“.d 文件”:编译器会指出实际使用了哪些头文件,并避免因增量效果比 Make 更糟糕而感到尴尬,Bazel 充分利用了这一点。由于它依赖于编译器,因此比包含扫描器的估算结果更好。

这些操作是使用 Action 上的方法实现的:

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

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

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

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

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

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

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

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

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

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

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

  • 此处提供了运行操作的各种策略的相关信息。
  • 此处提供了动态策略的相关信息。在该策略中,我们同时在本地和远程运行操作,以查看哪一项先完成。
  • 如需了解在本地执行操作的复杂性,请点击此处

本地资源管理器

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

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

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

输出目录的结构

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

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

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

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

到目前为止,我们还没有找到一种解决这个问题的原则性方法,这种方法与配置调整问题有相似之处。此处提供了关于各个选项的详细介绍。主要有问题的方面是 Starlark 规则(其作者通常不太熟悉 Bazel)和切面,后者在可生成“相同”输出文件的内容空间中添加了另一个维度。

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

测试

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

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

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

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

确定要运行哪些测试

确定要运行哪些测试是一个复杂的过程。

首先,在目标模式解析期间,以递归方式扩展测试套件。扩展在 TestsForTargetPatternFunction 中实现。有一点令人惊讶的是,如果测试套件声明没有测试,就会引用其软件包中的每个测试。这在 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 调用运行,请求在“主”构建后执行该测试。这是在 SkyframeExecutor.runExclusiveTest() 中实现的。

与常规操作不同,常规操作会在操作完成时转储终端输出,而用户可以请求流式传输测试的输出,以了解长时间运行的测试的进度。这由 --test_output=streamed 命令行选项指定,这意味着独占测试执行,这样不同测试的输出就不会分散。

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

通过观察各种事件(例如 TestAttemptTestResultTestingCompleteEvent),可在事件总线上获取已执行的测试的结果。它们会被转储到 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++),另一些语言会进行在线插桩,这意味着覆盖率插桩是在执行时添加。

另一个核心概念是基准覆盖率。这是库、二进制文件或测试(如果未运行任何代码)的覆盖率。它解决的问题是,如果您想计算二进制文件的测试覆盖率,合并所有测试的覆盖率是不够的,因为二进制文件中可能存在未关联到任何测试的代码。因此,我们要为每个二进制文件发出一个覆盖率文件,该文件仅包含我们收集的覆盖率文件,而没有被覆盖的行。目标的基准覆盖率文件位于 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 的子类来完成其他查询函数。为了支持流式查询结果,系统会向 QueryFunction 传递 query2.engine.Callback,而不是将它们收集到某个数据结构中,后者会调用该方法以获取想要返回的结果。

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

对某些查询输出格式(绝对是 proto)的微妙要求是,Bazel 需要发出软件包加载提供的信息,以便能够区分输出并确定特定目标是否已更改。因此,属性值需要可序列化,这也是为什么只有少数属性类型没有具有复杂 Starlark 值的属性的原因。通常的解决方法是使用标签,并将复杂信息附加到具有该标签的规则。这不是一个非常令人满意的解决方法,最好取消此要求。

模块系统

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

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

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

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

事件总线

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

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

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

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

外部代码库

虽然 Bazel 最初设计为在 monorepo(包含构建所需的一切的单个源代码树)中使用,但 Bazel 所处的环境不一定是正确的。“外部代码库”是用于衔接这两个世界的抽象概念:它们表示构建所需的代码,但不在主源代码树中。

WORKSPACE 文件

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

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

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

为了处理这种情况,将 WORKSPACE 文件(位于 WorkspaceFileFunction 中)的解析拆分为由 load() 语句描述的块。分块索引由 WorkspaceFileKey.getIndex() 指示,并且一直计算 WorkspaceFileFunction 到索引 X 意味着对它进行求值,直到第 X 个 load() 语句为止。

提取代码库

代码库的代码必须先fetched,然后才能提供给 Bazel。这会使 Bazel 在 $OUTPUT_BASE/external/<repository name> 下创建一个目录。

提取代码库的步骤如下:

  1. PackageLookupFunction 意识到它需要一个代码库,并创建了一个 RepositoryName 作为 SkyKey,后者会调用 RepositoryLoaderFunction
  2. RepositoryLoaderFunction 因不明确的原因将请求转发给 RepositoryDelegatorFunction(代码指出,这样做是为了避免在 Skyframe 重启时重新下载内容,但这并不是一个非常可靠的推理)
  3. RepositoryDelegatorFunction 通过遍历 WORKSPACE 文件块,直到找到请求的代码库,确定要求它提取的代码库规则
  4. 找到相应的 RepositoryFunction 来实现代码库提取;它可能是代码库的 Starlark 实现,也可能是采用 Java 实现的代码库的硬编码映射。

由于提取代码库的成本可能非常高昂,因此存在多个不同的缓存层:

  1. 已下载文件有一个缓存,由其校验和 (RepositoryCache) 进行键控。这要求 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

代码库映射

有时,多个代码库可能希望依赖于位于不同版本的同一代码库(这是“菱形依赖项问题”的一个实例)。例如,如果 build 中位于不同仓库中的两个二进制文件想要依赖于 Guava,系统会假定它们都引用了标签以 @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 类包括:

  • 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.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 包含一个性能分析器,可用于分析 build 和 Bazel 本身。它在名为 Profiler 的类中实现。它默认处于启用状态,不过它仅记录删节的数据,因此其开销在可接受的范围内;命令行 --record_full_profiler_data 会记录能够记录的所有内容。

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

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

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

测试 Bazel

Bazel 主要分为两种测试:一种将 Bazel 观察为“黑盒子”,另一种将仅运行分析阶段。我们将前一种称为“集成测试”,后一种称为“单元测试”,但它们更像是集成程度较低的集成测试。我们还有一些必要的实际单元测试。

集成测试分为两种:

  1. 使用非常复杂的 Bash 测试框架在 src/test/shell 下实现的测试
  2. 使用 Java 实现的 API。这些方法以 BuildIntegrationTestCase 的子类的形式实现。

BuildIntegrationTestCase 是首选的集成测试框架,因为它可以胜任大多数测试场景。由于它是一个 Java 框架,因此具有可调试性和与许多常见开发工具的无缝集成。Bazel 代码库中有许多 BuildIntegrationTestCase 类的示例。

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