[Kotlin] KomapperとKSP
はじめに
本記事では、Komapperの特徴の1つであるコンパイル時コード生成について紹介します。
KSPとは
Komapperはコンパイル時コード生成を実現するためにKotlin Symbol Processing (KSP) というコンパイラプラグインを使っています。KSPはコンパイル時にソースコードに付与されたアノテーションを読み取って、何らかのチェックを行ったり新しいソースコードを生成したりするツールです。
同じようなツールとしてはkaptがありますが、kaptはすでにメンテナンスモードに入っており、代替としてKSPの利用が推奨されています。KSPもkaptもコンパイル時にコードに付与されたアノテーションを読み取って何らかの処理を実行できるという点は同じですが、KSPの方がより効率的な作りになっています。
kaptはJavaのアノテーションプロセッサの仕様であるJSR 269を前提としたアーキテクチャとなっており、KotlinコードをJavaコード(スタブ)に変換してからJSR 269のアノテーションプロセッシングを実行します。変換が必要な分だけ処理が遅いという問題がありました。KSPはJSR 269は使わずKotlinのコードを直接解釈するアプローチをとっておりこの問題を解決しています。
さらに、「エラーの発生箇所がわかりにくい」というkaptの問題もKSPは解決できているように思われます。kaptを介したアノテーションプロセッシングは、エラーの発生箇所として、オリジナルのKotlinのコード位置ではなく変換後のJavaのコード位置をレポートします。一方で、KSPはオリジナルのKotlinのコード位置をレポートするのでユーザーにとって直感的です。
Komapperにおけるコード生成
Koampperでは、独自のアノテーションを使って次のようにエンティティを定義します。
@KomapperEntity
data class Person(
@KomapperId @KomapperAutoIncrement
val id: Int = 0,
val name: String
)
上記のコードをコンパイルするとこのエンティティ定義の詳細を保持するクラス(メタモデルと呼んでいます)のソースコードが出力されます。メタモデルは例えば次のような情報を保持します。
- マッピング対象のテーブル名
- 永続対象プロパティ
- プライマリキーに対応するプロパティ
- プライマリキーの値を生成する方法
- エンティティインスタンスを生成する方法
メモモデルのコードに興味がある方は、下記のアコーディオンを展開してみてください(長ったらしいので隠しています)。
メタモデルのコード(_Person.kt)
@file:Suppress("ClassName", "PrivatePropertyName", "UNUSED_PARAMETER", "unused", "RemoveRedundantQualifierName", "MemberVisibilityCanBePrivate", "RedundantNullableReturnType", "USELESS_CAST", "UNCHECKED_CAST", "RemoveRedundantBackticks")
package org.komapper.quickstart
// generated at 2022-07-18T10:35:40.923365+09:00[Asia/Tokyo]
@org.komapper.core.dsl.metamodel.EntityMetamodelImplementor(org.komapper.quickstart.Person::class)
class _Person private constructor(table: String = "person", catalog: String = "", schema: String = "", alwaysQuote: Boolean = false, disableSequenceAssignment: Boolean = false, declaration: org.komapper.core.dsl.metamodel.EntityMetamodelDeclaration<_Person> = {}) : org.komapper.core.dsl.metamodel.EntityMetamodel<org.komapper.quickstart.Person, kotlin.Int, _Person> {
private val __tableName = table
private val __catalogName = catalog
private val __schemaName = schema
private val __alwaysQuote = alwaysQuote
private val __disableSequenceAssignment = disableSequenceAssignment
private val __declaration = declaration
private object __EntityDescriptor {
val `id` = org.komapper.core.dsl.metamodel.PropertyDescriptor<org.komapper.quickstart.Person, kotlin.Int, kotlin.Int>(kotlin.Int::class, kotlin.Int::class, "id", "id", false, false, { it.`id` }, { e, v -> e.copy(`id` = v) }, { it }, { it }, false)
val `name` = org.komapper.core.dsl.metamodel.PropertyDescriptor<org.komapper.quickstart.Person, kotlin.String, kotlin.String>(kotlin.String::class, kotlin.String::class, "name", "name", false, false, { it.`name` }, { e, v -> e.copy(`name` = v) }, { it }, { it }, false)
}
val `id`: org.komapper.core.dsl.metamodel.PropertyMetamodel<org.komapper.quickstart.Person, kotlin.Int, kotlin.Int> by lazy { org.komapper.core.dsl.metamodel.PropertyMetamodelImpl(this, __EntityDescriptor.`id`) }
val `name`: org.komapper.core.dsl.metamodel.PropertyMetamodel<org.komapper.quickstart.Person, kotlin.String, kotlin.String> by lazy { org.komapper.core.dsl.metamodel.PropertyMetamodelImpl(this, __EntityDescriptor.`name`) }
override fun klass() = org.komapper.quickstart.Person::class
override fun tableName() = __tableName
override fun catalogName() = __catalogName
override fun schemaName() = __schemaName
override fun alwaysQuote() = __alwaysQuote
override fun disableSequenceAssignment() = __disableSequenceAssignment
override fun declaration() = __declaration
override fun idGenerator(): org.komapper.core.dsl.metamodel.IdGenerator<org.komapper.quickstart.Person, kotlin.Int>? = org.komapper.core.dsl.metamodel.IdGenerator.AutoIncrement(`id`)
override fun idProperties(): List<org.komapper.core.dsl.metamodel.PropertyMetamodel<org.komapper.quickstart.Person, *, *>> = listOf(`id`)
override fun versionProperty(): org.komapper.core.dsl.metamodel.PropertyMetamodel<org.komapper.quickstart.Person, *, *>? = null
override fun createdAtProperty(): org.komapper.core.dsl.metamodel.PropertyMetamodel<org.komapper.quickstart.Person, *, *>? = null
override fun updatedAtProperty(): org.komapper.core.dsl.metamodel.PropertyMetamodel<org.komapper.quickstart.Person, *, *>? = null
override fun properties(): List<org.komapper.core.dsl.metamodel.PropertyMetamodel<org.komapper.quickstart.Person, *, *>> = listOf(
`id`,
`name`)
override fun extractId(e: org.komapper.quickstart.Person): kotlin.Int = e.`id`
override fun convertToId(generatedKey: Long): kotlin.Int? = this.`id`.wrap(generatedKey.toInt())
override fun versionAssignment(): Pair<org.komapper.core.dsl.metamodel.PropertyMetamodel<org.komapper.quickstart.Person, *, *>, org.komapper.core.dsl.expression.Operand>? = null
override fun createdAtAssignment(c: java.time.Clock): Pair<org.komapper.core.dsl.metamodel.PropertyMetamodel<org.komapper.quickstart.Person, *, *>, org.komapper.core.dsl.expression.Operand>? = null
override fun updatedAtAssignment(c: java.time.Clock): Pair<org.komapper.core.dsl.metamodel.PropertyMetamodel<org.komapper.quickstart.Person, *, *>, org.komapper.core.dsl.expression.Operand>? = null
override fun preInsert(e: org.komapper.quickstart.Person, c: java.time.Clock): org.komapper.quickstart.Person = e
override fun preUpdate(e: org.komapper.quickstart.Person, c: java.time.Clock): org.komapper.quickstart.Person = e
override fun postUpdate(e: org.komapper.quickstart.Person): org.komapper.quickstart.Person = e
override fun newEntity(m: Map<org.komapper.core.dsl.metamodel.PropertyMetamodel<*, *, *>, Any?>) = org.komapper.quickstart.Person(
`id` = m[this.`id`] as kotlin.Int,
`name` = m[this.`name`] as kotlin.String)
override fun newMetamodel(table: String, catalog: String, schema: String, alwaysQuote: Boolean, disableSequenceAssignment: Boolean, declaration: org.komapper.core.dsl.metamodel.EntityMetamodelDeclaration<_Person>) = _Person(table, catalog, schema, alwaysQuote, disableSequenceAssignment, declaration)
fun clone(table: String = "person", catalog: String = "", schema: String = "", alwaysQuote: Boolean = false, disableSequenceAssignment: Boolean = false, declaration: org.komapper.core.dsl.metamodel.EntityMetamodelDeclaration<_Person> = {}) = _Person(table, catalog, schema, alwaysQuote, disableSequenceAssignment, declaration)
companion object {
init {
org.komapper.core.dsl.metamodel.checkMetamodelVersion("org.komapper.quickstart._Person", 1)
}
val `person` = _Person()
}
}
val org.komapper.core.dsl.Meta.`person` get() = _Person.`person`
コンパイル時コード生成のメリット
コンパイル時にメタモデルを生成するメリットは何でしょうか?Komapperにおいて代表的なものを2つ挙げます。
- リフレクションを避けられる
- メタモデルを利用して簡潔にコードが書ける
リフレクションを避けられる
エンティティ定義の詳細を取得する別の方法として、実行時にリフレクションを用いる方法があります。しかし、リフレクションにはいくつかデメリットがあります。
- 実行時エラーが増える(エンティティ定義に不備がある場合など実行時に判明する)
- リフレクションで取得された情報を確認するのが手間である(デバッガーやログを活用する必要がある)
- GraalVMを使ってネイティブイメージに変換するために追加の設定が必要である
コンパイル時のコード生成ではこれらの問題は起きません。1についてはコンパイル時に設定不備に気付けます。2については生成されるメタモデルのコードを目で見て確認できます。3については追加の設定が不要です。
メタモデルを利用して簡潔にコードが書ける
Komapperでは、メタモデルを利用して型の恩恵を受けつつSQLクエリを組み立てることができます。例えば、上述のPerson
に対応するメタモデルを使った例は以下の通りです。
// メタモデルのインスタンスを取得
val p = Meta.person
// クエリを定義
val query = QueryDsl.from(p).where { p.name greaterEq "K" }.orderBy(p.id)
p.name greaterEq "K"
の箇所ですが、もし"K"
という文字列ではなく別の型の値を渡したらコンパイルエラーになります。p.name
がString型専用のプロパティだからです。
上述のquery
を実行すると以下のようなSQLに変換されます。
select t0_.id, t0_.name from person as t0_ where t0_.name >= ? order by t0_.id asc
おまけ - KSP雑感
KSPの利用者として少し感想を述べてみたいと思います。
KSPはJSR 269のAPIを使ったことがある人なら直感的に使えると思います。全部ではないですが、大体似たようなAPIがあります。KSPはKotlin独自の言語機能(例えばdata classやvalue class)を判別できるので、JSR 269よりも応用範囲が広いと感じています。
もしかするとKSPの主要なターゲットはAndroidかもしれませんが、Android特化の何かがあるわけではないのでサーバサイド向けのKomapperのようなライブラリでも全く問題なく利用できます。
KSPの開発はGoogleの人を中心に活発です。KotlinのSlackに専用のチャネル(#ksp)もあり、質問の流通量も結構あります。
おわりに
Komapperのコンパイル時コード生成機能を紹介しました。もし興味があればクイックスタートなどお試しください。
参考情報
KSPのドキュメント
日本語によるKSPの解説動画
Discussion