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

問題を報告 ソースを表示 Nightly · 8.3 · 8.2 · 8.1 · 8.0 · 7.6

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

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

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

  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++ のように、他のファイルがファイルを直接含めることができる言語では、入力ソースを解析せずに、変更を監視する必要があるファイルのセット全体を特定することはできません。エンジニアはショートカットを多用する傾向があり、その結果、タスクの結果が再利用されるべきでない場合でも再利用されるという、まれで厄介な問題が発生することがあります。このようなことが頻繁に発生すると、エンジニアはビルドのたびにクリーンを実行して新しい状態を取得するようになり、増分ビルドの目的が完全に失われます。タスクを再実行する必要があるタイミングを判断するのは、驚くほど微妙な作業であり、人間よりもマシンに適しています。

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

最後に、タスクベースのビルドシステムによって課せられるビルドスクリプトは、多くの場合、扱いにくいものです。ビルドスクリプトは、構築されるシステムと同様にコードであり、バグが潜みやすい場所です。タスクベースのビルドシステムを使用する際に非常によく発生するバグの例を次に示します。

  • タスク A は、特定のファイルを生成するためにタスク B に依存しています。タスク 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 などのアーティファクト ベースのビルドシステムが作成されました。