基于工件的构建系统

本页面介绍了基于工件的构建系统及其构建理念。Bazel 是基于工件的构建系统。虽然基于任务的构建系统比构建脚本更具优势,但它们通过让各个工程师定义自己的任务,能够为他们提供太多功能。

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

功能性观点

您可以轻松地在基于工件的构建系统与功能编程之间进行类推。传统的命令式编程语言(例如 Java、C 和 Python)会指定要连续执行的语句列表,就像基于任务的构建系统让程序员定义一系列步骤一样{ 101}。相比之下,函数式编程语言(例如 Haskell 和 ML)的结构更类似于一系列数学方程式。在功能语言中,程序员描述要执行的计算,但会将执行该计算的时间和方式的详细信息交由编译器处理。

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

了解基于工件的构建系统

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 知道它每一步运行的工具的属性,因此它每次只能重新构建最小的工件集,同时保证不会生成过时的构建。

根据工件而不是任务来描述构建流程的细节是微妙但强大的。通过降低面向程序员的灵活性,构建系统可以详细了解构建过程中每个步骤所执行的操作。它可以利用这些知识,通过并行处理构建流程并重复使用其输出来提高构建效率。但这其实只是第一步,这些并行和重用的构建块为分布式且高度可扩缩的构建系统 the 定了基础。

其他出色的 Bazel 技巧

基于工件的构建系统从根本上解决了基于任务的构建系统中存在的并行性和重用问题。但是,我们之前提出的问题中仍存在一些尚未解决的问题。Bazel 可以灵活地逐个解决这些问题,我们应先讨论它们,然后再继续。

作为依赖项的工具

我们之前遇到的一个问题是,构建依赖于我们计算机上安装的工具,并且由于工具版本或位置不同而难以跨系统重现构建。如果您的项目所使用的语言需要不同的工具(例如,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依赖项,用于下载JAR文件。

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

根本问题在于,我们希望构建系统能够感知这些文件,而不必将其签入源代码控制系统。更新依赖项应有意识地进行选择,但该选择应是在中央位置进行一次,而不是由各个工程师或由系统自动管理。这是因为,即便是采用“实时头部”模型,我们仍然希望构建具有确定性,这意味着,如果您签出上周的提交,应该会看到当时的依赖项与之前相比

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

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

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