本页介绍了基于工件的构建系统及其背后的理念。Bazel 是一个基于工件的构建系统。虽然基于任务的构建系统比构建脚本更高级,但它们会让工程师自行定义任务,从而赋予他们过多权力。
基于工件的构建系统由系统定义了少量任务,工程师只能以有限的方式对其进行配置。工程师仍然需要告诉系统要构建什么,但构建系统会决定如何构建。与基于任务的构建系统一样,基于工件的构建系统(例如 Bazel)仍然具有 buildfile,但这些 buildfile 的内容非常不同。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_binary
和 java_library
。每个目标都对应于系统可以创建的工件:二进制目标会生成可直接执行的二进制文件,而库目标会生成可供二进制文件或其他库使用的库。每个目标都有:
name
:命令行和其他目标如何引用目标srcs
:要编译以为目标创建工件的源文件deps
:必须先于此目标构建并链接到此目标的其他目标
依赖项可以位于同一软件包中(例如 MyBinary
对 :mylib
的依赖项),也可以位于同一源代码层次结构中的其他软件包中(例如 mylib
对 //java/com/example/common
的依赖项)。
与基于任务的构建系统一样,您可以使用 Bazel 的命令行工具执行构建。如需构建 MyBinary
目标,请运行 bazel build :MyBinary
。在干净的代码库中首次输入该命令后,Bazel 会执行以下操作:
- 解析工作区中的每个
BUILD
文件,以创建工件之间的依赖项图。 - 使用图表递归确定
MyBinary
的传递依赖项,即MyBinary
依赖的每个目标以及这些目标依赖的每个目标。 - 依次构建这些依赖项。Bazel 首先构建没有其他依赖项的每个目标,并跟踪每个目标仍需要构建哪些依赖项。一旦某个目标的所有依赖项都构建完毕,Bazel 就会开始构建该目标。此过程会一直持续,直到
MyBinary
的所有传递依赖项都已构建完毕。 - 构建
MyBinary
以生成最终的可执行二进制文件,该文件会关联第 3 步中构建的所有依赖项。
从根本上讲,这里发生的情况可能看起来与使用基于任务的构建系统时发生的情况没有太大不同。事实上,最终结果是相同的二进制文件,而生成该文件的过程涉及分析一系列步骤以查找它们之间的依赖项,然后按顺序运行这些步骤。但二者之间存在一些关键差异。第 1 个出现在第 3 步中:由于 Bazel 知道每个目标仅生成一个 Java 库,因此它知道自己只需运行 Java 编译器(而不是任意用户定义的脚本),因此它知道可以安全地并行运行这些步骤。与在多核机器上逐个构建目标相比,这种方法可以将性能提升一个数量级。之所以能够实现这一点,是因为基于工件的构建方法让构建系统负责自己的执行策略,从而能够对并行性做出更强的保证。
不过,其优势不仅仅在于并行处理。当开发者在不进行任何更改的情况下第二次输入 bazel
build :MyBinary
时,我们会发现这种方法带来的另一个好处:Bazel 会在不到一秒的时间内退出,并显示一条消息,说明目标已是最新的。之所以能够做到这一点,是因为我们之前提到的函数式编程范式。Bazel 知道每个目标仅仅是运行 Java 编译器的结果,并且知道 Java 编译器的输出仅取决于其输入,因此只要输入没有更改,就可以重复使用输出。这种分析适用于每个级别;如果 MyBinary.java
发生变化,Bazel 会知道重新构建 MyBinary
,但会重复使用 mylib
。如果 //java/com/example/common
的源文件发生更改,Bazel 会知道要重新构建该库、mylib
和 MyBinary
,但会重复使用 //java/com/example/myproduct/otherlib
。由于 Bazel 知道自己在每个步骤运行的工具的属性,因此每次只能重新构建一组最少的工件,同时保证不会生成过时 build。
从工件(而非任务)的角度重新构建构建流程,这是一个微妙但强大的做法。通过减少向程序员提供的灵活性,构建系统可以更详细地了解构建的每个步骤中正在执行的工作。它可以利用这些知识,通过并行构建进程并重复使用其输出,使构建效率大幅提升。但这实际上只是第一步,这些并行和重用构建块构成了分布式且高度可伸缩的构建系统的基础。
其他实用的 Bazel 技巧
基于工件的构建系统从根本上解决了基于任务的构建系统固有的并行性和重用问题。不过,我们之前提到的一些问题仍未解决。Bazel 提供了巧妙的方法来解决这些问题,我们应该先讨论这些方法,然后再继续。
将工具用作依赖项
我们之前遇到的一个问题是,build 取决于计算机上安装的工具,并且由于工具版本或位置不同,在不同系统上重现 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 可能在一天正常运行,但在第二天却无明显原因地失败。最后,如果外部依赖项归第三方所有,则可能会带来巨大的安全风险:如果攻击者能够渗透到该第三方服务器,则可以将依赖项文件替换为他们自己设计的内容,从而可能完全控制您的构建环境及其输出。
根本问题在于,我们希望构建系统能够知道这些文件,而无需将它们签入源代码控制系统。更新依赖项应是一个有意识的选择,但该选择应在一个集中位置进行一次,而不是由各个工程师管理或由系统自动管理。这是因为,即使采用“Live at Head”模型,我们仍然希望 build 是确定性的,这意味着,如果您检出上周的提交内容,应该看到当时的依赖项,而不是现在的依赖项。
Bazel 和一些其他构建系统通过要求使用一个工作区级清单文件来解决此问题,该清单文件会列出工作区中每个外部依赖项的加密哈希。哈希是一种简洁的方式,可用于唯一地表示文件,而无需将整个文件签入源代码控制系统。每当从工作区引用新的外部依赖项时,该依赖项的哈希都会手动或自动添加到清单中。当 Bazel 运行 build 时,会将其缓存的依赖项的实际哈希值与清单中定义的预期哈希值进行比较,并且仅在哈希值不同时才会重新下载文件。
如果我们下载的工件与清单中声明的工件哈希不同,除非更新清单中的哈希,否则 build 将失败。这可以自动完成,但该更改必须先获得批准并签入源代码控制系统,然后 build 才会接受新依赖项。这意味着,系统始终会记录依赖项的更新时间,并且外部依赖项无法更改,除非工作区源代码发生相应更改。这也意味着,在检出较低版本的源代码时,构建保证会使用在该版本被检入时所用的相同依赖项(否则,如果这些依赖项不再可用,构建将会失败)。
当然,如果远程服务器不可用或开始提供损坏的数据,仍然会出现问题。如果您没有该依赖项的其他副本,这可能会导致您的所有 build 都开始失败。为避免此问题,我们建议您针对任何重要的项目,将其所有依赖项镜像到您信任且控制的服务器或服务。否则,即使已提交的哈希值可保证其安全性,您也始终需要依赖第三方来确保构建系统的可用性。