基于任务的构建系统

本页面介绍了基于任务的构建系统、其工作原理以及基于任务的系统可能发生的一些复杂性。在 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>

构建文件是用 XML 编写的,它定义了一些与构建相关的简单元数据以及任务列表(XML 中的 <target> 标记)。(Ant 使用单词目标表示任务并使用任务引用命令。) 每个任务会执行由 Ant 定义的一系列可能命令,包括创建和删除目录、运行 javac 以及创建 JAR 文件。这组命令可以由用户提供的插件扩展,以涵盖任何类型的逻辑。每个任务还可以通过依赖项特性来定义它所依赖的任务。这些依赖项构成了无环图,如图 1 所示。

显示依赖关系的 ry 图

图 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 和构建脚本实际上并没有太大区别。但我们已经通过这样做取得了很多成果。我们可以在其他目录中创建新的 buildfile,并将它们关联起来。我们能以任意复杂的方式轻松地添加依赖于现有任务的新任务。我们只需将单个任务的名称传递给 ant 命令行工具,即可确定需要运行的所有内容。

Ant 是一种于 2000 年首次发布的旧版软件。其他工具(如 Maven 和 Gradle)在过去几年中改进了 Ant,并通过添加自动管理外部依赖项和更简洁的语法(没有任何 XML)等功能进行了实际替换。但是,这些较新系统的本质保持不变:它们允许工程师以任务化的模块化方式编写构建脚本,并提供执行这些任务以及管理它们之间的依赖关系的工具。

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

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

并行执行构建步骤的难度

现代开发工作站功能非常强大,具有多个核心,能够并行执行多个构建步骤。但是,基于任务的系统通常无法并行执行任务,即使任务看似本应如此。假设任务 A 依赖于任务 B 和 C。由于任务 B 和任务 C 之间没有相互依赖,因此可以安全地同时运行任务 B,以便系统更快地获得任务 A 吗?也许它们不涉及任何相同的资源, 但可能不一样,可能是因为两者都使用同一个文件跟踪其状态,而且同时运行这两个文件会导致冲突。一般来说,系统没有办法了解这些冲突,因此必须存在这些冲突的风险(导致罕见但很难调试的构建问题),或者必须限制整个构建为在单个进程内的单个线程上运行。 这可能会浪费强大的开发者机器,并且会完全排除将构建分布在多台机器上的可能性。

难以执行增量构建

良好的构建系统可让工程师执行可靠的增量构建,这样细微更改就无需从头开始重新构建整个代码库。如果构建系统由于上述原因运行缓慢且无法并行执行构建步骤,这一点尤为重要。但遗憾的是,基于任务的构建系统同样难以实现。由于任务可以执行任何操作,因此通常无法检查任务是否已完成。许多任务只需要获取一组源文件并运行编译器来创建一组二进制文件;因此,如果底层源文件未更改,则无需重新运行。但是,如果没有额外的信息,系统就无法给出确认 - 可能是因为任务下载了可能已改变的文件,或者可能写入在每次运行时可能不同的时间戳。为确保正确性,系统通常必须在每次构建期间重新运行每个任务。有些构建系统会尝试通过让工程师指定需要重新运行任务的条件来启用增量构建。有时,这种方法是可行的,但有时它比看上去复杂得多。例如,在诸如 C++ 之类的语言中,如果文件可以被其他文件直接包含,那么不解析输入来源就很难确定必须监控整个文件集。工程师最后常常会利用快捷方式,这些快捷方式可能会导致任务结果重复被罕见且令人 丧,即使不应理应这样做。这种情况经常发生,工程师们习惯于在每个 build 之前运行干净 build,以获取全新状态,这完全违背了一开始就使用增量 build 的目的。确定何时需要重新运行某个任务时,这个过程令人惊 sub,并且机器比人类更好地处理了一项任务。

难以维护和调试脚本

最后,基于任务的构建系统强行构建的构建脚本通常难以使用。虽然构建脚本通常受到的审查较少,但构建脚本是与正在构建的系统一样的代码,很容易嵌入错误。以下是使用基于任务的构建系统时一些非常常见的错误示例:

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

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

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