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