基于工件的构建系统

报告问题 查看源代码 每夜 build · 7.3 · 7.2 · 7.1 · 7.0 · 6.5

本页介绍了基于工件的构建系统及其背后的理念。Bazel 是一个基于工件的构建系统。虽然基于任务的构建系统比构建脚本更高级,但它们允许工程师自行定义任务,从而赋予了工程师过多权力。

基于工件的构建系统由系统定义了少量任务,工程师只能以有限的方式对其进行配置。工程师仍然会告诉系统 要构建的内容,但构建系统会决定如何构建。与 基于任务的构建系统、基于工件的构建系统(如 Bazel) 但它们的内容截然不同Bazel 中的 buildfile 不是用图灵完备的脚本语言描述如何生成输出的命令集,而是描述要构建的一组工件、它们的依赖项以及一组会影响构建方式的选项的声明式清单。当工程师在命令行上运行 bazel 时,他们会指定要构建的一组目标(what),而 Bazel 负责配置、运行和调度编译步骤(how)。由于构建系统现在可以完全控制何时运行哪些工具,因此它可以提供更强有力的保证,从而实现更高的效率,同时保证正确性。

功能视角

基于工件的构建系统与函数式编程之间很容易进行类比。传统的命令式编程语言(例如 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 步中构建的依赖项。

从根本上说,这里发生的情况 与使用基于任务的构建系统时的情况不同。事实上, 最终结果是相同的二进制文件, 分析多个步骤以找出它们之间的依赖关系,然后运行 执行这些步骤但两者之间有很大的区别。显示第一条 第三步:由于 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 解决了第二部分问题,即平台独立性。 build 配置。目标平台取决于配置类型,而不是直接取决于其工具:

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

扩展构建系统

Bazel 附带了多种常用编程语言的目标, 但工程师始终希望实现更多目标 - 基于任务的部分优势在于 系统可以灵活支持任何类型的构建流程, 在基于工件的构建系统中最好不要放弃。 幸运的是,Bazel 支持通过 添加自定义规则

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

由于无法阻止操作开发者执行某些操作(例如在操作中引入非确定性进程),因此该系统并非万无一失。但在实践中,这种情况并不常见,将滥用行为的可能性一直推到操作级别,可以大大降低出错的可能性。规则支持许多常用语言和工具 可广泛在线提供,大多数项目永远无需定义自己的 规则。即便是那些拥有该角色的查询,也只需在 位于代码库中,这意味着大多数工程师都可以使用 而无需操心实施方式

隔离环境

操作似乎可能会遇到与其他系统中的任务相同的问题 - 写入同一文件且最终相互冲突的操作难道还能编写吗?实际上,Bazel 通过使用沙盒功能,可以避免这些冲突。在受支持的系统上,每个操作都通过文件系统沙盒与其他所有操作隔离。实际上,每项操作都只能看到 包含其声明的输入及其包含的任何输出的文件系统 生成的内容。Linux 上的 LXC 等系统强制要求采用相同的技术, 使用 Docker。也就是说,任何操作都不可能 另一个原因是,它们无法读取未声明的任何文件, 如果执行操作, 结束。Bazel 还使用沙盒来限制操作通过 网络。

使外部依赖项具有确定性

但仍有 1 个问题:构建系统通常需要从外部来源下载依赖项(无论是工具还是库),而不是直接构建它们。通过 @com_google_common_guava_guava//jar 依赖项,用于下载 JAR 文件 。

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

根本问题在于,我们希望构建系统能够 而无需将其签入源代码控制系统更新依赖项 应是自觉地做出选择,但应在集中精力 而不是由单独的工程师管理,或由 系统。这是因为,即使采用“Live at Head”模型,我们仍然希望 具有确定性,这意味着如果您签出上一个提交 您应该会按当时的状态(而非现在)查看依赖项 。

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

如果我们下载的工件与清单中声明的工件哈希不同,除非更新清单中的哈希,否则 build 将失败。这个 更改可以自动完成,但相应更改必须获得批准并签入 源代码控制将接受新的依赖项。这意味着,系统始终会记录依赖项的更新时间,并且外部依赖项无法更改,除非工作区源代码发生相应更改。这也意味着,在签出较旧版本的源代码时, build 使用的依赖项与它当时使用的依赖项相同 更新版本(否则,如果这些依赖项 )。

当然,如果远程服务器不可用或 开始提供损坏的数据,这可能会导致您的所有构建开始失败 如果您没有该依赖项的其他副本,则会创建该依赖项。为避免此问题,我们建议您针对任何重要的项目,将其所有依赖项镜像到您信任且控制的服务器或服务。否则 对于您的构建系统 可用性,即使签入的哈希保证其安全性也是如此。