使用 Ninja 加速构建!详解其使用方法

日本語|English|中国语
| 9 min read
Author: kotaro-miura kotaro-miuraの画像
Information

为了覆盖更广泛的受众,这篇文章已从日语翻译而来。
您可以在这里找到原始版本。

前言

#

最近我在使用构建工具 Make,心想“这个工具虽然用了很久,但有没有什么新的构建工具广泛流行呢?”查找之后得知 Ninja 这个工具非常不错,于是决定尝试一下,并将体验过程总结如下。

Ninja 的特点

#

Ninja 是一款以比 Make 更高速运行为卖点的构建系统。

它最初为了加快像 Google Chrome 这样的大型项目(从大约 40,000 个 C++ 文件编译生成单一可执行文件)的构建速度而开发。[1]

它提出了如下设计目标。[2]

  • 即使在庞大项目中也能进行非常快速的增量构建
  • 几乎不对代码的构建方式施加任何策略限制
  • 即便在 Makefile 难以正确解析的情况下,也能准确掌握依赖关系
  • 当便利性与速度发生冲突时,以速度为优先

反过来,下列事项则并非其明确的设计目标:

  • 用于手工编写构建文件的便捷语法
    • Ninja 文件应通过其他程序生成。(支持 CMake、Meson 等(作者补充))
  • 内置规则
    • Ninja 不具备类似 Make 那种用于编译 C 代码的隐式规则。
  • 构建时的定制
    • 命令选项应包含在生成 Ninja 文件的程序中。
  • 构建时的条件分支及搜索路径
    • 由于决策过程较慢,应尽量避免。

GitHub Star 数也在不断增长,看起来正在顺利普及。
Star History Chart

可以从 手册 中查看详细的规格说明。

试用

#

在 Ubuntu 环境下,可以通过以下命令安装:

$ sudo apt-get install ninja-build

ninja 命令

#

使用 ninja 命令来执行构建。

按如下格式执行:

ninja [オプション] [ターゲット名...]

配置文件(build.ninja)

#

执行 ninja 时,默认会从当前目录下的 build.ninja 文件读取配置。

如果要指定文件名来执行,则使用 ninja -f 文件路径 选项。

那么让我们来看看配置文件的书写方法。

基本格式如下:

build.ninja
rule ルール名
    command = コマンド

build ターゲット: ルール名 依存ファイル

基本上,是通过使用 rulebuild 两种声明语句来描述。

  • 在 build 语句中,将目标(即要生成的文件名)与对应的规则(生成方法)及依赖文件(生成所需的文件)进行关联。目标和依赖文件均可用空格分隔指定多个文件名。
  • 在 rule 语句中,在 command = 后跟上为了生成文件而需要执行的命令。

示例

#

下面给出一个简单的例子。

build.ninja
rule r1
    command = echo "DEP sample" > $out

rule r2
    command = echo "TEST `cat $in`" > $out

build test.txt: r2 dep.txt
build dep.txt: r1

示例解析

#
  1. build test.txt: r2 dep.txt
    
    表示用规则 r2,通过依赖文件 dep.txt 来生成 test.txt 文件。
    如果 dep.txt 不存在,则会执行生成 dep.txt 的 build 语句。
  2. build dep.txt: r1
    
    表示用规则 r1 生成 dep.txt 文件。没有指定依赖文件。
  3. rule r1
        command = echo "DEP sample" > $out
    
    在规则 r1 中,执行命令生成包含文本 DEP sample 的文件。
    此处的 $out 是 Ninja 内置变量,会展开为在 build 语句中指定的目标文件名,在本例中为 dep.txt
  4. rule r2
        command = echo "TEST `cat $in`" > $out
    
    在规则 r2 中,执行命令生成一个在输入文件内容前追加 TEST 字符串的文件。
    这里的 $in 同样为 Ninja 内置变量,会展开为在 build 语句中指定的依赖文件名,在本例中为 dep.txt

示例运行结果

#

接下来,用这个配置文件执行构建。与 Make 类似,可以观察到,根据依赖文件是否发生更改,会自动跳过目标生成。

$ ninja test.txt
[2/2] echo "TEST `cat dep.txt`" > test.txt

# 文件内容确认
$ cat dep.txt test.txt
DEP sample
TEST DEP sample

# 即使再次执行构建,也不会进行更新。
$ ninja test.txt 
ninja: no work to do. 

# 更改依赖文件的内容。
$ echo "DEP sample 1" > dep.txt

# 当依赖文件更新后,会重新生成目标文件。
$ ninja test.txt 
[1/1] echo "TEST `cat dep.txt`" > test.txt

# 文件内容确认
$ cat dep.txt test.txt
DEP sample 1
TEST DEP sample 1

以上就是基本的用法,非常简单明了。

依赖关系图

#

我发现 Ninja 提供了一个可以将文件依赖关系可视化为网络图形的工具,非常有趣,在此介绍一下。

使用 ninja -t graph 选项,可以将文件依赖关系图以 graphviz 格式输出。

例如,可以输出前面示例文件对应的依赖关系图。

预先安装 graphviz 后,通过将输出传递给 dot 命令,就可以生成依赖关系图的图片 graph.png

ninja -t graph | dot -Tpng -ograph.png
Information

在 Ubuntu 下,可以使用以下命令安装 graphviz。

sudo apt install graphviz

将会生成如下的图片。
依赖文件和目标以方形节点显示,而规则则以连线连接它们。
如果没有依赖文件,则规则将以圆形节点显示,并与目标相连。

graph1

其他规格总结

#

还有其他一些便于了解的规格,我们继续来看。

变量

#

在配置文件顶层,可以以 变量名 = 字符串 的形式定义变量。
在引用时,写作 $变量名

サンプルファイル
var = 豆蔵

rule r
    command = echo $var

build tag: r
実行結果
$ ninja
[1/1] echo 豆蔵
豆蔵

转义

#

转义字符是 $。在 ninja.build 文件中,如果想使用具有特殊意义的字符(空格、:, $ 本身、换行),可以在其前加 $ 进行转义。

例如,如果要将多个命令换行书写,可以这样写:

rule r4
    command = echo "r4 sample" $
    && echo "r4-12 sample"

phony 规则

#

Ninja 内置了一个名为 phony 的规则。

该规则不执行任何操作,但可用于为目标随意添加依赖关系。

例如,可以如下将 foo 定义为 some/file.txt 文件的别名:

サンプルファイル
rule r1
    command = cat $in > $out

build some/file.txt: r1 dep.txt
build foo: phony some/file.txt

执行时可以指定目标为 foo 而非 some/file.txt

実行結果
$ ninja foo
[1/1] cat dep.txt > some/file.txt

此外,还可以利用该机制创建汇总多个目标的组目标。

サンプルファイル
rule r1
    command = echo "r1 sample"
rule r2
    command = echo "r2 sample"
rule r3
    command = echo "r3 sample"

build all: phony tag1 tag2 tag3
build tag1: r1
build tag2: r2
build tag3: r3

依赖关系图如下:

phony

実行結果
$ ninja all
[1/3] echo "r1 sample"
r1 sample
[2/3] echo "r2 sample"
r2 sample
[3/3] echo "r3 sample"
r3 sample

隐式依赖

#

如前所述,规则内的命令可以使用 $in$out 等变量。
其中,$in 会展开为依赖文件列表,$out 会展开为目标文件列表。
另外,在文件指定中,使用 | 后跟的文件不会被展开为这些变量。

下面展示一个使用 | 的配置文件示例:

サンプルファイル
rule r1
    command = echo "DEP1 sample" > $out

rule r2
    command = echo "DEP2 sample" > $out

rule r3
    command = echo "TEST `cat $in`" > $out

build test1.txt | test2.txt: r3 dep1.txt | dep2.txt
build dep1.txt: r1
build dep2.txt: r2

依赖关系图如下所示:

implicit_dep_graph.png

実行結果
$ ninja test1.txt -v
[1/3] echo "DEP1 sample" > dep1.txt
[2/3] echo "DEP2 sample" > dep2.txt
[3/3] echo "TEST `cat dep1.txt`" > test1.txt

$ cat dep1.txt dep2.txt test1.txt
DEP1 sample
DEP2 sample
TEST DEP1 sample

在执行 r3 时,$in 只展开为 dep1.txt,而 $out 只展开为 test1.txt
另一方面,dep2.txt 被识别为依赖文件,因此会执行生成 dep2.txt 的规则 r2

此外,即使直接构建隐式目标 test2.txt 也不会生成该文件,但依赖文件的生成过程会被执行。

実行結果
$ ninja test2.txt -v
[1/3] echo "DEP1 sample" > dep1.txt
[2/3] echo "DEP2 sample" > dep2.txt
[3/3] echo "TEST `cat dep1.txt`" > test1.txt

$ cat dep1.txt dep2.txt test1.txt
DEP1 sample
DEP2 sample
TEST DEP1 sample

$ ls test2.txt
ls: cannot access 'test2.txt': No such file or directory

Order-Only 依赖

#

在依赖文件中,使用 || 后指定的文件,会确保该依赖文件被更新,但不会参与目标是否需要重建的判断。

利用这一特性,可以在确保依赖文件最新的同时,减少不必要的目标重建。

例如,下面比较了在 Order-Only 依赖与非 Order-Only 依赖下的构建行为。

在下面的例子中,对于是否重新构建 test2.txt 的判断,不会考虑 dep2.txt 是否更新。

サンプルファイル
rule dep
    command = echo "DEP sample" > $out

rule test
    command = cat $in > $out

build test1.txt: test dep1.txt
build test2.txt: test || dep2.txt
build dep1.txt: dep
build dep2.txt: dep

依赖关系图如下:

order_only_graph.png

実行結果
# 假设 test1.txt 和 test2.txt 已存在。
$ touch test1.txt
$ touch test2.txt

# 当重新生成 test1.txt 时,由于 dep1.txt 被更新(此处为新生成),test1.txt 的更新操作将会执行。
$ ninja test1.txt -v
[1/2] echo "DEP sample" > dep1.txt
[2/2] cat dep1.txt > test1.txt

# 重新生成 test2.txt 时,虽然 dep2.txt 被更新(新生成),但不会执行 test2.txt 的更新操作。
$ ninja test2.txt -v
[1/1] echo "DEP sample" > dep2.txt

动态依赖性

#

接下来介绍动态指定依赖文件的功能。

在构建过程中,可以生成一个类似于 build 语句列表的文件来表示依赖性,并利用该文件添加依赖关系。

下面的例子摘自 文档,用于处理 tar 包的解压操作。
在这个配置中,如果 tar 包自上次解压之后有更新,将会重新解压。
此外,即便 tar 包没有更新,如果之前解压出的文件因某种原因不存在,也会重新解压。

build.ninja
rule untar
  command = tar xf $in && touch $out
rule scantar
  command = scantar --stamp=$stamp --dd=$out $in
build foo.tar.dd: scantar foo.tar
  stamp = foo.tar.stamp
build foo.tar.stamp: untar foo.tar || foo.tar.dd
  dyndep = foo.tar.dd

这个过程稍微复杂,下面来一步步解释。

首先,当执行 ninja foo.tar.stamp 时,会评估以下 build 语句:

build foo.tar.stamp: untar foo.tar || foo.tar.dd
  dyndep = foo.tar.dd

这里的 untar 是负责执行解压操作的规则,同时生成用于记录时间戳的 foo.tar.stamp 文件。
dyndep = 是内置关键词,这里指定的文件 foo.tar.dd 预期会按照指定格式[3]描述额外的目标和依赖文件。这个 foo.tar.dd 会根据 tar 包的内容动态生成。
为此,此处将 foo.tar.dd 指定为 Order-Only 依赖。

接下来,将评估下面的 build 语句,用作 foo.tar.dd 的构建过程。

build foo.tar.dd: scantar foo.tar
  stamp = foo.tar.stamp

这里的 scantar 是假设已经提供的虚拟命令。该命令读取 tar 包内容,并根据内容生成如下文件(例如,对 tar tf 的输出进行处理):

foo.tar.dd
ninja_dyndep_version = 1
build foo.tar.stamp | file1.txt file2.txt : dyndep
  restat = 1

其中,file1.txtfile2.txt 是 tar 包中包含的文件名,并将它们作为(隐式的)目标文件添加。

如此一来,就可以根据 tar 包的内容动态指定依赖关系。

依赖关系图如下所示。(指示 file1.txtfile2.txt 的目标节点变成了看起来莫名其妙的数字,这是个 bug 吗……)

dyndep

并行执行

#

Ninja 默认以并行方式执行构建。

下面给出一个不会产生文件的简单例子,用以下配置文件可以观察到其行为。

サンプルファイル
rule r1
    command = sleep 2 && echo "r1 `date +%H:%M:%S`"
rule r2
    command = sleep 2 && echo "r2 `date +%H:%M:%S`"
rule r3
    command = sleep 2 && echo "r3 `date +%H:%M:%S`"

build tag: phony tag1 tag2 tag3
build tag1: r1
build tag2: r2
build tag3: r3

目标 tag 依赖于三个文件 tag1tag2tag3
而这三个依赖文件分别由规则 r1r2r3 执行,在运行时都等待 2 秒后输出当前时间。

実行結果
$ ninja tag
[1/3] sleep 2 && echo "r1 `date +%H:%M:%S`"
r1 19:49:04
[2/3] sleep 2 && echo "r2 `date +%H:%M:%S`"
r2 19:49:04
[3/3] sleep 2 && echo "r3 `date +%H:%M:%S`"
r3 19:49:04

从输出的秒数可以看出,它们是并行执行的,因此全部在同一时刻输出。

关于工具选项

#

如前文所述,在输出依赖关系图时就用到了,ninja 命令中提供了通过 -t 选项使用的便捷工具,包括:

  • browse
    • 可以在浏览器中显示依赖关系图
    • ninja -t browse --port=8000 --no-browser mytarget(在我这里执行时竟然报错💦)
  • graph
    • 以 graphviz 格式输出依赖关系图
    • ninja -t graph mytarget | dot -Tpng -ograph.png
    • 使用 sudo apt install graphviz -y 安装 dot
  • targets
    • 输出目标列表
  • commands
    • 输出给定目标的命令列表
  • inputs
    • 输出给定目标的输入文件列表
  • clean
    • 删除构建产物

结语

#

本文总结了对构建系统工具 Ninja 的体验内容。
还有许多细节规格,如果感兴趣的读者可以参阅 手册
正如其设计目标所述,基本上不建议手工编写 Ninja 配置文件,但了解其书写方式和执行方法还是非常有趣的。
特别是能够输出依赖关系图这一功能,非常实用,仅凭这一点就能派上不少用场。
相比 Makefile,Ninja 在依赖关系解析上的速度要快得多,因此如果有机会,我一定会尝试使用它。


  1. Evan Martin. The Performance of Open Source Software Ninja ↩︎

  2. Design goals ↩︎

  3. dydepファイル仕様 ↩︎

豆蔵では共に高め合う仲間を募集しています!

recruit

具体的な採用情報はこちらからご覧いただけます。