タスクベースのビルドシステム

このページでは、タスクベースのビルドシステム、その仕組み、タスクベースのシステムで発生する可能性のある複雑な問題について説明します。シェル スクリプトの次に論理的に進化するのは、タスクベースのビルドシステムです。

タスクベースのビルドシステムについて

タスクベースのビルドシステムでは、作業の基本単位はタスクです。各タスクは、あらゆる種類のロジックを実行できるスクリプトです。タスクは、その前に実行する必要がある他のタスクを依存関係として指定します。現在使用されている主要なビルドシステムのほとんど(Ant、Maven、Gradle、Grunt、Rake など)はタスクベースです。最新のビルドシステムのほとんどでは、シェル スクリプトではなく、ビルドの実行方法を記述するビルドファイルをエンジニアが作成する必要があります。

Ant マニュアルの例を次に示します。 Ant manual:

<project name="MyProject" default="di>st&q<uot; basedi>r="."
   description
    < simple exam>ple <build file
   /description
   !-- set globa>l pr<operties for this build --
   prope>rty <name="src" location="src>&quo<t;/
   property name="build">; loc<ation="build&>quot;/<
   property name="dist>"< locati>on=&qu<ot;dist"/

   target name="init"
     !-- Cr>eate t<he time stamp --
    > tst<amp/
  >   !<-- Create the build directory structure used by compile --
     mkdir dir=&q>uot;${<build}"/
   /target
   target name="compile&>quot; <depends="init"
       descripti>on=&<quot;co>mpil<e the source"
     !-- Compile the Java code from ${src} into ${build} --
    > javac< srcdir="${src}" destdir=">;${bui<ld}"/
   /target
  > targe<t name="dist" depends="compile"
       description=>"<generate the distribution"
     !-- Create the distribution dire>ctor<y --
  >   m<kdir dir="${dist}/lib"/
     !-- Put ev>erythi<ng in ${build} into the MyProject-${DSTAMP}.jar file ->-
    < jar jarfile="${d>ist}/l<ib/MyProject-${DSTAMP>}.ja<r"> <basedir=>&quot;${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 では、 ターゲットという単語はタスクを表し、タスクという単語は コマンドを表します)。各タスクは、Ant で定義されたコマンドのリストを実行します。このリストには、ディレクトリの作成と削除、javac の実行、JAR ファイルの作成が含まれます。このコマンドセットは、ユーザーが提供するプラグインによって拡張して、あらゆる種類のロジックに対応できます。各タスクは、depends 属性を使用して、依存するタスクを定義することもできます。図 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 によって実行されるコードは、次のシェル スクリプトと同等になります。

./createTimestamp.sh
mkdir build/
javac src/* -d build/
mkdir -p dist/lib/
jar cf dist/lib/MyProject-$(date --iso-8601).jar build/*

構文を削除すると、ビルドファイルとビルド スクリプトは実際にはそれほど違いはありません。しかし、これを行うことで多くのメリットが得られます。他のディレクトリに新しいビルドファイルを作成して、それらをリンクできます。既存のタスクに依存する新しいタスクを、任意の方法で簡単に追加できます。ant コマンドライン ツールに 1 つのタスクの名前を渡すだけで、実行する必要があるすべての処理をツールが判断します。

Ant は古いソフトウェアで、2000 年に最初にリリースされました。Maven や Gradle などの他のツールは、その後の数年間で Ant を改良し、外部依存関係の自動管理や XML を使用しないクリーンな構文などの機能を追加することで、Ant に代わるものとなりました。ただし、これらの新しいシステムの性質は変わりません。エンジニアは、タスクとして原則的かつモジュール化された方法でビルド スクリプトを作成し、それらのタスクの実行とタスク間の依存関係の管理を行うツールを提供できます。

タスクベースのビルドシステムのデメリット

これらのツールを使用すると、エンジニアは任意のスクリプトをタスクとして定義できるため、非常に強力で、想像できることはほぼすべて実行できます。ただし、この機能には欠点があり、ビルド スクリプトが複雑になるにつれて、タスクベースのビルドシステムの操作が難しくなる可能性があります。このようなシステムの問題は、エンジニアに権限を与えすぎ、システムに十分な権限を与えないことです。 システムはスクリプトの内容を把握していないため、ビルドステップのスケジュール設定と実行の方法を慎重に行う必要があり、パフォーマンスが低下します。また、各スクリプトが意図したとおりに動作していることをシステムが確認する方法がないため、スクリプトが複雑になり、デバッグが必要になることがあります。

ビルドステップの並列化の難しさ

最新の開発ワークステーションは非常に強力で、複数のコアを備えており、複数のビルドステップを並行して実行できます。しかし、タスクベースのシステムでは、タスクの実行を並列化できないことがよくあります。タスク A がタスク B とタスク C に依存しているとします。タスク B とタスク C は相互に依存していないため、同時に実行して、システムがタスク A により迅速に到達できるようにしても安全でしょうか。同じリソースにアクセスしない場合は、安全かもしれません。ただし、両方が同じファイルを使用してステータスを追跡し、同時に実行すると競合が発生する可能性があります。一般的に、システムがこれを把握する方法はないため、このような競合のリスクを負うか(まれですが、デバッグが非常に難しいビルドの問題につながります)、単一プロセスで単一スレッドで実行するようにビルド全体を制限する必要があります。 これは強力な開発マシンを無駄にする可能性があり、複数のマシンにビルドを分散する可能性を完全に排除します。

増分ビルドの実行の難しさ

優れたビルドシステムを使用すると、エンジニアは信頼性の高い増分ビルドを実行できます。これにより、小さな変更があっても、コードベース全体を最初から再ビルドする必要がなくなります。前述の理由により、ビルドシステムが遅く、ビルドステップを並列化できない場合は特に重要です。しかし残念ながら、タスクベースのビルドシステムでは、この点でも問題があります。タスクはあらゆる処理を実行できるため、タスクがすでに完了しているかどうかを確認する方法はありません。多くのタスクは、一連のソースファイルを受け取り、コンパイラを実行して一連のバイナリを作成します。そのため、基盤となるソースファイルが変更されていない場合は、再実行する必要はありません。ただし、追加情報がないと、システムはこれを確実に判断できません。タスクが変更された可能性のあるファイルをダウンロードしたり、実行ごとに異なるタイムスタンプを書き込んだりする可能性があるためです。正確性を保証するため、通常、システムはビルドごとにすべてのタスクを再実行する必要があります。一部のビルドシステムでは、タスクを再実行する必要がある条件をエンジニアが指定できるようにすることで、増分ビルドを有効にしようとしています。これは実現可能な場合もありますが、多くの場合、見た目よりもはるかに難しい問題です。たとえば、他のファイルでファイルを直接含めることができる C++ などの言語では、入力ソースを解析せずに、変更を監視する必要があるファイルのセット全体を特定することはできません。エンジニアは多くの場合、ショートカットを使用しますが、このショートカットにより、タスクの結果を再利用すべきでない場合でも再利用されるという、まれで煩わしい問題が発生する可能性があります。このようなことが頻繁に発生すると、エンジニアはビルドのたびに clean を実行して新しい状態を取得するようになり、増分ビルドの目的が完全に失われます。タスクを再実行する必要があるタイミングを判断するのは意外と難しく、人間よりもマシンに適した作業です。

スクリプトの保守とデバッグの難しさ

最後に、タスクベースのビルドシステムによって課せられるビルド スクリプトは、操作が難しいことがよくあります。ビルド スクリプトは、多くの場合、精査されることはありませんが、ビルド対象のシステムと同様にコードであり、バグが潜みやすい場所です。 タスクベースのビルドシステムを使用する場合に非常に一般的なバグの例を次に示します。

  • タスク A は、特定のファイルを生成するためにタスク B に依存しています。タスク B の所有者は、他のタスクがタスク B に依存していることに気づかず、別の場所に生成するように変更します。これは、誰かがタスク A を実行しようとして失敗するまで検出できません。
  • タスク A はタスク B に依存し、タスク B はタスク C に依存しています。タスク C は、タスク A で必要な特定のファイルを生成しています。タスク B の所有者は、タスク C に依存する必要がなくなったと判断します。これにより、タスク B がタスク C をまったく気にしない場合でも、タスク A が失敗します。
  • 新しいタスクの開発者は、ツールの場所や特定の環境変数の値など、タスクを実行するマシンについて誤った想定をしています。タスクは自分のマシンでは動作しますが、別のデベロッパーが試すと失敗します。
  • タスクには、インターネットからファイルをダウンロードしたり、ビルドにタイムスタンプを追加したりするなど、非決定的なコンポーネントが含まれています。ビルドを実行するたびに結果が異なる可能性があるため、エンジニアは、他のエンジニアの失敗や自動ビルドシステムで発生した失敗を再現して修正できないことがあります。
  • 複数の依存関係を持つタスクでは、競合状態が発生する可能性があります。タスク A がタスク B とタスク C の両方に依存し、タスク B とタスク C の両方が同じファイルを変更する場合、タスク A の結果は、タスク B とタスク C のどちらが先に完了するかによって異なります。

ここで説明したタスクベースのフレームワークでは、これらのパフォーマンス、正確性、保守性の問題を解決する汎用的な方法はありません。エンジニアがビルド中に実行される任意のコードを作成できる限り、システムはビルドを常に迅速かつ正確に実行できるだけの情報を取得できません。この問題を解決するには、エンジニアの権限を一部取り上げ、システムに権限を戻し、システムの役割をタスクの実行ではなくアーティファクトの生成として再定義する必要があります。

このアプローチにより、Blaze や Bazel などのアーティファクトベースのビルドシステムが作成されました。