基于工件的构建系统

报告问题 查看源代码

本页将介绍基于工件的构建系统以及其创建背后的理念。Bazel 是一个基于工件的构建系统。虽然基于任务的构建系统是构建脚本之上的一大步,但它们让各个工程师能够定义自己的任务,从而为其赋予过多的权力。

基于工件的构建系统具有少量由系统定义的任务,工程师可以通过有限方式配置这些任务。工程师仍会告知系统要构建的内容,但构建系统会决定如何构建。与基于任务的构建系统一样,基于工件的构建系统(例如 Bazel)仍具有构建文件,但这些构建文件的内容大不相同。Bazel 中的 buildfile 不是包含 Turing 完整脚本语言的一组命令式命令文件,而是用来描述一组要构建的工件及其依赖项,以及一组影响其构建方式的有限选项。工程师在命令行上运行 bazel 时,需要指定一组要构建的目标(目标),Bazel 负责配置、运行和安排编译步骤(方式)。由于构建系统现在对何时运行哪些工具具有完全控制权,因此它可以做出更强的保证,可以显著提高效率,同时保证正确性。

功能视角

您可以轻松地基于工件的构建系统与功能编程进行类比。传统的命令式编程语言(例如 Java、C 和 Python)会逐个指定要执行的语句列表,其方式与基于任务的构建系统允许程序员定义一系列要执行的步骤相同。相比之下,函数编程语言(例如 Haskell 和 ML)的结构更像是一系列数学方程式。在功能语言中,程序员描述要执行的计算,但会将计算的执行时间和方式的详细信息留给编译器。

这对应于在基于工件的构建系统中声明清单并让系统确定如何执行构建这一想法。使用功能编程无法轻松表达许多问题,但能够从中受益很大的问题:语言通常能够平行地对此类程序进行并行处理,并强行保证它们在命令式语言中无法实现。使用函数式编程表达的最简单的问题是,使用一系列规则或函数简单地将一段数据转换为另一块数据的问题。这正好是构建系统:整个系统实际上是一个数学函数,它将源文件(和编译器等工具)作为输入,并生成二进制文件作为输出。因此,基于功能编程的原则构建构建系统的效果也就不足为奇了。

了解基于工件的构建系统

Google 的构建系统 Blaze 是第一个基于工件的构建系统。Bazel 是 Blaze 的开源版本

build 文件(通常名为 BUILD)在 Bazel 中的如下所示:

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。

从工件而不是任务重新构建构建过程既微妙又强大。通过降低向程序员提供的灵活性,构建系统可以详细了解构建过程中每个步骤执行的操作。它可以运用这些信息,通过并行构建流程和重复使用其输出来提高构建效率。但这只是第一步,这些并行处理和重用构建构成了分布式且高度可扩缩的构建系统的基础。

其他巧妙的 Bazel 技巧

基于工件的构建系统从根本上解决了基于任务的构建系统固有的并行处理和重用问题。但是,我们之前仍存在一些我们尚未解决的问题。Bazel 可以巧妙地解决这些问题,在进行后续操作之前,我们应该先讨论这些问题。

将工具作为依赖项

我们之前遇到的一个问题是,构建依赖于我们机器上安装的工具,由于不同的工具版本或位置,跨系统重现 build 可能很困难。如果您的项目使用的语言需要基于构建或编译的目标平台(例如,Windows 与 Linux)而需要不同的工具,并且其中每个平台需要一组略有不同的工具来执行相同的任务,那么问题变得更加困难。

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

Bazel 通过设置构建配置解决了此问题的第二部分,即平台独立性。他们不会直接依赖于工具的目标,而是依赖于配置的类型:

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

扩展构建系统

Bazel 默认提供针对多种常用编程语言的目标,但工程师始终希望实现更多目标 - 基于任务的系统的优势之一是它们支持任何类型的构建流程,最好不要在基于工件的构建系统中放弃该系统。幸运的是,Bazel 允许通过添加自定义规则来扩展其支持的目标类型。

为了在 Bazel 中定义规则,规则作者声明了规则需要的输入(以 BUILD 文件中传递的属性的形式)和规则生成的固定输出集。作者还定义了该规则将生成的操作。每个操作会声明其输入和输出、运行特定可执行文件或将特定字符串写入文件,并且可以通过其输入和输出连接到其他操作。这意味着,操作是构建系统中最低级别的可组合项单元 - 操作可以随意执行,只要它只使用所声明的输入和输出,Bazel 会负责安排操作并视需要缓存结果。

系统无法万无一失,因为无法阻止操作开发者执行在操作过程中引入非确定性进程等操作。但在实践中,这种情况并不经常发生,将滥用的可能性一直降低至操作级别会大大降低出错的可能性。支持许多常见语言和工具的规则在网上广泛可用,并且大多数项目从不需要定义自己的规则。即使是这样,规则定义也只需要在存储库中的一个集中位置进行定义,这意味着大多数工程师都可以使用这些规则,而无需担心实现问题。

隔离环境

操作听起来可能与其他系统中的任务相同,难道不是依然可以编写同时写入同一文件并最终相互冲突的操作吗?实际上,Bazel 使用沙盒可以避免这些冲突。在受支持的系统上,每个操作都通过文件系统沙盒与其他所有操作隔离开来。实际上,每项操作只能看到文件系统的受限视图,其中包括它声明的输入及其生成的所有输出。这一机制由 Linux 中的 LXC 等系统强制执行,这也是 Docker 背后所采用的技术。这意味着操作不可能相互冲突,因为它们无法读取它们未声明的任何文件,并且它们已写入但未声明的任何文件都将在操作完成时被舍弃。Bazel 还使用沙盒来限制操作通过网络进行通信。

使外部依赖项具有确定性

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

根据当前工作区以外的文件存在风险。这些文件可以随时更改,可能需要构建系统持续检查文件是否最新。如果某个远程文件在工作区源代码中没有相应的更改的情况下发生更改,则也可能会导致无法重现的 build - build 可能某天能正常运行,但由于没有明确原因所引起的依赖项变更而失败。最后,当外部拥有归第三方所有时,外部依赖项可能会带来巨大的安全风险:如果攻击者能够入侵该第三方服务器,他们可以用自己的设计替换依赖项文件,从而可能完全控制您的构建环境及其输出。

根本问题是,我们希望构建系统了解这些文件,而不将这些文件签入源代码控制系统。更新依赖项应该是一个明智的选择,但该选择应该在中央位置进行一次,而不是由各个工程师管理或由系统自动管理。这是因为即使采用“实时模式”模型,我们仍然希望构建具有确定性,这意味着,如果您查看上周的提交,应该会看到依赖项的状态,而不是它们现在的状态。

Bazel 和一些其他构建系统通过以下方法解决这个问题:需要一个工作区清单文件,其中要列出工作区中的每个外部依赖项的加密哈希。哈希是一种简洁的方式来唯一标识文件,而无需将整个文件签入源代码控件。每当从工作区引用新的外部依赖项时,可通过手动或自动方式将该依赖项的哈希添加到清单中。当 Bazel 运行构建时,它会对照清单中定义的预期哈希值检查其缓存依赖项的实际哈希值,并仅在哈希值不同时重新下载文件。

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

当然,如果远程服务器不可用或开始提供损坏的数据,则可能仍然是一个问题 - 如果您没有可用的该依赖项的其他副本,则可能会导致所有构建开始失败。为避免出现此问题,对于任何重要项目,您都需要将它的所有依赖项镜像到您信任和控制的服务器或服务。否则,即使签入的哈希保证了构建的安全性,对于第三方的可用性来说,您还是始终受第三方的制约。