TechTargetは、Javaのビルドツール「Gradle」に関する記事を公開した。依存関係管理の問題への対処法をチュートリアル形式で紹介している。
2023年10月18日(米国時間)、TechTargetは「Gradle」に関する記事を公開した。
GradleはJavaのエコシステムの中でも人気のビルドツールだ。「Maven」と同じ様に、“慣例に基づいてビルドする”というアプローチができる上、「Make」や「Ant」のように、ビルドシステムの一部として任意の操作を実行できる「命令型」のビルドも選べる。
ただし注意点もある。Gradleを使う場合、「自分のビルドに対してどの程度の責任を負うのか」を慎重に選択する必要がある。Gradleの柔軟性は、サポートする開発チームの能力に依存するからだ。そこで本稿では、著者が遭遇した“依存関係管理の問題”を取り上げる。一般的で、保守が可能な、作業が簡単な方法に統合することで、対応しなければならない依存関係とそのバージョンの量を減らしてみようと思う。
なお、一言に“依存関係”と言ってもその範囲は広い。本稿で扱うのは「依存関係管理を一元化し、バージョンカタログを使用して他のプロジェクトに提供する方法」に関するものとする。
簡単な例を挙げる。「JUnit 5」のユニットテストフレームワークを使用する2つのプロジェクトがある。それぞれ「build.gradle.kts」ファイルがある。1つ目のプロジェクト(プロジェクト A)は以下のようになっている。
//プロジェクト A dependencies { implementation("com.mycorp:core-lib:1.19.0") testImplementation("org.junit.jupiter:junit-jupiter-api:5.9.3") testImplementation("org.junit.jupiter:junit-jupiter-engine:5.9.3") testImplementation("org.junit.jupiter:junit-jupiter-params:5.9.3") }
もう1つのプロジェクト(プロジェクト B)は恐らく最近できたもの(編集注:バージョンが新しいため)で、以下のようになっている。
//プロジェクト B dependencies { implementation("com.mycorp:core-lib:1.22.2") testImplementation("org.junit.jupiter:junit-jupiter-api:5.10.0") testImplementation("org.junit.jupiter:junit-jupiter-engine:5.10.0") testImplementation("org.junit.jupiter:junit-jupiter-params:5.10.0") }
この形は問題なく動作する。繰り返し“5.10.0”と記述するのを避けるため、以下のようにバージョン管理部分を外部化してもいいだろう。
// プロジェクト Bのバージョン管理を外部化する場合 val junitVersion = "5.10.0" dependencies { implementation("com.mycorp:core-lib:1.22.2") testImplementation("org.junit.jupiter:junit-jupiter-api:${junitVersion}") testImplementation("org.junit.jupiter:junit-jupiter-engine:${junitVersion}") testImplementation("org.junit.jupiter:junit-jupiter-params:${junitVersion}") }
ただ、“ベター”ではあるが“ベスト”ではない。プロジェクト Aには古い依存関係が残っているからだ。もちろんプロジェクト Aの該当するjUnitバージョンの値を更新すればいいのだが、その他の依存関係を持つバージョンを維持するのは負担がかかる。jUnitだけでなく、コアライブラリ(core-lib)のバージョンも更新する必要がある。
GradleはMavenと同様、部品表(BOM)をサポートしているため、それを使えば管理の負担を軽減できる。具体的には「JUnit BOM」をインポートし、そこからバージョンを継承すればいい。
// プロジェクト BでJUnit BOMを使用して特定のバージョンを宣言する場合 val junitVersion = "5.10.0" dependencies { implementation("com.mycorp:core-lib:1.22.2") implementation(platform("org.junit:junit-bom:${junitVersion}")) testImplementation("org.junit.jupiter:junit-jupiter-api") testImplementation("org.junit.jupiter:junit-jupiter-engine") testImplementation("org.junit.jupiter:junit-jupiter-params") }
BOMのバージョン宣言を追加した。こうすることで、JUnitの依存関係を管理できるようになる。場合によってはJUnit以外の依存関係も管理できる。“junit-jupiter-api”のバージョンは指定しなくてよい。なお、この例ではたまたま“junit-jupiter-api”とBOMは同じ“5.10.0”だが、違うバージョンでも問題ない。
BOMバージョンとcore-libの管理にはまだできることが残っている。例えばBOMと同じ役割を果たす包括的な依存関係を宣言する。そうすると、core-libに対する1つの依存関係と、テストスコープ用にインポートされた、必要なテストライブラリに関する依存関係を宣言できる。
ただ、これも少し“粗い”対応だ。仮に依存関係のデータ量が130MBあるとすると、全てのプロジェクトに130MBの依存関係を強制することになるからだ。より良い解決策としては、独自のバージョンカタログを持ち、上記のJUnitで使われているパターンを踏襲しつつ、改良を加えることだ。
バージョンカタログは、Gradleのライブラリに含まれる要素を複数指定できる。指定できる要素はバージョン情報、プラグイン参照、ライブラリ参照、バンドルなどだ。では基本となる「build.gradle.kts」から見てみよう。なお、カタログの参照については最初は空のままにして、作業を進めながら記述する。
//提供者向けバージョンカタログ plugins { `version-catalog` `maven-publish` } group = "com.theserverside" version = "1.0-SNAPSHOT" repositories { mavenCentral() } catalog { versionCatalog { //ここでカタログを定義する } } publishing { publications { create<MavenPublication>("maven") { // 効率的な方法は他にもあるが、これでも十分機能する groupId = "com.theserverside" artifactId = "tss-bom" version = "1.0" from(components["versionCatalog"]) } } }
最初のタスクは、バージョンを定義することだ。これは内部でも外部でも使える名前付き要素となる。では先ほどのコードのカタログ部分を変更する。
//カタログ提供者 catalog { versionCatalog { version("junit", "5.10.0") } }
これによって内部でバージョン参照が作成される(ちなみにこの時点ではあまり意味はない)。バージョンカタログにもエクスポートされており、今すぐカタログを公開することも可能だ。
./gradlew publishToMavenLocal
また、カタログを消費するプロジェクトを、前述したプロジェクト Aやプロジェクト Bと同じものと見なすことも可能だ。
カタログ提供者にとっては「settings.gradle.kts」が何を保持しているかは気にしていなかったが、カタログ消費者にとっては気になる。そこで次はカタログ消費者向けのセッティングに進む。“tss-consumer”プロジェクトの「settings.gradle.kts」は次の通り。
//消費者向けsettings.gradle.kts pluginManagement { repositories { mavenLocal() mavenCentral() gradlePluginPortal() } } plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "0.5.0" } dependencyResolutionManagement { repositories { mavenLocal() mavenCentral() } versionCatalogs { create("libs") { from("com.theserverside:tss-bom:1.0") } }} rootProject.name = "tss-consumer"
“dependencyResolutionManagement”の部分で、バージョンカタログの使用を宣言し、名前(libs)とバージョンカタログプロジェクトのソースを指定している。こうすることでlibsを介してバージョンカタログにアクセスできるようになる。
先ほど宣言したJUnitのバージョンをどのように使えるのか、「build.gradle.kts」ファイルを見てみよう。これはバージョンカタログから“junit-bom”を取得するものだ(“String”ではなく“Provider”で宣言しているためやや見にくいが、実際にこのように使うわけではないので問題ない)。
//消費者向けバージョンカタログ plugins { kotlin("jvm") version "1.9.0" } group = "com.theserverside" version = "1.0-SNAPSHOT" repositories { mavenLocal() mavenCentral() google() } dependencies { implementation(platform("org.junit:junit-bom:${libs.versions.junit.get()}")) testImplementation("org.junit.jupiter:junit-jupiter-api") testImplementation("org.junit.jupiter:junit-jupiter-engine") testImplementation("org.junit.jupiter:junit-jupiter-params") } tasks.test { useJUnitPlatform() } kotlin { jvmToolchain(8) }
依存関係を示すブロックの最初の行に注意してほしい。JUnitのBOMをインポートするが、今度はライブラリからバージョンを取得している。また、“Provider”なので、値を引き出すために“get()”を呼び出す必要がある。なお、ここでライブラリをBOMとして追加しておくとすると、Providerを逆参照しなくてもよくなる。便利なので、提供者向けバージョンカタログを更新しておこう。
//提供者向けバージョンカタログ catalog { versionCatalog { version("junit", "5.10.0") library("junit-bom", "org.junit", "junit-bom") .versionRef("junit") } }
カタログに含まれる要素はバージョンとライブラリの2つだ。ライブラリは、名前による「バージョン参照」を使ったJUnit BOMへの参照となる。ライブラリとして“junit-bom”を宣言しているが、この名前はKotlinでは使えない。Gradleは名前にダッシュ(―)が含まれる場合、それをピリオド(.)に置き換える。Kotlinで使いたい場合は代わりに“junit.bom”とするといい。
次に消費者向けのバージョンカタログを更新する。今回は“type-safe accessor”を使ってBOMを取得する。
//消費者向けバージョンカタログ dependencies { implementation(platform(libs.junit.bom)) testImplementation("org.junit.jupiter:junit-jupiter-api") testImplementation("org.junit.jupiter:junit-jupiter-engine") testImplementation("org.junit.jupiter:junit-jupiter-params") }
バージョンカタログとともにJUnit BOMをエクスポートし、実行可能なプラットフォームとして使えるようになった。こうした設定は任意のライブラリでも実施可能だ。この設定では他にもできることがある。例として、BOMから取得したJUnitサブプロジェクトの手動宣言を全て消してみよう。
ライブラリレファレンスでは、「versionRef("junit")」でBOMのバージョンを指定している。バージョンなしでライブラリを宣言することも可能だ。まずは“junit-jupiter-api”で実践してみよう。提供者向けのバージョンカタログを更新する。
//提供者向けバージョンカタログ catalog { versionCatalog { version("junit", "5.10.0") library("junit-bom", "org.junit", "junit-bom") .versionRef("junit") library("junit-jupiter-api", "org.junit.jupiter" "junit-jupiter-api") .withoutVersion() } }
こうするとlibs変数を介して“libs.junit.jupiter.api”の名前で、“junit-jupiter-api”への名前付き参照ができるようになる。この新しいライブラリを使うように消費者向けバージョンカタログも更新する。
//消費者向けバージョンカタログ dependencies { implementation(platform(libs.junit.bom)) testImplementation(libs.junit.jupiter.api) testImplementation("org.junit.jupiter:junit-jupiter-engine") testImplementation("org.junit.jupiter:junit-jupiter-params") }
バージョンカタログを経由して、BOMからjunit-jupiter-engineのバージョンを取得している。これで機能としてはほぼ完成だ。
ただ、これは「バンドル」(bundle)が省略された状態になっている。バンドルとは、グループとしてインポートできるライブラリ名のリストのことだ。そこで、他のJUnit依存関係をライブラリとして定義し、それらからバンドルを作成する。
//提供者向けバージョンカタログ catalog { versionCatalog { version("junit", "5.10.0") library("junit-bom", "org.junit", "junit-bom") .versionRef("junit") val junitDeps = listOf( "junit-jupiter-api", "junit-jupiter-engine", "junit-jupiter-params" ) junitDeps.forEach { libName -> library(libName, "org.junit.jupiter", libName) .withoutVersion() } bundle("junit", junitDeps) } }
BOMは実際の依存関係ではないため、バンドルには含めない。Gradleのプラットフォームとして個別に使用する必要があるが、定義はしているので、バンドルのみをインポートすることは可能だ。新しい依存関係を消費者向けバージョンカタログに反映する。
//消費者向けバージョンカタログ dependencies { implementation(platform(libs.junit.bom)) testImplementation(libs.bundles.junit) }
次に、消費者用にBOMへの参照を定義する。こうすると、バージョン参照なしでBOMからの依存関係を使用できる。
バージョンカタログには、BOMに関連するライブラリも定義する。こうすると、IDE(統合開発環境)で参照をオートコンプリートできるからだ。さらに、関連するライブラリのグループを作成し、全体をインポートすることもできる。例えば「PostgresSQL」を操作するために必要な一連のライブラリ(Jacksonシリアライザー、Postgresドライバー、JDBIなど)を定義し、単一の実装ブロックとして利用できる。
//消費者向けバージョンカタログ dependencies { implementation(platform(libs.junit.bom)) implementation(libs.bundles.rdms) testImplementation(libs.bundles.junit) }
これで、簡単にバージョンカタログの依存関係を管理できるようになった。下流工程でバージョンカタログを使う消費者は、正しいカタログプロジェクトを参照するだけでバージョンを更新できる。
バージョンカタログには「プラグイン」(plugin)を入れることもできる。早速「kotlinプラグイン」を明示的に定義しよう。
//消費者向けバージョンカタログ plugins { kotlin("jvm") version "1.9.0" }
インポートは、他のものとほぼ同じ方法で実施可能だ。プラグインとして「Kotlin1.9.0」を含むフルバージョンのカタログ「build.gradle.kts」は次の通り。
//提供者向けバージョンカタログ plugins { `version-catalog` `maven-publish`} group = "com.theserverside" version = "1.0-SNAPSHOT" repositories { mavenCentral() } catalog { versionCatalog { version("junit", "5.10.0") version("kotlin", "1.9.0") plugin("kotlin", "org.jetbrains.kotlin.jvm") .versionRef("kotlin") library("junit-bom", "org.junit", "junit-bom") .versionRef("junit") val junitDeps = listOf( "junit-jupiter-api", "junit-jupiter-engine", "junit-jupiter-params" ) junitDeps.forEach { libName -> library(libName, "org.junit.jupiter", libName) .withoutVersion() } bundle("junit", junitDeps) } } publishing { publications { create<MavenPublication>("maven") { groupId = "com.theserverside" artifactId = "theserverside-bom" version = "1.0" from(components["versionCatalog"]) } } }
これは消費者向けの「build.gradle.kts」のエイリアスを使ってインポートできる。全体として次のようになる。
//消費者向けバージョンカタログ plugins { alias(libs.plugins.kotlin) // note import of libs.plugins.kotlin here } group = "com.theserverside" version = "1.0-SNAPSHOT" repositories { mavenLocal() mavenCentral() } dependencies { implementation(platform(libs.junit.bom)) testImplementation(libs.bundles.junit) } tasks.test { useJUnitPlatform() } kotlin { jvmToolchain(8) }
バージョンカタログのKotlinを“1.9.1”に更新すると、プロジェクトも(更新されたバージョンカタログを引っ張っている限り)正しく、標準化されたKotlinプラグインを取得してくれる。通常であれば、少なくともインポートされたバージョンカタログのバージョン番号を変更しなければならないが、このやり方であれば消費者向けの「settings.gradle.kts」には全く手を付けなくてもいい。
バージョンカタログで、特定のバージョンを使用することは簡単だ。ただし、場合によっては別のバージョンを使いたいことがある。ライブラリのスナップショットを使用してテストしたい、古い機能をサポートするために古いバージョンのライブラリを使用したいなど。都度、ビルドを変更する方法もあるが、バージョンカタログを使うとそういった手間を減らせる。
バンドルをインポートすると、そのバンドルに含まれるバージョンを取得する。バンドルから特定のライブラリをオーバーライドすることもできる。GradleのAPI“ExternalModuleDependency”で“exclude()”に、その後に含める必要があるものを全て指定する。
例えば、特定のバンドルに複数のライブラリ(com.theserverside:a:1.0、com.theserverside:b:1.1、com.theserverside:c:1.3)を定義するバージョンカタログがあるとする。あるとき、ライブラリb(com.theserverside:b)のバージョン“1.0”が必要なことに気付いた。その場合は次のように定義する。
dependencies { implementation(libs.bundle.bundleOne) { exclude("com.theserverside:b") } implementation("com.theserverside:b:1.0") }
こうすると、バンドルからライブラリb以外の“a”と“c”が取り込まれ、クラスパスで必要な正確なバージョンを指定できるようになる。
Gradleによる依存関係管理でも残念ながら解決できない課題は残っている。1つは、バンドルの前にBOMをインポートする方法が見つからなかったことだ。バンドルがバージョンそのものを含まない限り、バージョンは「バンドルのインポートされたBOM」から取得される。BOMを含めてそのメリットを得るか、手動でバージョンを指定する必要があるので、特に理由がなくてもBOMをカタログに含めることになってしまう。
また、BOMをインポートすると、カタログのバージョンが表示されるのに、ライブラリの依存関係自体を指定しなければならないという課題がある。Gradleは命令型なので、理論的にはBOMをフェッチして依存関係を繰り返し処理し、ライブラリ参照を作成しながら進めることができる。ただし、BOMには複数の形式があるため、これは簡単にはいかないかもしれない。
バンドルにはスコープが付いていないことも課題だ。“api”、“implementation”、“testImplementation”などのスコープでバンドルをインポートすると、バンドル内の全てがそのスコープでインポートされてしまう。例えば実装スコープのMongoDBドライバーを含む“mongodb”バンドルと、“testImplementation”スコープの“testcontainers-mongodb”ライブラリを同じバンドル内に定義できない。その場合は“libs.bundles.mongodb”と“libs.bundles.test.mongodb”の両方のバンドルについてそれぞれインポートする必要がある。
ただ、それは注意点としては小さなものだ。仮にそれが深刻だったとしても、対処は可能だし、バージョンカタログが使えなくなることはほとんどない。
Copyright © ITmedia, Inc. All Rights Reserved.