背景

最近一向在做编译优化相关的作业,在作业的进程中才发现自己一向都忽略了一个重要东西,不同于自己写的简略项目,一个老练的项目都是根据构建体系构建的,假如不了解构建体系也就很难做出什么优化,在自己学习之后也希望经过一些文章将这些内容沉积下来。希望把这些内容做成一个系列吧,首要先宏观了解构建体系的根本概念,然后再介绍一下常用的构建体系如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 编译器而不是任意用户界说的脚本,因而知道能够并行运转这些进程。与在多核机器上一次构建一个方针比较,这样可进步一个数量级的功能,而这只能因为根据工件的方法让构建体系担任自己的履行策略,然后能够更好地保证并行性。

不过,优势不仅仅是并行性。当开发者第2次输入 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。