基于任务的构建系统

报告问题 查看源代码

本页将介绍基于任务的构建系统、这些系统的工作原理以及基于任务的系统可能会出现的一些复杂问题。继 Shell 脚本之后,基于任务的构建系统是构建方面的下一代逻辑进化。

了解基于任务的构建系统

在基于任务的构建系统中,基本工作单元是任务。每个任务都是一个可执行任何类型的逻辑的脚本,并且任务会将其他任务指定为必须在这些任务之前运行的依赖项。目前使用的大多数主要构建系统(如 Ant、Maven、Gradle、Grunt 和 Rake)都是基于任务的。大多数现代构建系统都需要工程师创建描述如何执行构建的构建文件,而不是 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>

build 文件采用 XML 编写,并定义了一些关于 build 的简单元数据和任务列表(XML 中的 <target> 标记)。(Ant 使用“目标”一词表示“任务”,并使用“任务”指代“命令”。)每个任务都会执行由 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/*

去除语法后,build 文件和 build 脚本实际上并没有太大的区别。但我们通过这样做获益良多。我们可以在其他目录中创建新的 build 文件,并将它们关联在一起。我们能够以任意且复杂的方式轻松地添加依赖于现有任务的新任务。我们只需将单个任务的名称传递给 ant 命令行工具,即可确定需要运行的所有内容。

Ant 是一种最早于 2000 年发布的旧软件。在此期间,Maven 和 Gradle 等其他工具也在 Ant 的基础上进行了改进,并通过添加了自动管理外部依赖项和更简洁的语法(无需任何 XML)等功能实质上取代了 Ant。但这些新系统的性质保持不变:它们允许工程师以有原则的模块化方式将构建脚本编写为任务,并提供用于执行这些任务和管理这些任务之间的依赖关系的工具。

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

从本质上讲,这些工具可让工程师将任何脚本定义为任务,因此它们功能极其强大,可让您使用这些工具完成您能想到的任何任务。但这种能力也有缺点,并且随着基于任务的构建系统的构建脚本变得越来越复杂,基于任务的构建系统可能会变得难以使用。此类系统的问题在于,它们实际上最终会为工程师提供过多功能,而为系统提供足够的功能。由于系统不知道脚本在做什么,性能会受到影响,因为其调度和执行构建步骤的方式必须非常保守。系统无法确认每个脚本是否都执行了它应有的操作,因此脚本往往会变得更加复杂,最终成为需要调试的另一项内容。

难以并行执行构建步骤

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

难以执行增量构建

良好的构建系统可让工程师执行可靠的增量构建,这样细微的更改就不需要从头开始重新构建整个代码库。如果构建系统因上述原因而运行缓慢且无法并行执行构建步骤,这一点尤为重要。但遗憾的是,基于任务的构建系统在这方面同样举步维艰。由于任务可以执行任何操作,因此通常无法检查任务是否已执行。许多任务只需获取一组源文件并运行编译器即可创建一组二进制文件;因此,如果底层源文件未更改,则无需重新运行这些任务。但是,如果没有其他信息,系统便无法确定这一点。任务下载的文件可能已更改,或者每次运行时写入的时间戳都可能不同。为保证正确性,系统通常必须在每次构建期间重新运行每项任务。某些构建系统通过让工程师指定在哪些条件下需要重新运行任务来尝试启用增量构建。有时候,这是可行的,但通常情况下,这个问题比看起来要复杂得多。例如,在允许其他文件直接包含文件的 C++ 等语言中,无法确定在不解析输入源的情况下必须观察更改的整组文件。工程师最终往往会走捷径,而这些捷径可能会导致罕见且令人沮丧的问题,即任务结果被重复使用,即使不应重复使用。如果这种情况经常发生,工程师们会养成在每次构建之前运行清理的习惯以获得全新状态,这与一开始就完全违背了增量构建的目的。弄清楚何时需要重新运行某个任务是极其微妙的,并且这项工作由机器比人工处理得更好。

难以维护和调试脚本

最后,基于任务的构建系统施加的构建脚本往往难以使用。虽然构建脚本通常不太接受审查,但构建脚本就像是正在构建的系统的代码,很容易隐藏 bug。以下是一些使用基于任务的构建系统时很常见的 bug 示例:

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

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

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