Kotlin×Gradle×JVMで実現するモジュラーモノリス
この記事は、[ispec] 医療に向き合う技術者集団の戦録 Advent Calendar 2024 の22日目の記事です!
はじめに
本記事では、KotlinとGradleを使ってモジュラーモノリスを実装する方法を解説します。モジュラーモノリスでは、アプリケーションを単一のデプロイメントユニットとして維持しながら、内部を論理的なモジュールに分割します。
JVMシステムにおけるモジュール化の基礎
JARファイルの役割
JVMで動作するプログラムは、一般的に「JAR(Java Archive)」という形式で管理されます。
- JARはクラスファイルやリソースをZIP形式でまとめたもので、実行時のクラスローディングの単位となります。
- Gradleのマルチプロジェクト構成では、サブプロジェクトごとに独立したJARが生成されます。
- 最終的にはこれらのJARを1つにまとめて実行可能な形(Fat JAR)にします。
Kotlinのinternal修飾子とカプセル化
Kotlinには以下のアクセス修飾子があります:
-
public(デフォルト): どこからでもアクセス可能 -
private: 定義されたクラスまたはファイル内でのみアクセス可能 -
protected: 定義されたクラスとその派生クラス内でのみアクセス可能 -
internal: 同一モジュール内でのみアクセス可能
特にinternal修飾子は、1つのコンパイル単位(通常はJARファイル)を境界として機能します。以下の性質によりJAR単位での強力なカプセル化を実現できます。
- 同一JAR内では
internalな要素にアクセス可能 - 異なるJAR間では
internalな要素は見えない
モジュール境界としてのJAR
Gradleのマルチプロジェクト構成では、各サブプロジェクトが独立したJARとして生成されます。
-
物理的な分離
- 各サブプロジェクトは独立したコンパイル単位となる。
-
可視性の制御
-
internal修飾子により、サブプロジェクト内の実装詳細を他のサブプロジェクトから隠蔽できる。
-
-
依存関係の管理
- Gradleの設定でサブプロジェクト間の依存関係を明示的に制御できる。
- 循環参照などの問題を防ぐことができる。
サブプロジェクトをモジュールと捉えることで、モジュラーモノリスなアプリケーションがGradleで実現できます。
Kotlin×Gradleによるモジュラーモノリスの実装パターン
シンプルなユーザー管理システムを例に、以下の3つの実装パターンについて、モジュラーモノリスアプリケーションを実装する際のメリットやデメリットを確認していきましょう。
- シングルプロジェクト構成: Gradleサブプロジェクト内のパッケージでモジュールを分割するパターン。
- マルチプロジェクト構成: モジュールごとにGradleのサブプロジェクトを分割するパターン。
- マルチモジュール構成+公開APIモジュール: パターン2の課題を改善するために公開APIモジュールを1つ挟むパターン。
題材となるユーザー管理システムは、以下の2つのモジュールを含むコマンドラインアプリケーションです。
-
user: ユーザー管理のコアとなるモジュール -
cli: コマンドラインのIFとなるメインモジュール
シングルプロジェクト構成
プロジェクトの構成は以下の通りです。
(完全なソースコードはGitHubにあります)
├── app
│ ├── build.gradle.kts
│ └── src
│ └── main
│ └── kotlin
│ └── gucchi
│ ├── cli
│ │ └── Main.kt
│ └── user
│ ├── User.kt
│ ├── UserRepository.kt
│ ├── UserRepositoryImpl.kt
│ └── UserService.kt
└── settings.gradle.kts
ルート直下のappディレクトリがGradleのサブプロジェクトとして認識されます。
appサブプロジェクトにパッケージとして各モジュール(user、cli)を分割するパターンです。
settings.gradle.kts
プロジェクトの設定ファイルです。プロジェクトに含まれるサブプロジェクトを定義します。
今回はappサブプロジェクトのみです。
build.gradle.kts
appサブプロジェクトのビルドスクリプトです。Kotlin DSLで記述されています。
shadowプラグインにより、依存ライブラリも含めて1つのJARファイルを生成します。
userモジュール
User.kt
ユーザーを表すモデルです。ユーザーはIDと名前を持ちます。
UserRepository.kt
ユーザーの保存・検索を行うリポジトリインターフェースです。
UserRepositoryImpl.kt
ユーザーリポジトリの実装クラスです。
UserService.kt
ユーザーの保存・検索サービスを提供します。
cliモジュール
Main.kt
userモジュールのユーザーサービス(UserService)を利用して、ユーザーの追加・検索を行うコマンドラインプログラムです。
main関数は以下のコマンドを受け付けるREPLです。
| コマンド | 内容 |
|---|---|
| add | 引数のIDと名前でユーザーを追加します。 |
| get | 引数のIDを元にユーザーを取得します。 |
| exit | プログラムを終了します。 |
ビルド・実行
それではビルドしてみましょう。ルート直下で以下を実行します。
$ ./gradlew shadowJar
これにより、app/build/libs/user-management-1.0.0-all.jarファイルが生成されます。
このJARは依存ライブラリも含むFat JAR形式であり、これ単体で実行可能形式です。それではこのJARを実行してみましょう。
$ java -jar app/build/libs/user-management-1.0.0-all.jar
> get 1
Not found
> add 1 oda
added: User(id=1, name=oda)
> add 2 toyotommi
added: User(id=2, name=toyotommi)
> get 2
Found: User(id=2, name=toyotommi)
> exit
$
メリット
- 機能を各モジュールに分割することで、関心の分離が図れます。
- 単一プロジェクトのため、変更からビルド・実行まで迅速に開発可能です。依存関係の管理がbuild.gradle.kts一箇所で完結します。
課題
- モジュール間のアクセス制御が難しい。シングルプロジェクト構成では、全てのモジュールが1つのJARに含まれるため、
internal修飾子は意味をなしません。コード上、userモジュールの詳細であるUserRepositoryImpl.ktをcliモジュールから参照することは防げません。
マルチプロジェクト構成
シングルプロジェクト構成では、モジュール間のアクセスを厳密に制御することが難しいのですが、Gradleのマルチプロジェクト構成を採用することにより、この問題を解決することができます。
それでは、パターン1で示したプログラムをマルチプロジェクトとして変更します。
以下が変更後のプロジェクト構成となります。
(完全なソースコードはGitHubにおいています)
├── build.gradle.kts
├── buildSrc
│ ├── build.gradle.kts
│ └── src
│ └── main
│ └── kotlin
│ └── kotlin-modular-monolith-common.gradle.kts
├── cli
│ ├── build.gradle.kts
│ └── src
│ └── main
│ └── kotlin
│ └── gucchi
│ ├── cli
│ │ └── Main.kt
│ └── user
│ ├── User.kt
│ ├── UserRepository.kt
│ ├── UserRepositoryImpl.kt
│ └── UserService.kt
├── settings.gradle.kts
└── user
├── build.gradle.kts
└── src
└── main
└── kotlin
└── gucchi
└── user
├── User.kt
├── UserRepository.kt
├── UserRepositoryImpl.kt
└── UserService.kt
ルートディレクトリ直下に以下の3つのサブディレクトリ(サブプロジェクト)を作成します。
-
buildSrc: マルチプロジェクト構成において各サブプロジェクトに共通のビルド設定を定義する特別なディレクトリです。詳細に関しては[1]を参照してみてください。 -
cli: cliモジュール。プログラムは変更前のcliモジュールとほぼ同じです。 -
user: userモジュール。プログラムは変更前のuserモジュールとほぼ同じです。
これらのサブプロジェクトはそれぞれが独立したモジュールであることを示すビルドファイル(build.gradle.kts)を持ちます。
settings.gradle.kts
マルチプロジェクト全体を設定します。includeにより、userおよびcliモジュールから構成されることを表します。先述のとおり、buildSrcは特別なディレクトリであり、Gradleにより自動的にプロジェクトとして認識されます。
build.gradle.kts
マルチプロジェクト全体のビルド定義です。
shadowプラグインにより、全モジュールのJARを1つにまとめます。
buildSrcで定義した全モジュール共通のプラグイン(kotlin-modular-monolith-common)を設定します。
userモジュール
build.gradle.kts
userモジュール単独のビルド定義ファイルです。
UserRepositoryImpl.kt
ユーザーリポジトリの実装クラスです。このクラスはuserモジュールの内部実装であるため、internal宣言を付与します。
その他のプログラムは変わらないため省略します。
cliモジュール
build.gradle.kts
cliモジュール単独のビルド定義ファイルです。
上記で着目すべきは、userモジュールに対する依存関係を設定していることです。
これにより、cliモジュールはuserモジュールを利用することが可能になります。
Main.ktは変わらないため、省略します。
ビルドと実行
ルートディレクトリで./gradlew shadowJarを実行します。
これにより、各モジュール(userおよびcli)がそれぞれビルドされ、それらのビルド結果をまとめたJARがルート直下のbuildディレクトリに生成されます。
$ ./gradlew shadowJar
$ ls build/libs
user-management-1.0.0-all.jar
それでは実行してみましょう。
$ java -jar build/libs/user-management-1.0.0-all.jar
>
//以下略
internalの効果
userモジュールのUserRepositoryimplは内部実装であるため、internalとなっています。
このUserRepositoryImplをcliモジュールのMain.ktから呼び出してビルドしてみましょう。
Main.ktに次のコードを追加します。
import gucchi.user.UserRepositoryImpl
以下のビルドエラーが示すように、internal修飾子によりコンパイルエラーとなります。
$ ./gradlew shadowJar
> Task :cli:compileKotlin FAILED
e: file:///kotlin-modular-monolith/cli/src/main/kotlin/gucchi/cli/Main.kt:5:20 Cannot access 'class UserRepositoryImpl : UserRepository': it is internal in file.
FAILURE: Build failed with an exception.
各サブプロジェクト毎のビルド
たとえば、cliモジュールのみをビルドしたい場合は次のようにします。
$ ./gradlew :cli:build
これにより、cli/buildディレクトリ配下にJARファイルが生成されます。
cliモジュールのみを修正した場合、全てのモジュールをビルドする必要がないため、テスト時間の短縮が測れるでしょう。
メリット
-
internal修飾子によるカプセル化が機能します。 - モジュール間の依存関係が明確になります。
- モジュール単位でのビルド・テストが可能になります。
課題
-
internal修飾子は、各クラスや関数ごとに付与する必要があるため、大規模なモジュールになるとinternal修飾子の付与忘れのリスクの可能性があります。 - モジュール境界の適切な設計が必要です
- ビルド設定が複雑化になります。
マルチモジュール構成+公開APIモジュール
モジュールが大規模になると隠蔽したい関数などにinternal修飾子を毎回設定するのは面倒です。また、付与忘れによる内部漏洩のリスクも高まります。
これを解決するために、公開用のAPIだけを提供するモジュールserviceをcliとuserの間に一層設けてみましょう。
依存関係は次のようになり、cliモジュールはserviceモジュールを介してのみしかuserの機能を利用できなくなります。
いわば、serviceモジュールがuserモジュールのファサード的な役割を果たします。
cli -> service -> user
以下が変更後のプロジェクト構成となります。
(完全なソースコードはGitHubにあります)
├── build.gradle.kts
├── buildSrc
│ ├── build.gradle.kts
│ └── src
│ └── main
│ └── kotlin
│ └── kotlin-modular-monolith-common.gradle.kts
├── cli
│ ├── build.gradle.kts
│ └── src
│ └── main
│ └── kotlin
│ └── gucchi
│ └── cli
│ └── Main.kt
├── service
│ ├── build.gradle.kts
│ └── src
│ └── main
│ └── kotlin
│ └── gucchi
│ └── service
│ └── UserService.kt
├── settings.gradle.kts
└── user
├── build.gradle.kts
└── src
└── main
└── kotlin
└── gucchi
└── user
├── User.kt
├── UserRepository.kt
├── UserRepositoryImpl.kt
└── UserService.kt
settings.gradle.kts
serviceサブプロジェクトを追加します。
build.gradle.kts
serviceサブプロジェクトを依存に追加します。
serviceモジュール
build.gradle.kts
userサブプロジェクトへの依存を持ちます。
UserService.kt
userモジュールへのファサードとなるクラスです。
ここで、サービス間の通信オブジェクトとなるUserDTOを定義しています。
これは、userモジュールが、serviceモジュールのみに隠蔽されているため、serviceモジュールの利用者がuserモジュールのUserエンティティを参照できないためです。
cliモジュール
build.gradle.kts
依存先をuserモジュールからserviceモジュールに変更します。
userモジュールなどその他のプログラムは変わらないため省略します。
ビルドと実行
ルートディレクトリで./gradlew shadowJarにより生成したJARファイルを実行します。
$ ./gradlew shadowJar
$ java -jar build/libs/user-management-1.0.0-all.jar
> get 1
Not Found
メリット
- ユーザー管理ロジックへのアクセスを
serviceモジュールに限定できる。 -
internal修飾子の設定が不要。
課題
プロジェクト構成がより複雑になる。
今回のケースでは、cliモジュールはuserモジュールにアクセスできないため、Userエンティティを参照できません。
このため、serviceモジュールにUserエンティティとほぼ同様のUserDTOを配置し、これをサービス間で通信することにしました。
この問題を解決するもう1つの方法は、依存定義にimplementationではなく、api宣言を用いることです。[2]
次のように、serviceモジュールのbuild.gradle.ktsにて、userモジュールへの依存をapiで宣言します。
plugins {
id("kotlin-modular-monolith-common")
}
dependencies {
api(project(":user")) //apiによるuserへの依存。これは推移的依存を発生させる。
implementation(kotlin("stdlib"))
}
api宣言は推移的依存を持ち込むため、serviceの依存先はその呼び出し元の依存にも追加されます。結果として、serviceの利用者であるcliモジュールからuserモジュールが参照できることになります。ただし、これはuserモジュールの内部構造を暴露していることにつながるため、api宣言は慎重に使うべきでしょう。
APIの設計・メンテナンスの工数が増える。
APIレイヤモジュールの設計・開発や、今回のケースのUserDTOのような問題を解決するための統一的な設計が必要となります。
まとめ
本記事では、Kotlin×Gradle×JVMにおけるモジュラーモノリスの実装方法を解説しました。モジュール化の基礎となるJARファイルの役割や、Kotlinのinternal修飾子によるカプセル化の重要性を理解した上で、シングルプロジェクト構成、マルチプロジェクト構成、公開APIモジュールを介した構成と、段階的に実装パターンを進化させていくことで、モジュラーモノリスのメリットを最大限に引き出すことができます。
ただし、Kotlinのinternal修飾子には限界があることにも注意が必要です。internal修飾子は、コンパイル時のみ可視性を制限し、JVM上ではpublicとして扱われます[3]。より厳密なモジュール化を実現するには、Java 9以降で導入されたJigsaw(Java Platform Module System)[4]の利用を検討する必要かもしれません。
また、モジュール化の設計には常にトレードオフが伴います。厳密なモジュール境界の定義は、モジュール間の結合度を下げ、独立性を高めますが、同時にモジュール間の情報共有を難しくし、開発の複雑性を増大させます。プロジェクトの規模や要件に応じて、適切なバランスを見極めることが重要でしょう。
以下は、本記事で紹介した3つの実装パターンの比較表です。
| 構成 | メリット | デメリット |
|---|---|---|
| シングルプロジェクト構成 | シンプルで迅速な開発が可能。すべてが1つのJARにまとまるため運用が簡単。 | モジュール間の依存性を厳密に管理できない。internal修飾子の効果が限定的。 |
| マルチプロジェクト構成 |
internal修飾子による厳密なカプセル化が可能。モジュールごとにテストやビルドを分割可能。 |
ビルド設定の複雑化。大規模モジュールでinternal修飾子の管理が煩雑。 |
| 公開APIモジュールを介した構成 | モジュール間のインターフェースを明確化。公開API以外を隠蔽できるため、設計が堅牢に。 | 構造の複雑化。APIレイヤーの設計と運用に追加のコストが発生。 |
モジュラーモノリスは、マイクロサービスアーキテクチャの複雑さを避けつつ、モノリシックアプリケーションの課題を解決する有力なアプローチです。本記事が、みなさまのプロジェクトにおけるモジュラーモノリスの適用の一助となれば幸いです。
Discussion