基于工件的构建系统

本页将介绍基于工件的构建系统及其背后的理念。Bazel 是一种基于工件的构建系统。虽然基于任务的构建系统比构建脚本要好一步,但它们允许各个工程师定义自己的任务,赋予他们过多的能力。

基于工件的构建系统包含少量由系统定义的任务,工程师可以有限地配置这些任务。工程师仍然会告知系统要构建的内容,但构建系统决定如何构建。与基于任务的构建系统一样,基于工件的构建系统(如 Bazel)仍有构建文件,但这些构建文件的内容截然不同。Bazel 中的 buildfile 不是采用图灵完成脚本语言中的一组命令式命令来描述如何生成输出,它是一个声明性清单,描述了一组要构建的工件、其依赖项以及一组影响构建方式的有限选项。当工程师在命令行上运行 bazel 时,他们需要指定一组要构建的目标(“内容”),而 Bazel 负责配置、运行和安排编译步骤(具体方式)。由于构建系统现在可以完全控制何时运行哪些工具,因此它可以提供更强的保证,使其效率更高,同时仍保证正确性。

功能性视角

用基于工件的构建系统与功能性编程进行类比很简单。传统的命令式编程语言(如 Java、C 和 Python)指定要逐个执行的语句列表,就像基于任务的构建系统允许程序员定义一系列要执行的步骤一样。相比之下,函数编程语言(例如 Haskell 和 ML)的结构更类似于一系列数学方程。在函数语言中,程序员会描述要执行的计算,但会向编译器留下有关该计算执行时间和具体方式的详细信息。

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

了解基于工件的构建系统

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

在 Bazel 中,buildfile(通常命名为 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 步出现在第 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 了解它在每个步骤中运行的工具的属性,因此每次只能重新构建最小的工件集,同时保证它不会生成过时的构建。

根据工件而不是任务重新构建构建流程虽然微妙但十分强大。通过降低提供给程序员的灵活性,构建系统可以更详细地了解每个构建步骤中要执行的操作。它可以利用这些信息,通过并行处理构建流程并重复使用其输出来大幅提高构建效率。但这实际上只是第一步,这些并行和重复使用的构建块构成了高度可扩缩的分布式构建系统的基础。

其他实用的 Bazel 技巧

基于工件的构建系统从根本上解决了基于任务的构建系统中固有的并行处理和重用问题。不过,还有一些我们之前没有解决的问题。Bazel 能够巧妙地解决以上各种问题,我们应该先讨论一下,然后再继续。

将工具作为依赖项

我们之前遇到的一个问题是,build 依赖于我们计算机上安装的工具,并且由于工具版本或位置不同,跨系统重现 build 可能会比较困难。如果您的项目使用的语言会根据构建或编译的平台(例如 Windows 与 Linux)而需要使用不同的工具,而每个平台需要一组略有不同的工具来完成同一工作,那么这个问题就变得更加困难。

为了解决这一问题的第一部分,Bazel 将工具视为每个目标的依赖项。工作区中的每个 java_library 都会隐式依赖于一个 Java 编译器,该编译器默认是一个知名编译器。每当 Bazel 构建 java_library 时,都会进行检查以确保指定的编译器在已知位置可用。就像任何其他依赖项一样,如果 Java 编译器发生变化,那么依赖于它的每一个工件都会重新构建。

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

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

扩展构建系统

Bazel 自带适用于多种常用编程语言的目标,但工程师始终需要做更多工作 - 基于任务的系统的一部分优势在于,可以灵活地支持任何类型的构建流程,在基于工件的构建系统中,最好不要放弃这一点。幸运的是,Bazel 允许通过添加自定义规则来扩展其支持的目标类型。

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

鉴于没有任何方法可以阻止动作开发者在 Action 中引入不确定性流程之类的行为,系统并非万无一失。但这种情况在实践中并不常见,而且将滥用行为的可能性降低到操作级别会大大减少出错的机会。支持许多常用语言和工具的规则可在线广泛使用,大多数项目从不需要定义自己的规则。即使有规则定义,也只需在代码库的集中位置定义规则定义,这意味着大多数工程师将能够使用这些规则,而无需担心其实现问题。

隔离环境

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

使外部依赖项具有确定性

还有一个问题:构建系统通常需要从外部来源下载依赖项(无论是工具还是库),而不是直接构建它们。通过从 Maven 下载 JAR 文件的 @com_google_common_guava_guava//jar 依赖项,可以看出这种情况。

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

基本问题是,我们希望构建系统能够识别这些文件,而无需将其签入源代码控制系统。更新依赖项是一种需要注意的选择,但应该在一个中心位置做出一次选择,而不应由各个工程师管理或由系统自动管理。这是因为,即使具有“在头脑中实时运行”模型,我们仍然希望构建具有确定性,这意味着,如果您查看上周的提交,应该会看到依赖项的原样,而不是现在。

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

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

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