从零开始学习构建系统之概述

背景

最近一直在做编译优化相关的工作,在工作的过程中才发现自己一直都忽略了一个重要东西,不同于自己写的简单项目,一个成熟的项目都是基于构建系统构建的,如果不了解构建系统也就很难做出什么优化,在自己学习之后也希望通过一些文章将这些内容沉淀下来。希望把这些内容做成一个系列吧,首先先宏观了解构建系统的基本概念,然后再介绍一下常用的构建系统如cmake,gradle和bazel。

什么是构建系统

那么什么是构建系统呢?其可以分为构建和系统,对于系统的定义如下:

系统是由相互作用相互依赖的若干组成部分结合而成的,具有特定功能的有机整体,而且这个有机整体又是它从属的更大系统的组成部分。

那么我们又该如何理解构建呢?

与构建相关联的是编译,编译是将一个源文件转换成一个二进制文件,而构建就是对于编译的安排,在一个大的工程中包含很多源文件,其中可能还包含这复杂的依赖关系,构建就是对于多个编译的合理安排。

构建系统:拥有安排多个编译功能的一个有机整体。

为什么需要构建系统

大多数工程师在学习编程时都是使用非常简单的例子,例子可能就只有一个源文件,所以大多数工程师开始都是直接调用gcc或javac等工具,或者使用IDE中提供的便捷的编译工具,例如以下例子就是将同一目录的源代码转化为二进制文件。

javac *.java

javac非常的智能,可以在当前目录的子目录中查找要导入的代码。但是它找不到文件系统的其他部分中存储的代码(或许是由多个项目共享的库)。它还只知道如何构建Java 代码。大型系统通常涉及使用各种编程语言编写的不同部分,并且这些部分之间具有网络,这意味着单一编译器无法构建整个系统。

当工程日趋复杂,一个简单的编译命令无法满足要求而需要多个编译命令的组合,这时候可能会想想到使用shell脚本来组织编译命令,这些脚本会按正确的顺序构建应用,但是随着工程的进一步膨胀,shell也显的力不从心,会遇到很多问题:

  • 构建变得很繁琐。随着系统变得越来越复杂,您在构建脚本上花费的时间几乎与实际代码一样。调试 shell 脚本非常痛苦,并且越来越多的黑客手段层层叠加。
  • 很慢。为了确保您不会意外依赖过时的库,需要让构建脚本在每次运行时按顺序构建每个依赖项。所以需要考虑添加一些逻辑来检测哪些部分需要重新构建,但这听起来非常复杂,并且对于脚本来说很容易出错,脚本也会越来越复杂,难以管理。

所以我自己的理解是从最初的gcc,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 中的 标记)。(Ant使用target 一词来表示任务,并使用task一词来指代命令。)每个任务都会执行由 Ant 定义的可能命令列表,其中包括创建和删除目录、运行 javac 以及创建 JAR 文件。用户提供的插件可以扩展这组命令,使其涵盖任何类型的逻辑。每个任务也可以通过依赖项属性定义其所依赖的任务。这些依赖项形成一个无环图,如下图所示。

  • 在当前目录中加载一个名为 build.xml 的文件,对其进行解析,以创建如图所示的图表结构。
  • 查找在命令行中提供的名为 dist 的任务,发现其依赖于名为 compile 的任务。
  • 查找名为 compile 的任务,发现其依赖于一个名为 init 的任务。
  • 查找名为 init 的任务,发现它没有依赖项。
  • 执行 init 任务中定义的命令。
  • 执行 compile 任务中定义的命令(鉴于相应任务的所有依赖项均已运行)。
  • 执行 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 命令行工具,该工具将确定需要运行的所有内容。

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

难以并行执行

现代开发工作站非常强大,有多个核心,可以并行执行多个构建步骤。但是,基于任务的系统通常似乎难以并行执行任务,假设任务 A 依赖于任务 B 和任务 C。由于任务 B 和任务 C 相互不依赖,因此能否安全同时运行这些任务,以便系统能够更快地完成任务 A?也许他们没有接触到任何相同的资源。但也可能不同——两者可能会使用同一文件跟踪其状态,并且同时运行这两个文件会导致冲突。系统一般无法知道这一点,因此要么面临这些冲突的风险(导致罕见但很难调试的构建问题),要么必须限制整个构建过程在单个进程中的单个线程上运行。 这会大大浪费强大的开发者机器,并且完全排除了在多台机器上分发 build 的可能性。

增量构建问题

良好的构建系统可让工程师执行可靠的增量构建,如果只是细微的改动,则无需从头开始重新构建整个代码库。如果构建系统速度慢且因上述原因而无法并行执行构建步骤,则增量编译尤为重要。在基于任务的构建系统中,许多任务只需获取一组源文件并运行编译器以创建一组二进制文件,因此只要底层源文件未更改,则不需要重新运行它们。但是如果没有其他信息,系统无法确定源文件是否没有更改,为了确保正确性,系统通常必须在每次构建期间重新运行每个任务。

脚本维护与调试困难

基于任务的构建系统施加的构建脚本通常很难使用。虽然构建脚本通常不那么严格,但就像构建系统一样,它们也是容易隐藏bug的地方。下面列出了一些在使用基于任务的构建系统时出现的常见错误示例:

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

基于工件的构建系统

可以将基于工件的构建系统与功能编程进行类比。传统的命令式编程语言(例如 Java、C 和 Python)会逐个指定要执行的语句列表,其方式与基于任务的构建系统允许程序员定义一系列要执行的步骤相同。相比之下,函数编程语言(例如 Haskell 和 ML)的结构更像是一系列数学方程式。在功能语言中,程序员描述要执行的计算,但会将计算的执行时间和方式的详细信息留给编译器。

这对应于在基于工件的构建系统中声明清单并让系统确定如何执行构建这一想法。使用功能编程无法轻松表达许多问题,但能够从中受益很大,此种语言通常能够并行地对此类程序进行并行处理。使用函数式编程解决最简单的问题是就是使用一系列规则或函数简单地将一段数据转换为另一块数据的问题。这正好是构建系统:整个系统实际上是一个数学函数,它将源文件(和编译器等工具)作为输入,并生成二进制文件作为输出。
Google 的构建系统 Blaze 是第一个基于工件的构建系统。Bazel 是 Blaze 的开源版本
build 文件(通常名为 BUILD)在 Bazel 中的如下所示:

java_binary(
    name = "MyBinary",
    srcs = ["MyBinary.java"],
    deps = [
        ":mylib",
    ],
)
java_library(
    name = "mylib",
    srcs = ["MyLibrary.java", "MyHelper.java"],
    visibility = ["//java/com/example/myproduct:__subpackages__"],
    deps = [
        "//java/com/example/common",
        "//java/com/example/myproduct/otherlib",
    ],
)

在 Bazel 中,BUILD 文件定义了目标,这里的两种目标为 java_binary 和 java_library。每个目标都对应于一个系统可以创建的工件:二进制目标会生成可直接执行的二进制文件,而库目标会生成可由二进制文件或其他库使用的库。每个目标都有:

  • name:如何通过命令行和其他目标引用该目标
  • srcs:为针对目标创建工件而编译的源文件
  • deps:必须在此目标之前构建的其他目标并将其关联到此目标
    依赖项可以位于同一软件包中(例如 MyBinary 对 :mylib 的依赖项),也可以位于同一源代码层次结构中的其他软件包(例如 mylib 对 //java/com/example/common 的依赖项)。

与基于任务的构建系统一样,您使用 Bazel 的命令行工具执行构建。如需构建 MyBinary 目标,请运行 bazel build :MyBinary。在干净的代码库中首次输入该命令后,Bazel会执行以下操作。

  • 解析工作区中的每个BUILD文件,以创建工件之间的依赖关系图。
  • 使用图来确定MyBinary的传递依赖项;也就是说,MyBinary所依赖的每个目标以及这些目标所依赖的每个目标都以递归方式进行处理。
  • 按顺序构建其中每个依赖项。Bazel首先构建没有其他依赖项的每个目标,并跟踪仍需为每个目标构建需要哪些依赖项。目标的所有依赖项一经构建,Bazel 就会开始构建该目标。此过程持续到MyBinary的每个传递依赖项均已构建完毕。
  • 构建 MyBinary以生成最终的可执行二进制文件,该文件会链接在第3步中构建的所有依赖项。

从根本上说,此处发生的事件似乎与使用基于任务的构建系统时发生的情况有很大不同。事实上,最终结果是相同的二进制文件,生成它的过程涉及分析一系列步骤以找到它们之间的依赖关系,然后按顺序运行这些步骤。但两者之间存在严重差异。在第3步中由于Bazel知道每个目标只生成一个 Java 库,因此它知道它只需运行 Java 编译器而不是任意用户定义的脚本,因此知道可以并行运行这些步骤。与在多核机器上一次构建一个目标相比,这样可提高一个数量级的性能,而这只能因为基于工件的方法让构建系统负责自己的执行策略,从而可以更好地保证并行性。

不过,优势不仅仅是并行性。当开发者第二次输入 bazel build :MyBinary 而未进行任何更改时,这种方法会让我们显得很明显:Bazel 将在不到一秒内退出,并显示一条消息,说明目标已是最新状态。这是可能的,因为我们之前讨论过函数编程范例 - Bazel 知道每个目标只是运行 Java 编译器的结果,也知道 Java 编译器的输出只依赖于其输入,因此只要输入未更改,就可以重复使用该输出。此分析适用于每个级别;如果 MyBinary.java 发生变化,Bazel 知道要重新构建 MyBinary 但会重复使用 mylib。如果 //java/com/example/common 的源文件发生更改,Bazel 知道要重新构建该库、mylib 和 MyBinary,但会重复使用 //java/com/example/myproduct/otherlib。由于 Bazel 了解其每一步运行的工具的属性,因此它每次都只能重新构建一组最低的工件,同时又保证它不会生成过时的 build。

从工件而不是任务重新构建构建过程既微妙又强大。通过降低向程序员提供的灵活性,构建系统可以详细了解构建过程中每个步骤执行的操作。它可以运用这些信息,通过并行构建流程和重复使用其输出来提高构建效率。但这只是第一步,这些并行处理和重用构建构成了分布式且高度可扩缩的构建系统的基础。

最后

这篇文章主要介绍了构建系统的概念,如基于任务的构建系统和基于工件的构建系统,更多文章可以关注公众号QStack。

猜你喜欢

转载自blog.csdn.net/QStack/article/details/128810208