基于任务的构建系统

报告问题 查看来源 每晚 · 7.3。 · 7.2 条 · 7.1。 · 7.0。 · 6.5

本页将介绍基于任务的构建系统、这些系统的工作原理以及一些 基于任务的系统可能会出现的复杂问题。在 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 使用 target 一词来表示 task,并使用 task 一词来指代 commands。)每个任务都会执行 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/*

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

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

基于任务的构建系统的缺点

由于这些工具本质上允许工程师将任何脚本定义为任务,因此它们非常强大,可让您通过它们执行几乎所有能想象到的操作。但这种能力也存在缺点,基于任务的构建系统 使其构建脚本变得越来越复杂。通过 这类系统的问题在于,它们实际上最终会输送出太多电量, 而对系统而言,却不够强大。因为系统不知道 脚本的行为,性能会受到影响,因为必须非常保守 如何安排和执行构建步骤。而且系统无法 确认每个脚本都执行了它应有的操作, 并最终成为另一个需要调试的问题。

构建步骤并行处理的难度

现代开发工作站功能非常强大, 能够并行执行多个构建步骤。但是,基于任务的系统通常无法并行执行任务,即使看起来应该可以并行执行也是如此。假设任务 A 依赖于任务 B 和 C。因为任务 B 和任务 C 不相互依赖,同时运行它们是否安全? 系统是否能够更快地完成任务 A?如果它们不使用任何相同的资源,或许可以。但也许不是这样,也许这两个任务都使用相同的文件来跟踪状态,同时运行它们会导致冲突。我们没有 但系统通常知道这些冲突, (会导致罕见但非常难以调试的构建问题),或者 将整个构建限制为在单个进程的单个线程上运行。 这可能会严重浪费强大的开发者机器,并且完全排除了在多台机器上分发 build 的可能性。

难以执行增量 build

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

难以维护和调试脚本

最后,基于任务的构建系统施加的构建脚本通常只是 也很难处理虽然它们通常不会受到审查,但构建脚本 就像是正在构建的系统代码,很容易将 bug 隐藏起来。 以下是使用基于任务的构建系统时非常常见的一些 bug 示例:

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

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

这种方法催生了 Blaze 和 Bazel 等基于工件的构建系统。