基于任务的构建系统

本页将介绍基于任务的构建系统、它们的工作原理,以及基于任务的系统可能会出现的一些复杂情况。在 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>

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 任务中定义的命令(前提是该任务的所有依赖项都已运行)。

最后,运行 dist 任务时 Ant 执行的代码等同于以下 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 脚本实际上并没有太大区别。不过,通过这样做,我们已经收获很多。我们可以在其他目录中创建新的 buildfile,并将这些文件链接到一起。我们可以轻松以任意复杂方式添加依赖于现有任务的新任务。我们只需将单个任务的名称传递给 ant 命令行工具,即可确定需要运行的所有内容。

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

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

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

并行执行构建步骤的难度

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

难以执行增量构建

良好的构建系统可以让工程师执行可靠的增量构建,这样一个小小的变更就不需要从头开始重新构建整个代码库。如果构建系统速度缓慢且由于上述原因无法并行执行构建步骤,这一点尤为重要。遗憾的是,基于任务的构建系统在这方面也不太容易实现。由于任务可以执行任何操作,因此一般而言,您无法检查它们是否已完成。许多任务只需接受一组源文件并运行编译器来创建一组二进制文件;因此,如果底层源文件未更改,则无需重新运行这些任务。但如果没有其他信息,系统就无法确定这一点 - 也许任务下载的文件可能已更改,或者它写入的时间戳可能在每次运行时有所不同。为了保证正确性,系统通常必须在每次构建期间重新运行每个任务。一些构建系统尝试通过让工程师指定需要在哪些条件下重新运行任务来尝试启用增量构建。有时这是可行的,但实际情况往往比看起来复杂得多。例如,在 C++ 等允许其他文件直接添加文件的语言中,不解析输入源就无法确定必须监控更改的整组文件。工程师们往往会走捷径,而这些捷径可能会导致罕见且令人沮丧的问题,即使任务结果本不该被重复使用,也是如此。如果这种情况经常发生,工程师会养成在每次构建之前都运行干净的习惯来获得新状态,这完全颠倒了一开始就使用增量 build 的目的。弄清楚何时需要重新运行任务非常简单,而且是由机器比人类更好地处理的工作。

难以维护和调试脚本

最后,基于任务的构建系统施加的构建脚本通常很难使用。虽然构建脚本通常没有那么严格审查,但构建脚本与正在构建的系统类似,是容易隐藏 bug 的地方。下面是在使用基于任务的构建系统时非常常见的一些 bug 示例:

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

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

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