基于工件的构建系统

报告问题 查看源代码 每夜版 · 8.4 · 8.3 · 8.2 · 8.1 · 8.0 · 7.6

本页介绍了基于制品的 build 系统及其创建理念。Bazel 是一个基于制品的构建系统。虽然基于任务的构建系统比构建脚本好得多,但它们让各个工程师能够定义自己的任务,从而赋予了他们过大的权力。

基于制品构建的系统具有少量由系统定义的任务,工程师可以以有限的方式配置这些任务。工程师仍然会告知系统要构建什么,但构建系统会确定如何构建。与基于任务的构建系统一样,基于制品的构建系统(例如 Bazel)仍然具有 buildfile,但这些 buildfile 的内容却大相径庭。Bazel 中的 buildfile 并不是一组图灵完备的脚本语言中的命令,用于描述如何生成输出,而是一个声明性清单,用于描述要构建的一组制品、它们的依赖项以及一组会影响构建方式的有限选项。当工程师在命令行中运行 bazel 时,他们会指定一组要构建的目标(即什么),而 Bazel 负责配置、运行和调度编译步骤(即如何)。由于构建系统现在可以完全控制何时运行哪些工具,因此它可以做出更强的保证,从而在保证正确性的同时大幅提高效率。

功能视角

基于制品构建的系统与函数式编程之间很容易进行类比。传统的命令式编程语言(例如 Java、C 和 Python)指定要依次执行的语句列表,就像基于任务的构建系统允许程序员定义要执行的一系列步骤一样。相比之下,函数式编程语言(例如 Haskell 和 ML)的结构更像是一系列数学方程式。在函数式语言中,程序员描述要执行的计算,但将何时以及如何精确执行该计算的细节留给编译器。

这相当于在基于制品的构建系统中声明清单,并让系统确定如何执行构建。许多问题无法使用函数式编程轻松表达,但能够使用函数式编程轻松表达的问题会从中受益匪浅:语言通常能够轻松地并行化此类程序,并对其正确性做出强有力的保证,而这在命令式语言中是不可能实现的。使用函数式编程最容易表达的问题是那些仅涉及使用一系列规则或函数将一块数据转换为另一块数据的问题。而这正是构建系统的本质:整个系统实际上是一个数学函数,它以源文件(以及编译器等工具)作为输入,并生成二进制文件作为输出。因此,基于函数式编程的原则构建构建系统效果良好也就不足为奇了。

了解基于制品构建的系统

Google 的构建系统 Blaze 是首个基于制品的构建系统。Bazel 是 Blaze 的开源版本。

以下是 Bazel 中的 build 文件(通常命名为 BUILD)的示例:

java_binary(
    name = "MyBinary",
    srcs = ["MyBinary.java"],
    deps = [
        ":mylib",
    ],
)
java_library(
    name = "mylib",
    srcs = ["MyLibrary.java", "MyHelper.java"],
    visibility = ["//java/com/example/myproduct:__subpackages__"],
    deps = [
        "//java/com/example/common",
        "//java/com/example/myproduct/otherlib",
    ],
)

在 Bazel 中,BUILD 文件定义了目标,这里有两种类型的目标:java_binaryjava_library。每个目标都对应于系统可以创建的制品:二进制目标会生成可以直接执行的二进制文件,而库目标会生成可供二进制文件或其他库使用的库。每个目标都有:

  • name:目标在命令行上以及被其他目标引用时的方式
  • srcs:要编译以创建目标工件的源文件
  • deps:必须在相应目标之前构建并链接到该目标的其他目标

依赖项可以位于同一软件包中(例如 MyBinary:mylib 的依赖),也可以位于同一源代码层次结构中的不同软件包中(例如 mylib//java/com/example/common 的依赖)。

与基于任务的构建系统一样,您可以使用 Bazel 的命令行工具执行构建。如需构建 MyBinary 目标,请运行 bazel build :MyBinary。在干净的代码库中首次输入该命令后,Bazel 会执行以下操作:

  1. 解析工作区中的每个 BUILD 文件,以创建制品之间的依赖关系图。
  2. 使用图表确定 MyBinary 的传递依赖项;也就是说,MyBinary 所依赖的每个目标以及这些目标所依赖的每个目标(以递归方式)。
  3. 按顺序构建每个依赖项。Bazel 首先构建没有其他依赖项的每个目标,并跟踪每个目标仍需要构建哪些依赖项。一旦某个目标的所有依赖项都构建完毕,Bazel 就会开始构建该目标。此过程会一直持续,直到 MyBinary 的每个传递依赖项都已构建完毕。
  4. 构建 MyBinary 以生成最终可执行二进制文件,该文件会链接在第 3 步中构建的所有依赖项。

从根本上讲,这里发生的事情似乎与使用基于任务的构建系统时发生的事情没有太大区别。事实上,最终结果是相同的二进制文件,而生成该二进制文件的过程涉及分析一系列步骤以找到它们之间的依赖关系,然后按顺序运行这些步骤。但两者之间存在重大差异。第一个出现在第 3 步中:由于 Bazel 知道每个目标只会生成一个 Java 库,因此它知道自己只需运行 Java 编译器,而无需运行任意用户定义的脚本,因此它知道可以安全地并行运行这些步骤。与在多核机器上一次构建一个目标相比,这种方法可将性能提升一个数量级,而这只有在基于制品的构建方法下才能实现,因为这种方法让构建系统负责自身的执行策略,从而可以更强有力地保证并行性。

不过,其优势远不止于并行处理。当开发者第二次输入 bazel build :MyBinary 而不进行任何更改时,此方法的下一个优势就会显现出来:Bazel 会在不到一秒的时间内退出,并显示一条消息,指出目标是最新的。这得益于我们之前讨论过的函数式编程范式 - Bazel 知道每个目标都只是运行 Java 编译器的结果,并且知道 Java 编译器的输出仅取决于其输入,因此只要输入没有变化,输出就可以重复使用。此分析适用于各个级别;如果 MyBinary.java 发生变化,Bazel 会知道要重新构建 MyBinary,但会重用 mylib。如果 //java/com/example/common 的某个源文件发生更改,Bazel 会知道要重新构建相应库,即 mylibMyBinary,但会重用 //java/com/example/myproduct/otherlib。由于 Bazel 了解其在每个步骤中运行的工具的属性,因此每次都能够仅重建最少的制品集,同时保证不会生成过时的 build。

从制品而非任务的角度重新构建构建流程,这种做法看似细微,实则非常强大。通过减少向程序员公开的灵活性,构建系统可以更清楚地了解构建的每个步骤中正在执行的操作。它可以利用这些知识并行处理 build 流程并重用其输出,从而大幅提高 build 效率。但这只是第一步,这些并行和重用的构建块构成了分布式且高度可扩缩的构建系统的基础。

其他实用的 Bazel 技巧

基于制品构建的系统从根本上解决了基于任务构建的系统固有的并行性和重用性问题。不过,之前出现的一些问题尚未解决。Bazel 有巧妙的方法来解决上述每个问题,我们应该在继续讨论之前先讨论这些方法。

作为依赖项的工具

我们之前遇到的一个问题是,build 依赖于我们机器上安装的工具,由于工具版本或位置不同,跨系统重现 build 可能会很困难。当您的项目使用的语言需要不同的工具(具体取决于它们所构建或编译的平台,例如 Windows 与 Linux)时,问题会变得更加复杂,因为每个平台都需要略有不同的工具集才能完成相同的工作。

Bazel 通过将工具视为每个目标的依赖项来解决此问题的第一部分。工作区中的每个 java_library 都隐式依赖于 Java 编译器,该编译器默认为一个知名的编译器。每当 Bazel 构建 java_library 时,它都会检查以确保指定的编译器位于已知位置。与其他任何依赖项一样,如果 Java 编译器发生更改,则依赖于它的每个工件都会重新构建。

Bazel 通过设置build 配置来解决问题的第二部分(平台独立性)。目标不再直接依赖于其工具,而是依赖于配置类型:

  • 主机配置:在 build 期间运行的 build 工具
  • 目标配置:构建您最终请求的二进制文件

扩展 build 系统

Bazel 开箱即用,可为多种热门编程语言提供目标,但工程师总是希望做得更多。基于任务的系统的一大优势在于,它们可以灵活地支持任何类型的构建流程,最好不要在基于制品的构建系统中放弃这一优势。幸运的是,Bazel 允许通过添加自定义规则来扩展其支持的目标类型。

如需在 Bazel 中定义规则,规则作者需要声明规则所需的输入(以 BUILD 文件中传递的属性的形式)以及规则生成的固定输出集。作者还可以定义该规则将生成的操作。每个操作都会声明其输入和输出,运行特定的可执行文件或将特定字符串写入文件,并且可以通过其输入和输出与其他操作相关联。这意味着,操作是 build 系统中最低级别的可组合单元 - 操作可以执行任何操作,只要它仅使用其声明的输入和输出即可,而 Bazel 会负责安排操作并根据需要缓存其结果。

由于无法阻止操作开发者执行诸如在其操作中引入不确定性流程之类的操作,因此该系统并非万无一失。但在实践中,这种情况并不常见,而且将滥用可能性降至操作级别可大大减少出错机会。网上有许多支持常见语言和工具的规则,大多数项目永远不需要定义自己的规则。即使对于那些需要定义规则的工程师,也只需在代码库中的一个中心位置定义规则,这意味着大多数工程师都可以使用这些规则,而无需担心它们的实现。

隔离环境

操作似乎可能会遇到与其他系统中的任务相同的问题 - 难道不还是有可能编写出既写入同一文件又最终相互冲突的操作吗?实际上,Bazel 通过使用沙盒来避免这些冲突。在支持的系统上,每个操作都通过文件系统沙盒与其他操作隔离。实际上,每个操作只能看到文件系统的受限视图,其中包含其已声明的输入和已生成的任何输出。这是由 Linux 上的 LXC 等系统强制执行的,而 LXC 也是 Docker 背后的技术。这意味着,由于操作无法读取任何未声明的文件,并且任何写入但未声明的文件在操作完成时都会被丢弃,因此操作之间不可能发生冲突。Bazel 还使用沙盒来限制通过网络进行通信的操作。

使外部依赖项具有确定性

不过,仍然存在一个问题:构建系统通常需要从外部来源下载依赖项(无论是工具还是库),而不是直接构建它们。在示例中,通过 @com_google_common_guava_guava//jar 依赖项可以看出这一点,该依赖项会从 Maven 下载 JAR 文件。

依赖于当前工作区之外的文件存在风险。这些文件可能会随时更改,因此可能需要构建系统不断检查它们是否是最新的。如果远程文件发生更改,但工作区源代码中没有相应更改,也可能会导致构建无法重现。由于依赖项更改未被注意到,构建可能在某一天正常运行,但在第二天却因不明原因而失败。最后,当外部依赖项归第三方所有时,可能会带来巨大的安全风险:如果攻击者能够渗透到该第三方服务器,他们就可以用自己设计的内容替换依赖项文件,从而可能完全控制您的 build 环境及其输出。

根本问题在于,我们希望构建系统能够识别这些文件,而无需将它们签入源代码控制系统。更新依赖项应是一种有意识的选择,但这种选择应在中心位置做出一次,而不是由各个工程师管理或由系统自动管理。这是因为即使采用“Live at Head”模型,我们仍然希望 build 是确定性的,这意味着如果您检出上周的提交,您应该看到当时的依赖项,而不是现在的依赖项。

Bazel 和一些其他构建系统通过要求提供一个工作区范围的清单文件来解决此问题,该文件列出了工作区中每个外部依赖项的加密哈希。哈希是一种简洁的方式,可用于唯一表示文件,而无需将整个文件签入源代码控制系统。每当从工作区引用新的外部依赖项时,该依赖项的哈希都会手动或自动添加到清单中。当 Bazel 运行 build 时,它会检查缓存的依赖项的实际哈希是否与清单中定义的预期哈希一致,并且仅在哈希不同时重新下载文件。

如果我们下载的制品与清单中声明的哈希不同,除非更新清单中的哈希,否则 build 将失败。此操作可以自动完成,但必须先批准该变更并将其签入源代码控制,然后 build 才会接受新的依赖项。这意味着,系统始终会记录依赖项的更新时间,并且外部依赖项无法在工作区来源中没有相应更改的情况下发生更改。 这也意味着,在检出旧版源代码时,构建保证会使用检入该版本时所用的相同依赖项(否则,如果这些依赖项不再可用,构建将会失败)。

当然,如果远程服务器变得不可用或开始提供损坏的数据,这仍然会是一个问题 - 如果您没有该依赖项的其他副本,这可能会导致您的所有 build 开始失败。为避免此问题,我们建议您将所有重要项目的依赖项镜像到您信任和控制的服务器或服务。否则,您将始终受第三方构建系统可用性的制约,即使已签入的哈希值保证了其安全性也是如此。