[Kotlin] Mappie を使ってデータクラスの詰め替えをする
Motiavtion
レイヤードアーキテクチャを採用している場合、必然的にデータクラスの詰め替えが多く発生します。
もはや慣れてきているのであまり苦には感じないのですが...、ふとしたタイミングでもう少し楽をできないものかと思うことがあります。
一番古典的な方法だとリフレクションを使ったライブラリなどがありますが、リフレクションなのでオーバーヘッドはありますし、なにより型安全ではないので好ましくありません。
(マッピングができないフィールドがあったときにサイレントに無視されてバグったり...)
Javaだとアノテーションプロセッサを使ったMapStructもありますが、いかんせんどうにもKotlinとは相性が悪そうです。
似たようなライブラリをいくつか探していたところ、Mappie が良さそうだったので紹介します。
(なお、konvertというのもあります。こちらのほうがMapStructと使用感は似ていそうです。が、...アノテーションベースで柔軟性に欠けそうな印象があるため、今回はMappieを紹介します)
Mappieの概要
Mappieは、Kotlin Compiler Pluginとして実装されており、コンパイル時にオブジェクトマッピングコードを可能な限り自動で生成してくれます。
まずは、以下の簡単な例を見てましょう。ObjectMappieを継承したPersonMapperを用意するだけで、PersonからPersonDtoへの変換コードが自動で生成されます。
data class Person(
val firstName: String,
val lastName: String,
val middleName: String?,
val age: Int,
)
data class PersonDto(
val firstName: String,
val lastName: String,
val age: Int,
)
// これ
object PersonMapper : ObjectMappie<Person, PersonDto>()
fun main() {
val person = Person(firstName = "Ryosuke", lastName = "Hasebe", middleName = null, age = 20)
val personDto = PersonMapper.map(person)
println(personDto)
}
// 実行結果
PersonDto(firstName=Ryosuke, lastName=Hasebe, age=20)
マッピングルールを明示的に指定することも可能
先の例は、双方に同じfield nameがあるので自動でマッピングコードを生成することができました。
一方で次のような場合、PersonDto.nameがマッピングできないためコンパイルエラーとなります。ちゃんとコンパイルエラーになってくれるのが偉い。
(Intellij IDEA上だと残念ながらコンパイルエラーになってくれませんでしたが...これは多分、FIR実装を工夫することで解決できるんじゃないかなぁ)
data class Person(
val firstName: String,
val lastName: String,
val age: Int,
)
data class PersonDto(
val name: String,
val age: Int,
)
object PersonMapper : ObjectMappie<Person, PersonDto>()
fun main() {
val person = Person(firstName = "Ryosuke", lastName = "Hasebe", age = 20)
val personDto = PersonMapper.map(person)
println(personDto)
}
// コンパイルエラーとなる
Target PersonDto::name has no source defined
こういった場合は、明示的にマッピングルールを書いてあげることで解決できます。Kotlin DSLとして書けるので、柔軟性がありそうです。
data class Person(
val firstName: String,
val lastName: String,
val age: Int,
)
data class PersonDto(
val name: String,
val age: Int,
)
object PersonMapper : ObjectMappie<Person, PersonDto>() {
// これ
override fun map(from: Person) = mapping {
to::name fromValue "${from.firstName} ${from.lastName}"
}
}
fun main() {
val person = Person(firstName = "Ryosuke", lastName = "Hasebe", age = 20)
val personDto = PersonMapper.map(person)
println(personDto)
}
// 実行結果
PersonDto(name=Ryosuke Hasebe, age=20)
ドキュメントサイトを見ると、他にも色々とマッピングルールを書けることが確認できます。
マッピングエラーの表現
例外としてハンドリングする
次のようにオブジェクト生成時にバリデーションがあるようなケースを見てましょう。バリデーションに引っかかった場合は当然例外が発生します。
data class Person(
val firstName: String,
val lastName: String,
val age: Int,
)
data class PersonDto(
val name: String,
val age: Int,
) {
// これ
init {
require(age >= 20) { "age must be greater than or equals to 20" }
}
}
object PersonMapper : ObjectMappie<Person, PersonDto>() {
override fun map(from: Person) = mapping {
to::name fromValue "${from.firstName} ${from.lastName}"
}
}
fun main() {
val person = Person(firstName = "Ryosuke", lastName = "Hasebe", age = 18)
val personDto = PersonMapper.map(person)
println(personDto)
}
// 実行結果
Exception in thread "main" java.lang.IllegalArgumentException: age must be greater than or equals to 20
at demo.PersonDto.<init>(Main.kt:19)
at demo.PersonMapper.map(Main.kt:24)
at demo.MainKt.main(Main.kt:31)
at demo.MainKt.main(Main.kt)
kotlin.Result
としてハンドリングする
例外じゃなくてkotlin.Result
でハンドリングしたい場合、以下のような拡張関数を書くだけで実現できます。このあたりも柔軟に実現できていいですね。
fun <FROM, TO> ObjectMappie<FROM, TO>.mapAsResult(from: FROM): Result<TO> {
return runCatching { map(from) }
}
fun main() {
val person = Person(firstName = "Ryosuke", lastName = "Hasebe", age = 18)
val personDtoResult = PersonMapper.mapAsResult(person)
println(personDtoResult.getOrNull())
}
// 実行結果
null
まとめ
Mappieを活用することで、Kotlinにおけるオブジェクトマッピングを簡潔かつ型安全に実装できることが分かりました。
また、デフォルトの自動マッピングに加え、DSLを用いた柔軟なマッピングルールの指定も可能であるため、より細かい制御が求められる場面でも対応できます。さらに、マッピング時のエラー処理についても、例外として処理するだけでなく、kotlin.Result
を利用したハンドリング方法も採用できたり、柔軟な設計が可能です。
プロダクションで採用するかと言われるとまだまだ悩ましいところではありますが...、便利そうだなということは十分に実感できました。
Discussion