本页介绍了基于任务的构建系统、它们的工作原理以及基于任务的系统可能出现的一些复杂问题。在 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 使用“目标”一词表示“任务”,并使用“任务”来表示“命令”。)每个任务都会执行 Ant 定义的一系列可能的命令,其中包括创建和删除目录、运行 javac
和创建 JAR 文件。这组命令可以由用户提供的插件扩展,以涵盖任何类型的逻辑。每个任务还可以通过依赖属性来定义其所依赖的任务。这些依赖项会形成一个非循环图,如图 1 所示。
图 1. 显示依赖项的无环图
用户通过向 Ant 的命令行工具提供任务来执行构建。例如,当用户输入 ant dist
时,Ant 会执行以下步骤:
- 加载当前目录中名为
build.xml
的文件,并对其进行解析以创建图结构(如图 1 所示)。 - 查找命令行上提供的名为
dist
的任务,并发现它依赖于名为compile
的任务。 - 查找名为
compile
的任务,并发现它依赖于名为init
的任务。 - 查找名为
init
的任务,并发现它没有依赖项。 - 执行
init
任务中定义的命令。 - 在
compile
任务的所有依赖项都已运行的情况下,执行compile
任务中定义的命令。 - 在
dist
任务的所有依赖项都已运行的情况下,执行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 脚本实际上并没有太大的区别。但我们已经从中获益良多。我们可以在其他目录中创建新的 buildfile 并将它们关联起来。我们能够以任意且复杂的方式轻松地添加依赖于现有任务的新任务。我们只需将单个任务的名称传递给 ant
命令行工具,即可确定需要运行的所有内容。
Ant 是一款旧软件,最初发布于 2000 年。在过去几年中,Maven 和 Gradle 等其他工具在 Ant 的基础上不断改进,并通过添加自动管理外部依赖项和无任何 XML 的更简洁语法等功能,从根本上取代了 Ant。但这些新系统的性质保持不变:它们允许工程师以任务的形式以原则化和模块化的方式编写构建脚本,并提供执行这些任务和管理它们之间的依赖项的工具。
基于任务的构建系统的缺点
从本质上讲,这些工具可让工程师将任何脚本定义为任务,因此它们功能极其强大,可让您使用这些工具完成您能想到的任何任务。但这种强大功能也有缺点,随着基于任务的构建系统的构建脚本变得越来越复杂,使用起来可能会变得困难。此类系统的问题在于,它们实际上最终会为工程师提供太多功能,而为系统提供足够的功能。由于系统不知道脚本在做什么,性能会受到影响,因为其调度和执行构建步骤的方式必须非常保守。而且,系统无法确认每个脚本是否在执行应有操作,因此脚本往往会变得越来越复杂,最终成为另一个需要调试的项目。
难以并行执行构建步骤
现代开发工作站非常强大,其多个核心能够并行执行多个构建步骤。但是,基于任务的系统通常无法并行执行任务,即使看起来应该能够执行。假设任务 A 依赖于任务 B 和 C。由于任务 B 和任务 C 彼此没有依赖关系,是否可以同时运行它们,以便系统更快地执行任务 A?如果它们不使用任何相同的资源,或许可以。但也许不是这样,也许这两个任务都使用相同的文件来跟踪状态,同时运行它们会导致冲突。通常,系统无法得知,因此它必须冒着发生这些冲突的风险(导致罕见但非常难以调试的构建问题),或者必须限制整个 build 在单个进程中的单个线程上运行。这可能会造成强大的开发者机器的巨大浪费,并且会完全排除在多台机器上分发 build 的可能性。
难以执行增量 build
借助良好的构建系统,工程师可以执行可靠的增量构建,这样一来,只需进行一项小更改,就无需从头重新构建整个代码库。如果构建系统运行缓慢且无法出于上述原因并行执行构建步骤,这一点尤为重要。但遗憾的是,基于任务的构建系统在这里也遇到了问题。由于任务可以执行任何操作,因此通常无法检查它们是否已完成。许多任务只需获取一组源文件并运行编译器即可创建一组二进制文件;因此,如果底层源文件未更改,则无需重新运行这些任务。但是,如果没有其他信息,系统无法确定这一点:任务下载的文件可能已更改,或者它写入的时间戳可能在每次运行时都不同。为了保证正确性,系统通常必须在每次构建期间重新运行每个任务。某些构建系统会尝试通过让工程师指定需要重新运行任务的条件来启用增量构建。有时,这种方法是可行的,但通常情况下,这个问题比看起来要棘手得多。例如,在允许其他文件直接包含文件的语言(如 C++)中,如果不解析输入源,则无法确定必须监控哪些文件以便及时发现更改。工程师最终往往会采取捷径,而这些捷径可能会导致罕见且令人沮丧的问题,即重复使用任务结果,即使不应这样做。如果这种情况经常发生,工程师会养成在每次构建之前运行清理作业的习惯,以获取新状态,这完全违背了增量构建的初衷。确定何时需要重新运行任务非常细微,这项工作由机器来处理比由人来处理更合适。
难以维护和调试脚本
最后,基于任务的构建系统强加的构建脚本通常很难使用。虽然构建脚本通常不太受审核,但它们与要构建的系统一样是代码,并且很容易隐藏 bug。以下是使用基于任务的构建系统时非常常见的一些 bug 示例:
- 任务 A 依赖于任务 B 来生成特定文件作为输出。任务 B 的所有者没有意识到其他任务依赖于它,因此更改了它,以便在其他位置生成输出。直到有人尝试运行任务 A 并发现它失败后,才能检测到这种情况。
- 任务 A 依赖于任务 B,任务 B 依赖于任务 C,任务 C 会生成作为任务 A 所需输出的特定文件。任务 B 的所有者认为任务 B 不再需要依赖任务 C,这会导致任务 A 失败,即使任务 B 并不在意任务 C!
- 新任务的开发者无意中对运行任务的机器做出了假设,例如工具的位置或特定环境变量的值。任务在其机器上可以正常运行,但每当其他开发者尝试运行时都会失败。
- 任务包含非确定性组件,例如从互联网下载文件或向 build 添加时间戳。现在,用户每次运行 build 时都可能会得到不同的结果,这意味着工程师不一定能重现和修复彼此的失败问题,也无法修复自动化 build 系统上发生的失败问题。
- 具有多个依赖项的任务可能会创建竞态条件。如果任务 A 依赖于任务 B 和任务 C,而任务 B 和 C 都修改了同一文件,则任务 A 会得到不同的结果,具体取决于任务 B 和 C 中的哪一项最先完成。
在本文中介绍的基于任务的框架中,没有通用的方法来解决这些性能、正确性或可维护性问题。只要工程师可以编写在 build 期间运行的任意代码,系统就无法获得足够的信息,无法始终能够快速且正确地运行 build。为解决此问题,我们需要从工程师手中夺取一些电力,并交回系统手中,并将系统的角色重新概念为运行中的任务,而是生成工件。
这种方法促使创建了基于工件的构建系统,如 Blaze 和 Bazel。