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

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

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

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

buildfile は 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/*

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

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

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

これらのツールでは、基本的にエンジニアが任意のスクリプトをタスクとして定義できるため、非常に強力で、想像できるほとんどのことを実行できます。しかし、その機能には欠点があり、ビルド スクリプトが複雑になるにつれ、タスクベースのビルドシステムでの作業が難しくなることがあります。このようなシステムの問題は、エンジニアに過剰な権限を与え、システムに十分な権限を与えない結果になるということです。システムはスクリプトが何をしているのかを認識していないため、ビルドステップのスケジューリングと実行方法が非常に保守的になり、パフォーマンスが低下します。また、各スクリプトが想定どおりに動作していることをシステムが確認する方法がないため、スクリプトは複雑になり、最終的にはデバッグが必要になります。

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

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

増分ビルドの実行が困難

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

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

最後に、タスクベースのビルドシステムによって適用されるビルド スクリプトは、多くの場合、扱いにくいものです。多くの場合、あまり精査されることはあまりありませんが、ビルド スクリプトはビルドされるシステムと同様のコードであり、バグが簡単に隠れてしまう場所です。タスクベースのビルドシステムを使用する際に頻繁に発生するバグの例を次に示します。

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

ここに示すタスクベースのフレームワーク内に、こうしたパフォーマンス、正確性、メンテナンスに関する問題を解決する汎用的な方法はありません。エンジニアがビルド中に実行される任意のコードを記述できる限り、システムは、ビルドを常に迅速かつ正確に実行できる十分な情報を保持できません。この問題を解決するには、エンジニアの負担を軽減し、システムに負担を移行し、システムの役割をタスクの実行ではなくアーティファクトの生成として再定義する必要があります。

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