このページでは、タスクベースのビルドシステムとその仕組み、タスクベースのシステムで起こり得る複雑さについて説明します。シェル スクリプトに続いて、タスクベースのビルドシステムは、ビルドの論理的な進化です。
タスクベースのビルドシステムについて
タスクベースのビルドシステムの場合、作業の基本単位はタスクです。各タスクは、あらゆる種類のロジックを実行できるスクリプトであり、タスクは、他のタスクをその前に実行する必要がある依存関係として指定します。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 ファイルの作成が含まれます。このコマンドセットは、ユーザー提供のプラグインで拡張して、あらゆる種類のロジックに対応しています。各タスクは、depends 属性を使用して依存するタスクも定義できます。図 1 に示すように、これらの依存関係により非巡回グラフが形成されます。
図 1. 依存関係を示す非巡回グラフ
ユーザーは、Ant のコマンドライン ツールにタスクを指定してビルドを実行します。たとえば、ユーザーが「ant dist
」と入力すると、Ant は次の処理を行います。
- 現在のディレクトリに
build.xml
という名前のファイルを読み込み、このファイルを解析し、図 1 に示すグラフ構造を作成します。 - コマンドラインで指定された
dist
という名前のタスクを探し、compile
という名前のタスクと依存関係があることを確認します。 compile
という名前のタスクを探し、init
という名前のタスクと依存関係があることを確認します。init
という名前のタスクを探し、依存関係がないことを確認します。init
タスクで定義されているコマンドを実行します。- タスクの依存関係がすべて実行されている場合、
compile
タスクで定義されているコマンドを実行します。 - タスクの依存関係がすべて実行されている場合、
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
コマンドライン ツールに渡すだけで、実行する必要があるすべてのタスクが決定されます。
Ant は、2000 年にリリースされた古いソフトウェアです。Maven や Gradle などの他のツールでは、ここ数年で Ant が改善されました。外部依存関係の自動管理や XML を使用しないクリーンな構文などの機能を追加することで、Ant に取って代わられました。しかし、こうした新しいシステムの性質は変わりません。エンジニアは、原則的かつモジュール方式の方法でビルド スクリプトをタスクとして記述し、タスクの実行とタスク間の依存関係を管理するためのツールを提供できます。
タスクベースのビルドシステムのダークサイド
これらのツールを使用すると、エンジニアは基本的に任意のスクリプトをタスクとして定義できるため、非常に強力で、考えられるほぼすべての処理を行うことができます。ただし、その機能には欠点があり、ビルド スクリプトが複雑になるにつれ、タスクベースのビルドシステムでの作業が難しくなることがあります。このようなシステムの問題点は、実際にエンジニアに大量の電力を供給し、システムに十分な電力を供給できないことです。システムがスクリプトの動作を認識していないため、ビルドステップのスケジュールと実行を慎重に行う必要があるため、パフォーマンスが低下します。また、各スクリプトが意図したとおりに動作していることをシステムが確認する方法がないため、スクリプトは複雑になり、最終的にはデバッグを必要とするものになる傾向があります。
ビルドステップの並列化が困難
最新の開発ワークステーションは非常に強力で、複数のコアによって複数のビルドステップを並行して実行できます。しかし、タスクベースのシステムでは、タスクの実行を並列化できるはずであっても、多くの場合、並列化できません。タスク A がタスク B とタスク C に依存しているとします。タスク B と C は互いに依存関係を持たないため、システムがタスク A に迅速に到達できるように、タスク B と C を同時に実行しても安全ですか?同じリソースに一切触れない場合はしかし、そうではないかもしれません。両方が同じファイルを使用してステータスを追跡し、同時に実行すると競合が発生する可能性があります。一般的に、システムがそれを把握する方法はありません。そのため、このような競合のリスク(希少ではあるがデバッグが非常に困難なビルド問題を引き起こす)か、またはビルド全体を単一プロセスの 1 つのスレッドで実行されるように制限する必要があります。 これは高性能のデベロッパー マシンの膨大な無駄になりかねず、ビルドを複数のマシンに分散する可能性を完全に排除します。
増分ビルドの実行が困難
優れたビルドシステムがあれば、エンジニアは信頼性の高い増分ビルドを実行できます。そのため、小さな変更でコードベース全体をゼロから再構築する必要はありません。これは、ビルドシステムが低速で、前述の理由でビルドステップを並列化できない場合に特に重要です。しかし残念なことに、この段階でもタスクベースのビルドシステムは苦労しています。タスクは何でも実行できるため、一般的に、タスクがすでに完了しているかどうかを確認する方法はありません。多くのタスクは、ソースファイルのセットを受け取り、コンパイラを実行してバイナリのセットを作成するだけです。そのため、基になるソースファイルが変更されていなければ、再実行する必要はありません。ただし、追加情報がないと、システムはこのことをはっきりと判断できません。おそらく、タスクが、変更された可能性のあるファイルをダウンロードするか、実行ごとに異なる可能性があるタイムスタンプを書き込む可能性があります。正確性を保証するために、システムは通常、各ビルド中にすべてのタスクを再実行する必要があります。一部のビルドシステムは、エンジニアがタスクを再実行する必要がある条件を指定できるようにすることで、増分ビルドを有効にしようとします。これが実現可能な場合もありますが、見かけより難しい問題であることも少なくありません。たとえば、他のファイルにファイルを直接インクルードできる C++ などの言語では、入力ソースを解析せずに変更を監視する必要があるファイルセット全体を特定することはできません。エンジニアは多くの場合、後回しになりかねず、このようなショートカットはまれな問題を引き起こす可能性があります。そうすべきでなくてもタスクの結果が再利用されるという問題です。これが頻繁に発生する場合、エンジニアはすべてのビルドの前にクリーンに実行する習慣を身に付け、新しい状態を取得するため、そもそも増分ビルドを行うという目的を完全に果たせません。タスクを再実行する必要があるタイミングの判断は驚くほど簡単です。このジョブは、人間よりも機械で処理する方が適切です。
スクリプトの管理とデバッグが困難
最後に、タスクベースのビルドシステムによって適用されるビルド スクリプトは、多くの場合、扱いにくいものです。多くの場合、精査はそれほど行われませんが、ビルド スクリプトはビルドされるシステムと同様のコードであり、バグが簡単に隠れてしまう場所です。タスクベースのビルドシステムを使用する際に頻繁に発生するバグの例を次に示します。
- タスク A は、特定のファイルを出力として生成するためにタスク B に依存します。タスク B のオーナーは、他のタスクがそれに依存していることに気付かないため、別の場所に出力を生成するように変更します。これは、誰かがタスク A を実行しようとして失敗することに気付くまで検出できません。
- タスク A はタスク B に依存します。タスク B はタスク C に依存し、タスク A が必要とする出力として特定のファイルを生成します。タスク B のオーナーは、タスク C にこれ以上依存する必要がないと判断します。これにより、タスク B はタスク C をまったく必要としていなくても、タスク A は失敗します。
- 新しいタスクの開発者が、ツールの場所や特定の環境変数の値など、タスクを実行しているマシンについて誤って仮定した場合。このタスクは自分のマシンでは機能しますが、別のデベロッパーがそれを試みると失敗します。
- インターネットからのファイルのダウンロードやビルドへのタイムスタンプの追加など、タスクに非決定的なコンポーネントが含まれている。現在では、ビルドを実行するたびに結果が変わる可能性があります。つまり、エンジニアがお互いの障害や自動ビルドシステムで発生した障害を常に再現して修正できるとは限りません。
- 複数の依存関係があるタスクは、競合状態を引き起こす可能性があります。タスク A がタスク B とタスク C の両方に依存しており、タスク B と C が同じファイルを変更した場合、タスク B とタスク C のどちらが先に終了するかによってタスク A の結果が異なります。
ここで説明するタスクベースのフレームワーク内に、こうしたパフォーマンス、正確性、メンテナンスに関する問題を解決する汎用的な方法はありません。エンジニアがビルド中に任意のコードを記述できる限り、ビルドを迅速かつ正確に実行するための十分な情報がシステムにあるとは限りません。この問題を解決するには、エンジニアの力を少し奪ってシステムが管理できるようにし、システムの役割をタスクの実行ではなくアーティファクトの生成として再概念化する必要があります。
このアプローチは、Blaze や Bazel などのアーティファクト ベースのビルドシステムの作成につながりました。