基于任务的构建系统

报告问题 查看源代码

本页面介绍了基于任务的构建系统、其工作原理以及基于任务的系统可能会出现的一些复杂问题。继 shell 脚本之后,基于任务的构建系统是构建的新逻辑演变。

了解基于任务的构建系统

在基于任务的构建系统中,基本的工作单元是任务。每个任务都是可以执行任何类型的逻辑的脚本,而任务将其他任务指定为必须在其之前运行的依赖项。目前使用的主要大多数构建系统(例如 Ant、Maven、Gradle、Grunt 和 Rake)都基于任务。大多数现代构建系统都要求工程师创建描述如何执行构建的 build 文件,而不是 shell 脚本。

参考 Ant 手册中的以下示例:

<project name="MyProject" default="dist" basedir=".">
   <description>
     simple example build file
   </description>
   <!-- set global properties for this build -->
   <property name="src" location="src"/>
   <property name="build" location="build"/>
   <property name="dist" location="dist"/>

   <target name="init">
     <!-- Create the time stamp -->
     <tstamp/>
     <!-- Create the build directory structure used by compile -->
     <mkdir dir="${build}"/>
   </target>
   <target name="compile" depends="init"
       description="compile the source">
     <!-- Compile the Java code from ${src} into ${build} -->
     <javac srcdir="${src}" destdir="${build}"/>
   </target>
   <target name="dist" depends="compile"
       description="generate the distribution">
     <!-- Create the distribution directory -->
     <mkdir dir="${dist}/lib"/>
     <!-- Put everything in ${build} into the MyProject-${DSTAMP}.jar file -->
     <jar jarfile="${dist}/lib/MyProject-${DSTAMP}.jar" basedir="${build}"/>
   </target>
   <target name="clean"
       description="clean up">
     <!-- Delete the ${build} and ${dist} directory trees -->
     <delete dir="${build}"/>
     <delete dir="${dist}"/>
   </target>
</project>

buildfile 采用 XML 格式编写,用于定义一些关于 build 的简单元数据以及任务列表(XML 中的 <target> 标记)。(Ant 使用词 target 来表示任务,它使用词 task 来指命令。)每个任务都会执行由 Ant 定义的一系列可能命令,包括创建和删除目录、运行 javac 以及创建 JAR 文件。用户提供的插件可以扩展这组命令,使其涵盖任何类型的逻辑。每个任务也可以通过依赖项属性定义其所依赖的任务。这些依赖关系形成一个循环图,如图 1 所示。

显示依赖项的有机图

图 1. 显示依赖关系的非循环图

用户通过向 Ant 命令行工具提供任务来执行构建。例如,当用户输入 ant dist 时,Ant 会执行以下步骤:

  1. 在当前目录中加载名为 build.xml 的文件并对其进行解析,以创建如图 1 所示的图结构。
  2. 查找在命令行中提供的名为 dist 的任务,发现它依赖于名为 compile 的任务。
  3. 查找名为 compile 的任务,发现它依赖于名为 init 的任务。
  4. 查找名为 init 的任务,发现它没有依赖项。
  5. 执行 init 任务中定义的命令。
  6. 执行 compile 任务中定义的命令(前提是该任务的所有依赖项均已运行)。
  7. 执行 dist 任务中定义的命令(前提是该任务的所有依赖项均已运行)。

最后,Ant 在运行 dist 任务时执行的代码等同于以下 shell 脚本:

./createTimestamp.sh
mkdir build/
javac src/* -d build/
mkdir -p dist/lib/
jar cf dist/lib/MyProject-$(date --iso-8601).jar build/*

当语法被去掉后,buildfile 和 build 脚本实际上并没有太大区别。但我们已经通过这样做收获了很多。我们可以在其他目录中创建新的 buildfile,并将其链接到一起。我们可以轻松地以复杂的任意方式添加依赖于现有任务的新任务。我们只需将单个任务的名称传递给 ant 命令行工具,该工具即可确定需要运行的所有内容。

蚂蚁是老软件,最初发布于 2000 年。数年来,Maven 和 Gradle 等其他工具在 Ant 上得到了改进,基本上通过添加外部依赖项自动管理和不含任何 XML 的更简洁语法等功能取代。但这些新系统的性质保持不变:它们允许工程师以有原则的模块化方式编写构建脚本作为任务,并提供用于执行这些任务和管理这些依赖项之间的工具。

基于任务的构建系统的黑暗面

由于这些工具基本上允许工程师将任何脚本定义为一项任务,因此它们功能非常强大,让您能够使用它们执行几乎任何您能想到的操作。但是,这种功能也存在缺点,随着构建脚本变得越来越复杂,基于任务的构建系统可能会变得难以使用。此类系统的问题在于,它们最终的实际结果是给工程师带来了太多的电力,使系统没有足够的电。由于系统不知道脚本在做什么,因此性能会受到影响,因为它在调度和执行构建步骤的方式方面必须非常保守。系统无法确认每个脚本是否按预期运行,因此脚本往往会越来越复杂,最终是需要调试的另一个方面。

并行执行构建步骤有难度

现代开发工作站具有非常强大的功能,有多个核心能够并行执行多个构建步骤。但是,基于任务的系统通常无法并行执行任务,即使看起来应该能够并行执行。假设任务 A 依赖于任务 B 和 C。由于任务 B 和 C 互不依赖,因此是否可以安全地同时运行这些任务,以便系统能够更快地完成任务 A?也许他们没有 接触到任何相同的资源但也许不会 - 可能两者都使用同一文件来跟踪其状态,而同时运行它们会导致冲突。系统一般无法获知这些冲突,因此要么面临这些冲突的风险(导致罕见但难以调试的构建问题),要么必须限制整个构建过程在单个进程中的单个线程上运行。 这可能会对强大的开发者机器产生巨大浪费,而且完全排除了将 build 分发到多台机器的可能性。

难以执行增量构建

良好的构建系统允许工程师执行可靠的增量构建,使细微更改不需要从头开始重新构建整个代码库。如果构建系统速度慢并且无法出于上述原因并行执行构建步骤,这一点尤为重要。但遗憾的是,基于任务的构建系统也存在问题。由于任务可以执行任何操作,因此通常无法检查是否已完成。许多任务只需获取一组源文件并运行编译器以创建一组二进制文件;因此,如果底层源文件未更改,则不需要重新运行这些二进制文件。但是,如果没有其他信息,系统就无法确定 - 可能是任务下载了一个本可能发生改变的文件,也可能是它每次写入的时间戳都可能不同。为了确保正确性,系统通常必须在每次构建期间重新运行每个任务。一些构建系统会尝试让工程师指定重新运行任务的条件,从而实现增量构建。有时这是可行的,但问题往往比实际要难得多。例如,在 C++ 等允许其他文件直接包含文件的语言中,如果不解析输入来源,就不可能确定必须监控整个文件集的变更。工程师通常最终会采用快捷方式,而这些快捷方式可能会导致出现罕见且令人沮丧的问题,即使在不应使用任务结果的情况下也是如此。如果这种情况经常发生,工程师们就养成了在每个 build 之前运行干净整洁以获得新状态的习惯,完全违背了最初使用增量构建的目的。弄清楚何时需要重新运行任务出人意料地巧妙操作,由机器来负责比人工干预的工作要好。

维护和调试脚本时遇到问题

最后,基于任务的构建系统施加的构建脚本通常很难使用。虽然 build 脚本通常不那么严格,但它们就像构建系统一样,并且很容易被 bug 隐藏。以下是使用基于任务的构建系统时经常会遇到的一些 bug 示例:

  • 任务 A 依赖于任务 B 生成特定文件作为输出。任务 B 的所有者没有意识到其他任务依赖于该任务,因此他们更改了该任务以在其他位置生成输出。只有在用户尝试运行任务 A 但发现任务 A 失败后,系统才能检测到此情况。
  • 任务 A 依赖于任务 B,而任务 B 依赖于任务 C,后者生成了一个特定文件作为任务 A 所需的输出。任务 B 的所有者确定不再需要依赖于任务 C,这就导致任务 A 失败,即使任务 B 并不关心任务 C 也是如此!
  • 新任务的开发者意外地对运行该任务的机器做出假设,例如某个工具的位置或特定环境变量的值。任务会在机器上运行,但只要其他开发者尝试,就会失败。
  • 任务包含非确定性组件,例如从互联网下载文件或向 build 添加时间戳。现在,每次运行构建时,都可能会获得不同的结果,这意味着工程师并不总是可以重现和修复自动构建系统中彼此出现的故障或故障。
  • 具有多个依赖项的任务可以创建竞态条件。如果任务 A 同时依赖于任务 B 和任务 C,并且任务 B 和 C 都修改了同一个文件,则任务 A 会获得不同的结果,具体取决于任务 B 和 C 中的哪一个结束了。

此处列出的基于任务的框架中没有通用方法来解决这些性能、正确性或可维护性问题。只要工程师可以编写在构建期间运行的任意代码,系统就无法获得足够的信息来始终快速正确运行构建。为了解决这个问题,我们需要从工程师手中夺取一些权力,并将控制权移交给系统,并将系统角色重新理解为运行工件而不是生成工件。

这种方法促使我们创建了基于工件的构建系统,例如 Blaze 和 Bazel。