Kotlin×Gradle×JVMで実現するモジュラーモノリス
この記事は、[ispec] 医療に向き合う技術者集団の戦録 Advent Calendar 2024 の22日目の記事です!
はじめに
本記事では、KotlinとGradleを使ってモジュラーモノリスを実装する方法を解説します。モジュラーモノリスでは、アプリケーションを単一のデプロイメントユニットとして維持しながら、内部を論理的なモジュールに分割します。
JVMシステムにおけるモジュール化の基礎
JARファイルの役割
JVMで動作するプログラムは、一般的に「JAR(Java Archive)」という形式で管理されます。
- JARはクラスファイルやリソースをZIP形式でまとめたもので、実行時のクラスローディングの単位となります。
- Gradleのマルチプロジェクト構成では、サブプロジェクトごとに独立したJARが生成されます。
- 最終的にはこれらのJARを1つにまとめて実行可能な形(Fat JAR)にします。
internal
修飾子とカプセル化
Kotlinの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