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

このページでは、タスクベースのビルドシステムの概要と仕組み、タスクベースのシステムで発生しうる複雑な点について説明します。タスクベースのビルドシステムは、シェル スクリプトに続き、ビルドを論理的に進化させたものです。

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

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

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>

ビルドファイルは XML で記述され、ビルドに関するシンプルなメタデータとタスクのリスト(XML の <target> タグ)を定義します。(Ant は「ターゲット」という単語を使用してタスクを表し、「タスク」という単語はコマンドを指します)。各タスクは、Ant によって定義される実行可能なコマンドのリストを実行します。実行対象となるコマンドには、ディレクトリの作成と削除、javac の実行、JAR ファイルの作成などがあります。このコマンドセットは、ユーザー提供のプラグインによって拡張され、あらゆる種類のロジックに対応することができます。各タスクは、依存する属性を使用して、依存するタスクを定義することもできます。図 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 を使用しないクリーンな構文などの機能を追加することで、これに置き換わりました。しかし、これらの新しいシステムの性質は変わりません。エンジニアは、原則に基づいたモジュール方式でタスクとしてビルド スクリプトを記述し、それらのタスクを実行し、タスク間の依存関係を管理するためのツールを提供できるからです。

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

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

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

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

増分ビルドの実行に関する問題

優れたビルドシステムがあれば、エンジニアは信頼性の高い増分ビルドを実行できるため、小さな変更でコードベース全体をゼロから再構築する必要がなくなります。これは、ビルドシステムの動作が遅く、上記の理由でビルドステップを並列化できない場合に特に重要です。残念ながら タスクベースのビルドシステムもここでは苦労しますタスクには何でも可能なため、一般的にはタスクがすでに完了しているかどうかを確認する方法はありません。多くのタスクは、ソースファイルのセットを受け取り、コンパイラを実行して一連のバイナリを作成するだけです。そのため、基盤となるソースファイルが変更されていなければ、再実行する必要はありません。しかし、追加情報がなければ、システムはそれをはっきりと伝えることはできません。タスクが変更した可能性のあるファイルをダウンロードしたり、実行ごとに異なるタイムスタンプを書き込む可能性があります。正確性を確保するために、システムは通常、ビルドごとにすべてのタスクを再実行する必要があります。一部のビルドシステムでは、タスクを再実行する必要がある条件をエンジニアに指定できるようにすることで、増分ビルドを有効にしようとします。これは可能な場合もありますが、多くの場合、見かけよりもはるかに厄介な問題です。たとえば、他のファイルによってファイルを直接インクルードできる C++ などの言語では、入力ソースを解析することなく、変更を監視する必要があるファイルセット全体を特定することはできません。エンジニアはしばしば近道に直面します。このような近道は、タスクの結果が本来はあっても再利用されてしまう、めったに発生しない問題につながる可能性があります。このようなことが頻繁に発生すると、エンジニアは毎回ビルドの前にクリーンを実行して最新の状態を取得する習慣を身に付けて、そもそも増分ビルドを行うという目的を完全に失ってしまいます。タスクの再実行が必要なタイミングを判断するのは驚くほど繊細であり、人間よりも機械のほうが適切に処理できます。

スクリプトのメンテナンスとデバッグが難しい

最後に、タスクベースのビルドシステムによって適用されるビルド スクリプトは、操作が難しいこともよくあります。多くの場合、ビルド スクリプトはビルド中のシステムと同様のコードであり、バグが見つけにくい場所になっています。タスクベースのビルドシステムを扱うときによく見られるバグの例を以下に示します。

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

ここで説明するタスクベースのフレームワークには、パフォーマンス、正確性、保守性の問題を解決する汎用的な方法がありません。エンジニアがビルドで実行される任意のコードを記述できる限り、システムがビルドを常に迅速かつ正しく実行できるように十分な情報を確保することはできません。この問題を解決するには、エンジニアの手を借りてその機能をシステムに持ち戻し、タスクの実行ではなく、アーティファクトの生成としてのシステムの役割を再概念化する必要があります。

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