ミローネ言語の開発にビルドツールninjaを使いはじめて、それなりに時間がたったので、雑感を記事に書いておく。
モチベーション
ミローネ言語 (自作言語) の開発では複数のプログラミング言語を使うので、ビルドやテストの作業が複雑になっている。 dotnetコマンド1つでは完了しない。 特に100個ぐらいあるテストプロジェクトのそれぞれでCコンパイラを起動するのは時間がかかるので、ビルドツールを使って差分更新したくなった。
make vs. ninja
代表的なビルドツールは GNU make だが、ミローネ言語では ninja というビルドツールを使った。(※後で別のものに変えるかも)
- ninja-build/ninja: a small build system with a focus on speed: GitHubリポジトリ、バイナリの配布元
ninjaを選んだ最大の理由は設計思想に興味があり、それの理解を深めたかったから。 ninjaの最初の作者による振り返り記事やマニュアルを参照:
- Tech Notes: The Success and Failure of Ninja (2020-05-12, ninja原作者による振り返り)
- The Ninja build system: マニュアル
マニュアルの Philosophical overview の部分にあるようにninjaはビルドツール界の「アセンブラ」を指向しているらしい。 つまり、機能を最小限にすることで性能を保つこと、ユーザーが直接ninjaを使うのではなく他のツールがninjaをバックエンドとして使うこと。
例えばninjaでは複数のファイルを *.ext
のような形でまとめて指定する機能がない。
複数のファイルを参照するにはファイル名を列挙する必要がある。
当然ながら手動で列挙するのは厳しいので、ninjaに渡すビルドスクリプト (build.ninja
) を何らかの方法で生成することになる。
ビルド手順を「build.ninja を生成する → ninjaを実行する」という2段階に分けることで、ninja自身の複雑化・低速化を防ぐ、という感じらしい。
また、makeよりよい点として、コマンド自体が依存関係に含まれていることがある。 例えばmakeでは、ビルドを実行した後、ビルドコマンド自体を書き換えて再ビルドしても「更新済み」といわれてしまう。 ninjaはビルドの依存関係にコマンドを含めているので、このケースでも再ビルドが起こる。 この挙動はビルドスクリプトを試行錯誤しているときに便利だった。
ビルドスクリプトの生成
ninjaをバックエンドとして使うツールはいくつかあるが、目的のユースケースに合致するものが見当たらなかった。
ミローネ言語では F# で書いた小さいツールを使って build.ninja
を生成している。
ビルドコマンドなどはテンプレートファイルに書いておき、そこにあるプレイスホルダーに探索したファイル名のリストを埋めたり、依存関係を増やしたり、という処理をしている。
build.ninja
の生成 → ninja
と2つのコマンドを打つのはめんどくさいので、そこはmakeを使って省略している。
こんな感じ:
default: build.ninja
ninja
また、ninjaのような、それほど一般的ではないツールは事前にインストールしないといけない。 インストール手順が煩雑化するのが気になる。
そこで make
の際に自動でninjaが入るようにしてみた。
ninjaはバイナリを1個ダウンロードしてくるだけなのでやりやすい。
こういう感じ:
default: bin/ninja build.ninja
bin/ninja
bin/ninja:
<ninjaのバイナリをダウンロードしてbin/ninjaに置くコマンド>
ただし、make経由ではninja側で定義したビルドターゲットを指定できないので、ninjaコマンドを直接使ってしまいがち。
その場合に build.ninja
の生成を忘れやすい。
これもおそらく、ninjaを直接使うよりninjaをバックエンドとするツールを使ったほうがよい理由の1つかもしれない。
その他のトピック
ninjaは速い?
ninjaのウリの1つとして速さがあるが、このリポジトリの規模だとビルドツール自体の消費する時間は一瞬なので関係ない。 デフォルトで並列なのは便利かもしれない。
ninja in ninja
ミローネ言語のコンパイラはCのコードを出力する。 いままでは標準出力にソースコードを出すだけだったが、分割コンパイルができなくて不便だったので、複数のファイルに分けて出力することにした。
ミローネ言語のコンパイラを動かしてみないとどのファイルが生成されるか分からないため、ninjaのビルドの依存関係を事前に生成するのが難しくなった。 (暗黙の依存関係があるだけならdyndepでできるかもしれないが、各 .c ファイルをコンパイルして .o を作るためのbuild文を生成することに相当する記述が思いつかなかった。)
そういうわけでミローネ言語のコンパイルの後に、生成されたファイルのリストからninjaのビルドスクリプトを生成して、またninjaを起動するという手法を試したらうまくいった。 計測したわけではないが、ここにninjaの速さ(オーバーヘッドの少なさ)が生きている……かもしれない。
注意点としては、同一のビルドディレクトリで複数のninjaプロセスを起動すると壊れること。
内部的に使っているログファイル(.ninja_log
)を複数のプロセスから読み書きするとうまく動かないので、それらが異なるディレクトリに配置されるように、builddir
変数に異なる値を指定しておく必要がある。
出力のないビルド依存関係
F# のプロジェクトをビルドするとき、それが依存しているパッケージは自動でrestore(復元)されるが、その処理に少し時間がかかる。
そのため自動でrestoreする機能はキャンセルして (--no-restore
)、依存パッケージが増減したタイミングでrestoreコマンドを実行する (dotnet restore
) ほうが速い。
これもninjaのビルドスクリプトに書いておけば、必要なときだけrestoreされるので時間の節約になる。
restore操作の「出力」は、dotnetコマンドがパッケージをどこに置くかによるので、明示的に書きたくない。 しかしninjaでは出力のないビルド文は書けない。(できたとしてもタイムスタンプを比較できないため、ビルドをスキップするタイミングがない。)
これはタイムスタンプを記録しておくだけのダミーのファイルを出力に指定しておくことで回避できる。
rule dotnet_restore
command = dotnet restore && touch $out
restat = 1
build dotnet_restore.timestamp: dotnet_restore MyProject.fsproj
ついでにphonyビルド文を用意しておくとコマンドから直接使うとき便利。
ninja dotnet_restore
と書ける。
build dotnet_restore: phony dotnet_restore.timestamp
複数の出力を持つビルド依存関係
makeとninjaの大きな違いの1つに、1つのコマンドが複数のファイルを出力するケースをビルド依存関係に記述できる点がある。
これはいまのところ利用していない。
(ミローネ言語のコンパイラが複数のファイルを出力するようになったときに、一時的に利用したが、前述の通り .ninja
を生成する方針に変わったので消えた。)
シェルスクリプトがつらい
make vs. ninja はさておき、シェルスクリプトでコマンドを書くのが厳しい。 何らかのスクリプト言語を使ったほうがいいとは思うが、それを事前にインストールせよというのもまたつらい。 (それから言語やランタイムのバージョン、依存しているライブラリのバージョン……)
電波: シングルバイナリをダウンロードしただけで動くスクリプト言語があったらいいのかもしれない?
おわりに
全体的にふわふわしているが、ここで終わりにする。 ミローネ言語側でdotnetのようなビルドツールを用意したほうがいいんだけど、しばらくはninjaを使っていくことになりそう。