これまでのページを見ると、1 つのテーマが繰り返し登場します。つまり、独自のコードは管理が簡単ですが、その依存関係の管理ははるかに困難です。依存関係にはさまざまなものがあります。タスクに依存する場合(「リリースを完了としてマークする前にドキュメントを push する」など)や、アーティファクトに依存する場合(「コードをビルドするには最新バージョンのコンピュータ ビジョン ライブラリが必要」など)があります。コードベースの別の部分に内部依存関係がある場合もあれば、別のチーム(組織内またはサードパーティ)が所有するコードやデータに外部依存関係がある場合もあります。いずれにしても、「これを行うにはあれが必要」という考えは、ビルドシステムの設計で繰り返し発生するものであり、依存関係の管理はビルドシステムの最も基本的な作業と言えます。
モジュールと依存関係の処理
Bazel などのアーティファクトベースのビルドシステムを使用するプロジェクトは、一連のモジュールに分割され、モジュールは BUILD
ファイルを使用して相互の依存関係を表現します。これらのモジュールと依存関係を適切に整理すると、ビルドシステムのパフォーマンスとメンテナンスに必要な作業量の両方に大きな影響を与える可能性があります。
きめ細かいモジュールと 1:1:1 ルールの使用
アーティファクト ベースのビルドを構造化する場合、最初に生じる問題は、個々のモジュールに含める機能の量を決定することです。Bazel では、モジュールは、java_library
や go_binary
などのビルド可能な単位を指定するターゲットで表されます。極端な例として、1 つの BUILD
ファイルをルートに配置し、そのプロジェクトのすべてのソースファイルを再帰的にグルーピングすることで、プロジェクト全体を 1 つのモジュールに含めることができます。極端な場合、ほぼすべてのソースファイルを独自のモジュールにできます。その場合、各ファイルは、依存する他のすべてのファイルを BUILD
ファイルにリストする必要があります。
ほとんどのプロジェクトはこれらの極端な選択肢の間のどこかに位置し、選択にはパフォーマンスとメンテナンス性のトレードオフが伴います。プロジェクト全体に 1 つのモジュールを使用すると、外部依存関係を追加する場合を除き、BUILD
ファイルを変更する必要はありませんが、ビルドシステムは常にプロジェクト全体を一度にビルドする必要があります。つまり、ビルドの一部を並列化または分散することはできず、すでにビルドされている部分をキャッシュに保存することもできません。ファイルごとに 1 つのモジュールの場合はその逆です。ビルドシステムはビルドのステップのキャッシュとスケジューリングにおいて最大限の柔軟性がありますが、エンジニアは、どのファイルを参照するかを変更するたびに依存関係のリストを維持するためにより多くの労力を費やす必要があります。
正確な粒度は言語によって異なりますが(多くの場合、言語内でも異なります)、Google では、タスクベースのビルドシステムで通常作成するモジュールよりもはるかに小さいモジュールを優先する傾向があります。Google の一般的な本番環境バイナリは、数万のターゲットに依存することが多く、中規模のチームでもコードベース内に数百のターゲットを所有できます。パッケージ化の概念が組み込まれている Java などの言語では、通常、各ディレクトリに 1 つのパッケージ、ターゲット、BUILD
ファイルが含まれています(Bazel に基づく別のビルドシステムである Pants では、これを 1:1:1 ルールと呼びます)。パッケージ化規則が緩い言語では、BUILD
ファイルごとに複数のターゲットが定義されることがよくあります。
ビルド ターゲットを小さくすることで、分散ビルドを高速化し、ターゲットを再構築する頻度を下げることができるため、そのメリットが大規模に現れ始めます。テストが加わると、このメリットはさらに強くなります。よりきめ細かいターゲットにより、ビルドシステムは特定の変更の影響を受ける可能性のあるテストのサブセットのみを実行できるため、よりスマートに動作できるようになります。Google は、小さいターゲットを使用することによる体系的なメリットを信じているため、このデメリットを軽減するために、BUILD
ファイルを自動的に管理するツールに投資してデベロッパーの負担を軽減しています。
buildifier
や buildozer
などの一部のツールは、Bazel の buildtools
ディレクトリで使用できます。
モジュールの可視性の最小化
Bazel などのビルドシステムでは、各ターゲットに可視性(他のターゲットが依存する可能性があるターゲットを決定するプロパティ)を指定できます。限定公開ターゲットは、独自の BUILD
ファイル内でのみ参照できます。ターゲットは、明示的に定義された BUILD
ファイルのリスト内のターゲットに対して、または公開可視性の場合はワークスペース内のすべてのターゲットに対して、より広範な公開設定を許可できます。
ほとんどのプログラミング言語と同様に、通常は可視性をできるだけ最小限に抑えることをおすすめします。通常、Google のチームは、Google のどのチームでも利用できる広く使用されているライブラリを表すターゲットのみ公開します。コードを使用する前に他のチームとの調整が必要なチームは、ターゲットの公開設定としてカスタマー ターゲットの許可リストを維持します。各チームの内部実装ターゲットは、チームが所有するディレクトリのみに制限されます。また、ほとんどの BUILD
ファイルには、非公開ではないターゲットが 1 つだけあります。
依存関係の管理
モジュールは互いに参照できる必要があります。コードベースをきめ細かいモジュールに分割するデメリットは、それらのモジュール間の依存関係を管理する必要があることです(ただし、ツールを使用して自動化できます)。通常、これらの依存関係を表現することが、BUILD
ファイルのコンテンツの大部分になります。
内部依存関係
きめ細かいモジュールに分割された大規模なプロジェクトでは、ほとんどの依存関係が内部的なもの(同じソース リポジトリで定義され、ビルドされた別のターゲット)である可能性があります。内部依存関係は、ビルドの実行中にビルド済みアーティファクトとしてダウンロードされるのではなく、ソースからビルドされるという点で、外部依存関係とは異なります。また、内部依存関係に「バージョン」という概念はありません。ターゲットとそのすべての内部依存関係は、常にリポジトリ内の同じ commit/リビジョンでビルドされます。内部依存関係に関して慎重に扱う必要がある問題の 1 つは、伝播依存関係(図 1)の処理方法です。ターゲット A がターゲット B に依存し、これが共通ライブラリ ターゲット C に依存しているとします。ターゲット A はターゲット C で定義されたクラスを使用できますか?
図 1. 推移的依存関係
基盤となるツールに関しては、これに問題はありません。B と C はどちらもビルド時にターゲット A にリンクされるため、C で定義されたシンボルは A に認識されます。Bazel では長年この機能が許可されていましたが、Google の成長に伴い問題が発生し始めました。B がリファクタリングされ、C に依存する必要がなくなったとします。B の C への依存関係が削除された場合、A と、B への依存関係を介して C を使用している他のターゲットが破損します。実質的に、ターゲットの依存関係は公開コントラクトの一部となり、安全に変更できなくなりました。そのため、依存関係が時間とともに蓄積され、Google でのビルドが遅れ始めました。
Google は最終的に、Bazel に「厳格な伝播依存関係モード」を導入することで、この問題を解決しました。このモードでは、Bazel は、ターゲットがシンボルに直接依存せずにシンボルを参照しようとしているかどうかを検出します。そうである場合、エラーと、依存関係を自動的に挿入するために使用できるシェルコマンドを返して失敗します。この変更を Google のコードベース全体にロールアウトし、数百万ものビルド ターゲットをすべてリファクタリングして依存関係を明示的にリストすることは、数年にわたる取り組みでしたが、その価値は十分ありました。ターゲットに不要な依存関係が減ったため、ビルドが大幅に高速化されました。エンジニアは、依存するターゲットが破損する心配をすることなく、不要な依存関係を削除できます。
通常どおり、厳格な推移的依存関係を適用するとトレードオフが伴います。ビルドファイルが冗長になっていました。頻繁に使用されるライブラリを偶発的に pull するのではなく、多くの場所に明示的にリストする必要があり、エンジニアは BUILD
ファイルに依存関係を追加するのに多くの労力を費やす必要があるためです。Google はその後、不足している多くの依存関係を自動的に検出し、デベロッパーの介入なしで BUILD
ファイルに追加することで、この負担を軽減するツールを開発しました。ただし、このようなツールがなくても、コードベースのスケーリングに伴うトレードオフは十分に価値があると判断しています。BUILD
ファイルに依存関係を明示的に追加するのは 1 回限りの費用ですが、暗黙的な伝播依存関係を処理すると、ビルド ターゲットが存在する限り、継続的な問題が発生する可能性があります。Bazel はデフォルトで Java コードに厳格な伝播依存関係を適用します。
外部依存関係
依存関係が内部的でない場合は、外部にする必要があります。外部依存関係は、ビルドシステムの外部でビルドされ、保存されるアーティファクトの依存関係です。依存関係はアーティファクト リポジトリ(通常はインターネット経由でアクセス)から直接インポートされ、ソースからビルドされるのではなく、そのまま使用されます。外部依存関係と内部依存関係の最大の違いの一つは、外部依存関係にはバージョンがあり、それらのバージョンがプロジェクトのソースコードとは独立して存在することです。
自動と手動の依存関係管理
ビルドシステムでは、外部依存関係のバージョンを手動または自動で管理できます。手動で管理する場合、buildfile には、アーティファクト リポジトリからダウンロードするバージョンが明示的にリストされます。多くの場合、1.1.4
などのセマンティック バージョン文字列が使用されます。自動的に管理する場合は、ソースファイルで許容されるバージョンの範囲を指定し、ビルドシステムは常に最新バージョンをダウンロードします。たとえば、Gradle では依存関係のバージョンを「1.+」として宣言し、メジャー バージョンが 1 であれば、依存関係のマイナー バージョンまたはパッチ バージョンをすべて許可するように指定できます。
自動管理される依存関係は小規模なプロジェクトでは便利ですが、通常、規模が大きいプロジェクトや複数のエンジニアが作業しているプロジェクトでは、災害の原因になります。自動的に管理される依存関係の問題は、バージョンが更新されるタイミングを制御できないことです。外部の関係者が破壊的な更新を行わないことを保証する方法はありません(セマンティック バージョニングを使用していると主張している場合でも同様です)。そのため、ある日正常に動作していたビルドが、翌日には動作しなくなる可能性があります。変更内容を簡単に検出したり、動作状態にロールバックしたりすることはできません。ビルドが破綻しなくても、微妙な動作やパフォーマンスの変化が起きて、追跡できない場合があります。
一方、手動で管理される依存関係はソース管理の変更を必要とするため、簡単に検出してロールバックできます。また、古いバージョンのリポジトリをチェックアウトして、古い依存関係でビルドすることもできます。Bazel では、すべての依存関係のバージョンを手動で指定する必要があります。中程度の規模でも、手動バージョン管理のオーバーヘッドは、安定性を確保するために十分な価値があります。
1 バージョンのルール
通常、ライブラリのバージョンごとに異なるアーティファクトは異なるため、理論的には、同じ外部依存関係の異なるバージョンをビルドシステムで異なる名前で宣言することはできないという理由はありません。これにより、各ターゲットは使用する依存関係のバージョンを選択できます。これにより、実際には多くの問題が発生するため、Google はコードベース内のすべてのサードパーティの依存関係に対して、厳格な 1 つのバージョン ルールを適用しています。
複数のバージョンを許可することの最大の問題は、ダイヤモンド依存関係の問題です。ターゲット A がターゲット B と外部ライブラリの v1 に依存しているとします。ターゲット B をリファクタリングして同じ外部ライブラリの v2 への依存関係を追加すると、ターゲット A は、同じライブラリの 2 つの異なるバージョンに暗黙的に依存することになるため、機能しなくなります。ターゲットのユーザーがすでに別のバージョンに依存している可能性があるため、ターゲットから複数のバージョンがあるサードパーティ ライブラリに新しい依存関係を追加することは、実際には安全ではありません。1 バージョン ルールに従うと、この競合は発生しません。ターゲットがサードパーティ ライブラリへの依存関係を追加する場合、既存の依存関係はすでに同じバージョンにあるため、問題なく共存できます。
推移的な外部依存関係
外部依存関係の推移的依存関係に対処することは、特に困難な場合があります。Maven Central などの多くのアーティファクト リポジトリでは、アーティファクトがリポジトリ内の他のアーティファクトの特定のバージョンへの依存関係を指定できます。Maven や Gradle などのビルドツールは、デフォルトで各伝播依存関係を再帰的にダウンロードします。つまり、プロジェクトに 1 つの依存関係を追加すると、合計で数十個のアーティファクトがダウンロードされる可能性があります。
これは非常に便利です。新しいライブラリの依存関係を追加するときに、そのライブラリの各伝播依存関係を追跡してすべて手動で追加するのは大変な作業です。ただし、大きな欠点もあります。異なるライブラリが同じサードパーティ ライブラリの異なるバージョンに依存する可能性があるため、この戦略は必然的に 1 つのバージョンのルールに違反し、ダイアモンド依存関係の問題につながります。ターゲットが、同じ依存関係の異なるバージョンを使用する 2 つの外部ライブラリに依存している場合、どちらが取得されるかはわかりません。つまり、外部依存関係を更新すると、新しいバージョンで依存関係の競合するバージョンを pull し始めると、コードベース全体で無関係なエラーのように見える可能性があります。
このため、Bazel は推移的な依存関係を自動的にダウンロードしません。ありがたいことに、解決策はありません。Bazel の代替手段は、リポジトリの外部依存関係をすべて記載し、リポジトリ全体でその依存関係に使用される明示的なバージョンをリストしたグローバル ファイルを要求することです。幸い、Bazel には、一連の Maven アーティファクトの伝播依存関係を含むこのようなファイルを自動的に生成できるツールが用意されています。このツールを一度実行すると、プロジェクトの初期 WORKSPACE
ファイルを生成できます。その後、このファイルを手動で更新して、各依存関係のバージョンを調整できます。
ここでも、利便性とスケーラビリティのどちらかを選択する必要があります。小規模なプロジェクトでは、自己参照型の依存関係の管理を気にせず、自動的な自己参照型の依存関係を使用できる場合があります。この戦略は、組織とコードベースの規模が大きくなるにつれて魅力が薄れ、競合や予期しない結果が頻繁に発生するようになります。大規模な場合、依存関係を手動で管理するコストは、依存関係の自動管理によって発生する問題に対処するコストよりもはるかに低くなります。
外部依存関係を使用したビルド結果のキャッシュ保存
外部依存関係は、多くの場合、安定版のライブラリをリリースするサードパーティによって提供されます。ソースコードが提供されないこともあります。一部の組織では、独自のコードの一部をアーティファクトとして利用できるようにし、他のコードが内部依存関係ではなくサードパーティとしてそのコードに依存できるようにすることもあります。アーティファクトのビルドは遅いがダウンロードは速い場合、理論的にはビルドを高速化できます。
ただし、オーバーヘッドと複雑さも大きくなります。各アーティファクトのビルドとアーティファクト リポジトリへのアップロードは誰かが担当する必要があり、クライアントは、常に最新バージョンを使用していることを確認する必要があります。また、システムのさまざまな部分がリポジトリ内のさまざまなポイントからビルドされ、ソースツリーの一貫したビューがなくなるため、デバッグが非常に困難になります。
アーティファクトのビルドに時間がかかる問題を解決するより良い方法は、前述のようにリモート キャッシュをサポートするビルドシステムを使用することです。このようなビルドシステムでは、すべてのビルドの結果のアーティファクトがエンジニア間で共有される場所に保存されます。したがって、デベロッパーが最近他の人によってビルドされたアーティファクトに依存している場合、ビルドシステムはそのアーティファクトをビルドする代わりに自動的にダウンロードします。これにより、アーティファクトに直接依存することによるパフォーマンス上のメリットがすべて得られますが、ビルドの一貫性は、常に同じソースからビルドされた場合と変わりません。これは Google で内部的に使用されている戦略であり、Bazel はリモート キャッシュを使用するように構成できます。
外部依存関係のセキュリティと信頼性
サードパーティ ソースのアーティファクトに依存することは、本質的にリスクがあります。サードパーティ ソース(アーティファクト リポジトリなど)が停止すると、外部依存関係をダウンロードできず、ビルド全体が停止する可能性があるため、可用性にリスクが生じます。セキュリティ リスクもあります。サードパーティ システムが攻撃者に侵害された場合、攻撃者は参照されているアーティファクトを独自の設計のものに置き換え、任意のコードをビルドに挿入できるようになります。どちらの問題も、依存するアーティファクトを管理するサーバーにミラーリングし、Maven Central などのサードパーティ アーティファクト リポジトリへのビルドシステムのアクセスをブロックすることで軽減できます。トレードオフとして、これらのミラーの維持には労力とリソースが必要になるため、使用するかどうかはプロジェクトの規模によって決まります。また、各サードパーティ アーティファクトのハッシュをソース リポジトリで指定することを義務付けることで、オーバーヘッドをほとんどかけずにセキュリティの問題を完全に防ぐこともできます。これにより、アーティファクトが改ざんされるとビルドが失敗します。この問題を完全に回避する別の方法として、プロジェクトの依存関係をベンダーに依存させることもできます。プロジェクトが依存関係をベンダーリングする場合、プロジェクトのソースコードとともに、ソースまたはバイナリとしてソース管理にチェックインします。これは実質的に、プロジェクトのすべての外部依存関係が内部依存関係に変換されることを意味します。Google は社内でこの手法を使用して、Google 全体で参照されているすべてのサードパーティ ライブラリを、Google のソースツリーのルートにある third_party
ディレクトリにチェックします。ただし、これは Google でのみ機能します。これは、Google のソース管理システムが非常に大規模な Monorepo を処理するようにカスタム構築されているためです。そのため、すべての組織でベンダーリングが選択できるとは限りません。