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

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

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

タスクベースのビルドシステムの場合、基本的な作業単位はタスクです。各タスクは、あらゆる種類のロジックを実行できるスクリプトであり、タスクは前に実行する必要がある依存関係として他のタスクを指定します。現在使用されている主要なビルドシステム(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/*

構文が削除されると、ビルドファイルとビルド スクリプトはそれほど変わりません。しかし、Google はこの作業によってすでに多くのものを得ています。別のディレクトリに新しいビルドファイルを作成してリンクできます。既存のタスクに依存する新しいタスクは、任意で複雑な方法で簡単に追加できます。1 つのタスクの名前を ant コマンドライン ツールに渡すだけで、実行する必要があるすべてのタスクが決定されます。

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

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

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

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

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

増分ビルドの実行が困難

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

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

最後に、タスクベースのビルドシステムで強制されるビルド スクリプトは、しばしば作業が困難です。多くの場合、ビルド スクリプトはそれほど精査されませんが、構築中のシステムと同様のコードであり、バグが隠れやすい場所です。タスクベースのビルドシステムを扱う際によく見られるバグの例を以下に示します。

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

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

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