はじめに
ServerSide Kotlin(FWはSpring Boot)アプリのマルチモジュール対応について書きたいと思います。
試した環境
Kotlin 1.7.22
Spring Boot 3.0
Gradle 7.5.1
IntelliJ IDEA Ultimate 2022.3
マルチモジュール化するモチベーション is 何?
現在の開発ではオニオンアーキテクチャを採用しているのですが、レイヤー間の不正なアクセスをモジュールの仕組みで防ぎたいというのがモチベーションになります。
以前担当していたプロダクトでは ArchUnit で検知する方法を取っていましたが仕組みで防ぐに越したことはありません。
上記のようにモジュールを分けて依存関係の向きを一方向に定義することで、ui層でDBアクセスしたり、infrastrucure層のオブジェクトがapplication層に渡るといった意図しない実装をされる心配がなくなります。
また、それぞれの層で必要なライブラリだけを依存関係に追加しますので、ライブラリのバージョンアップによる影響範囲が最低限かつ明確になります。
ビジネスロジックを実装するdomain層が沢山のライブラリに依存してしまうと辛いので、それを防げるのが良いです。
Kotlin のマルチモジュールサポートについて
まず、言語仕様に以下のような記述があるように、
Kotlin には Java のような言語標準のモジュールシステムは備えられておらず、ビルドシステムや IDE の仕組みを使う必要があります。
簡単な例では、モジュールとは、あるプロジェクトで同時にコンパイルされたファイルの集合のことです。
1 回の Kotlin コンパイラ呼び出しでコンパイルされるファイル群
Maven モジュール
Gradle プロジェクト
私は IntelliJ IDEA と Gradle で開発しているので、それらを使用してマルチモジュール化を行いました。
やったこと
サブプロジェクトの作成
先ほど挙げたレイヤーごとにGradleのサブプロジェクトを作成していきます。
IntelliJ IDEA上でNew -> Moduleすれば、settings.gradle.ktsの設定や、フォルダを作ってbuild.gradle.ktsを置いてところは自動でやってくれるので楽です。
// 自動で挿入される
include("infrastructure")
include("domain")
include("application")
include("usecase")
完成するとこのようなフォルダ構成になります。
┗ application
┗ src/
┗ build.gradle.kts
┗ domain
┗ src/
┗ build.gradle.kts
┗ infrastructure
┗ src/
┗ build.gradle.kts
┗ ui
┗ src/
┗ build.gradle.kts
src/
build.gradle.kts
settings.gradle.kts
モジュールの依存関係を定義
次に、それぞれのモジュールのbuild.gradle.ktsに依存関係を設定していきます。
下記はui層の例ですが、application層とdomain層だけ参照するようにしています。
外部ライブラリもui層で必要なものだけに絞って定義します。
dependencies {
implementation(project(":application"))
implementation(project(":domain"))
implementation("org.springframework.boot:spring-boot-starter-web")
}
困ったことや工夫したところ
各プロジェクトで共通の設定はまとめる
build.gradle.ktsがモジュールごとに作られますが、
保守性を考えると重複する設定は極力書きたくないので、ルートプロジェクトのbuild.gradle.ktsにまとめて設定を行いました。
allprojects {
// どのモジュールもmavencntralを見る
repositories {
mavenCentral()
}
// Kotlinのコンパイルオプションも同じで良い
tasks.withType<KotlinCompile> {
kotlinOptions {
freeCompilerArgs = listOf("-Xjsr305=strict")
jvmTarget = "17"
}
}
// 必須で必要なプラグイン
apply(plugin = "kotlin")
apply(plugin = "org.jetbrains.kotlin.plugin.spring")
apply(plugin = "io.spring.dependency-management")
// テストは全てのケースで書きますので設定しておく
tasks.withType<Test> {
useJUnitPlatform()
}
// どのモジュールでも必要なライブラリを設定しておく
dependencies {
testImplementation("io.kotest:kotest-runner-junit5-jvm:5.5.4")
}
}
サブプロジェクトでは Spring Bootプラグインはapplyしない
単純にSpring Bootプラグインを設定するとサブプロジェクトもSpring Bootアプリケーションと扱われて正しく動作しません。
そこでサブプロジェクトではapply false
でプラグインを適用すれば依存関係の解決だけにプラグインを使用することが出来ます。
エラーメッセージが分かりにくいので知らないとハマると思います。
plugins {
id("org.springframework.boot") apply false
}
// BOMの指定も必要
dependencyManagement {
imports {
mavenBom(org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES)
}
}
プラグインのバージョンをまとめて指定したい
各サブモジュールのbuild.gradle.ktsにバージョンを書いてしまうと、バージョンアップ時に大変なので一箇所で定義したいです。
最初はGradleのextプロパティで指定しようとしましたが、pluginsブロックには変数が設定できないようでダメでした。
正解かどうか良くわかりませんが、settings.gradle.ktsに使用するプラグインをすべて定義して、サブプロジェクト側ではバージョンを指定しないようにしました。
pluginManagement {
repositories {
maven { url = uri("https://repo.spring.io/milestone") }
gradlePluginPortal()
}
plugins {
id("org.springframework.boot") version "3.0.0"
id("io.spring.dependency-management") version "1.1.0"
id("io.gitlab.arturbosch.detekt") version "1.22.0"
id("nu.studer.jooq") version "8.0"
id("org.flywaydb.flyway") version "9.10.1"
kotlin("jvm") version "1.7.22"
kotlin("plugin.spring") version "1.7.22"
}
}
サブプロジェクト側ではこのようにバージョンを指定しません。
plugins {
id("nu.studer.jooq")
id("org.flywaydb.flyway")
}
Jacoco のマルチモジュール対応
標準のjacocoプラグインはサブプロジェクトに対応していないのですが、jacoco-report-aggregation
を使ってレポートの集約を行いました。
plugins {
id("jacoco-report-aggregation")
}
ちょっと手を入れたこととしては、testCodeCoverageReport
タスクを動かせばJacocoのレポートが出力されるのですが、JOOQの自動生成コードは対象外にしたかったので下記のような設定を加えました。
// Jacocoのレポートからjooqの自動生成コードを除外する
tasks {
testCodeCoverageReport {
classDirectories.setFrom(files(
subprojects.map {it.fileTree(mapOf("dir" to "${it.buildDir}/classes", "exclude" to arrayOf("**/jooq/**")))}
))
}
}
また、GitHub ActionsでPull Request時に修正したコードのカバレッジを出すようにしていますが、標準のjacocoプラグインの設定からxmlのパスを書き換えるぐらいの対応で済みました。
name: Java CI with Gradle
on:
pull_request:
permissions:
contents: read
pull-requests: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'corretto'
- run: ./gradlew ci --no-daemon
- name: Add coverage to PR
id: jacoco
uses: madrapps/jacoco-report@v1.3
with:
paths: ${{ github.workspace }}/build/reports/jacoco/testCodeCoverageReport/testCodeCoverageReport.xml
token: ${{ secrets.GITHUB_TOKEN }}
イマイチなところ
application層からspring-txへの依存関係を指定する必要がある
@Transactional
を使うために本来あまり依存したくないspring-txを読み込む必要がありました。
別のレイヤーからリフレクションで設定するなど回避方法はありますが、どうするのが一番キレイなのか考え中です。
@Service
class HogeService(val hogeRepository: HogeRepository) {
@Transactional(readOnly = true)
fun findById(id: Long): Hoge {
return hogeRepository.findById(id)
}
}
Spring Bootに依存したテストがルートプロジェクトでしか動かせない
テストコードもサブプロジェクトごとにきれいに配置したいのですが、@SpringBootTest
を使用するようなテストがルートプロジェクトに置かないと動かない問題があります。
@SpringBootApplication
を付与したmainクラスをルートプロジェクトに置いているので、それはそうなるよなという感じですが今のところ解決できていません。
(アドベントカレンダーまでに解決させたかったが間に合いませんでした...)
追記
後日、テストを動かしたいサブプロジェクトのbuild.gradle.ktsにtestRuntimeOnlyでmainクラスが置かれているルートプロジェクトをテスト実行時に参照するようにしたところうまく行きました。
testRuntimeOnly(rootProject)
まとめ
終わってみれば Kotlin 要素がほとんどなく、Gradle のサブプロジェクトの話が中心になってしまいました。
さらっと書いてますがGradle自体難しいですし、Springがエラーになったときは原因を掴むのは経験値がないとちょっと辛いかもしれないので、
Kotlin本体がモジュール対応してくれることをサンタさんにお願いしときました🎄