基于任务的构建系统

报告问题 查看来源 Nightly · 8.3 · 8.2 · 8.1 · 8.0 · 7.6

本页介绍了基于任务的 build 系统、其工作方式以及基于任务的系统可能会出现的一些复杂情况。在 shell 脚本之后,基于任务的构建系统是构建的下一个逻辑演进。

了解基于任务的构建系统

在基于任务的 build 系统中,基本工作单元是任务。每个任务都是一个可以执行任何类型逻辑的脚本,并且任务会将其他任务指定为必须在自身之前运行的依赖项。目前使用的大多数主要构建系统(例如 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,并使用字词 task 指代 commands。)每个任务都会执行 Ant 定义的可能命令列表,其中包括创建和删除目录、运行 javac 以及创建 JAR 文件。用户提供的插件可以扩展这组命令,以涵盖任何类型的逻辑。每个任务还可以通过 depends 属性定义其依赖的任务。这些依赖项构成一个非循环图,如图 1 所示。

显示依赖关系的亚克力图

图 1. 显示依赖关系的有向无环图

用户通过向 Ant 的命令行工具提供任务来执行 build。例如,当用户输入 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 命令行工具,该工具就会确定需要运行的所有内容。

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

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

由于这些工具实际上允许工程师将任何脚本定义为任务,因此它们非常强大,可让您执行几乎任何可以想象的任务。但这种强大功能也存在缺点,随着基于任务的构建系统的构建脚本变得越来越复杂,使用起来可能会很困难。此类系统的问题在于,它们实际上最终会赋予工程师过大的权力,而赋予系统过小的权力。由于系统不知道脚本在做什么,因此在调度和执行 build 步骤时必须非常保守,这会导致性能下降。系统也无法确认每个脚本是否在执行应有的操作,因此脚本往往会变得越来越复杂,最终成为需要调试的另一项内容。

并行化构建步骤的难度

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

难以执行增量 build

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

难以维护和调试脚本

最后,基于任务的构建系统所施加的构建脚本通常难以处理。虽然构建脚本通常不太受关注,但它们与正在构建的系统一样都是代码,很容易隐藏 bug。以下是一些在使用基于任务的 build 系统时非常常见的 bug 示例:

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

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

这种方法促成了基于制品库的构建系统(如 Blaze 和 Bazel)的诞生。