アプリケーションエンジニアの宮田です。
自分のチームでは、10月から既存のSpringBoot
プロジェクトへKotlin
の導入を進めています。
今回は、そんな中で直面した課題への対策として、「Kotlin
フレンドリーなBeanPropertyRowMapper
的なもの」を作った件についてまとめます。
BeanPropertyRowMapperとは
まずはじめに、RowMapper
インターフェースと、その実装の1つであるBeanPropertyRowMapper
について説明します。
RowMapper
とは、JdbcTemplate::query
等の処理を実行した際に、DBから取得した結果とJava
のオブジェクトとを紐づける機能のインターフェースです。
RowMapper
を手動で書いた場合、以下のようになります。
ResultSet
からカラム名と対応する型でそれぞれ取得を行い、SampleClass
のコンストラクタを呼び出しています。
jdbcTemplate.query("select * from sample_table") { rs: ResultSet, _: Int -> SampleClass(rs.getInt("sample_id"), rs.getString("sample_value")) }
BeanPropertyRowMapperを使う場合
BeanPropertyRowMapper
はorg.springframework.jdbc.core
に定義されたRowMapper
の一種で、POJO
に対してマッピングを行うことができます。
先ほどの例をBeanPropertyRowMapper
で書き換える場合、以下のようになります。
jdbcTemplate.query("select * from sample_table", BeanPropertyRowMapper(SampleClass::class.java))
カラム名対フィールド名の対応などはBeanPropertyRowMapper
がよしなに扱ってくれるため、Class
を渡すだけでマッピングができます。
このため、RowMapper
を手動で書いた場合に比べると省力化できます。
BeanPropertyRowMapperの問題点
先述の通り便利なBeanPropertyRowMapper
ですが、コンストラクタでval
のフイールドを初期化するような場合には使うことができません。
BeanPropertyRowMapper
は、以下の手順でマッピングを行うからです。
- 引数無しコンストラクタでオブジェクトを初期化
- セッター経由でフィールドを初期化
この仕様はKotlin
との相性が非常に悪いです。
一方、RowMapper
を手動で書けばサンプルの通りコンストラクタ呼び出しもできますが、対応するカラム名を自力で文字列として書く必要があるなど、特に大規模なオブジェクトの取得時には大変な労力がかかります。
そして、この問題を解決できるようなライブラリを見つけることはできませんでした。
この問題を解決するためにやったこと
この問題を解決するため、KClass
をよしなに扱って取得結果と紐づけるRowMapper
を作ってみました。
最低限の機能として以下を備えています。
- プライマリコンストラクタを取得結果に対して呼び出せる(=
data class
対応) - (
Enum::valueOf
で取得できる)Enum
を紐づけられる
コードは以下の通りです。
import org.springframework.jdbc.core.RowMapper import java.sql.ResultSet import kotlin.reflect.KClass import kotlin.reflect.KFunction import kotlin.reflect.KParameter import kotlin.reflect.full.primaryConstructor private class ParameterForMap(val param: KParameter, propertyNameConverter: (String) -> String = { it }) { val clazz: Class<*> = (param.type.classifier as KClass<*>).java val name: String = propertyNameConverter(param.name!!) } class DataRowMapper<T: Any>(clazz: KClass<T>, propertyNameConverter: (String) -> String = { it }): RowMapper<T> { private val constructor: KFunction<T> = clazz.primaryConstructor!! // 処理対象コンストラクタ // コンストラクタのパラメータを処理しやすいようにまとめたもの private val parameters: Set<ParameterForMap> = constructor.parameters .map { ParameterForMap(it, propertyNameConverter) } .toSet() override fun mapRow(rs: ResultSet, rowNum: Int): T = parameters.associate { it.param to when (it.clazz.isEnum) { true -> EnumMapper.getEnum(it.clazz, rs.getObject(it.name, String::class.java)) false -> rs.getObject(it.name, it.clazz) } }.let { constructor.callBy(it) } }
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; public class EnumMapper { /** * Kotlinの型推論バグでクラスからvalueOfが使えないため、ここだけJavaで書いている(型引数もT extends Enumでは書けなかった) * @param clazz Class of Enum * @param value StringValue * @param <T> enumClass * @return Enum.valueOf */ @SuppressWarnings("unchecked") @Nullable public static <T> T getEnum(@NotNull Class<T> clazz, @Nullable String value) { if (value == null || value.length() == 0) { return null; } return (T) Enum.valueOf((Class<? extends Enum>) clazz, value); } }
以下、これらのコードについて解説していきます。
大雑把な処理の流れ
まず、大雑把な処理の流れは以下の通りです。
1 ~ 3までが初期化処理で、4, 5がマップ処理です。
KClass
からプライマリコンストラクタを取得する- 1で取得したコンストラクタから引数(
KParameter
)一覧を取得する - 2に対してそれぞれ(カラム名と対応するよう変換した)引数名と
Class
を取得する - 3に対して
ResultSet::getObject
でオブジェクトを取得する(Enum
の場合は文字列として取得した上でEnumMapper
による変換を行う) - 3, 4を合わせたパラメータから、1で取得したコンストラクタを呼び出す
細かな部分
次に、各処理における細かな部分を解説します。
コンストラクタとパラメータ関連の取得とDBからの取得結果とのマッピングについて
Kotlin
のプライマリコンストラクタから取得したKParameter
からは、パラメータ名とそのクラスが取得できます。
具体的にその処理をやっているのは以下のコードです。
private class ParameterForMap(val param: KParameter, propertyNameConverter: (String) -> String = { it }) { val clazz: Class<*> = (param.type.classifier as KClass<*>).java val name: String = propertyNameConverter(param.name!!) }
このプログラムでは、ここで得られる情報とDBからの取得結果とを突き合わせることでマッピングを行います。
フィールド名とカラム名のマップ
propertyNameConverter
は、フィールド名からカラム名への変換を担う関数です。
例えば、「フィールド名はキャメルケースだが、カラム名はスネークケース」といった場合に適切な変換を行うため用います。
これによって、ResultSet::getObject
に渡すカラム名を生成できます。
Enumのマップについて
このプログラムはEnum::valueOf
でマップできる値であればEnum
を自身の型にマップできますが、この変換処理はJava
で書いています。
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; public class EnumMapper { /** * Kotlinの型推論バグでクラスからvalueOfが使えないため、ここだけJavaで書いている(型引数もT extends Enumでは書けなかった) * @param clazz Class of Enum * @param value StringValue * @param <T> enumClass * @return Enum.valueOf */ @SuppressWarnings("unchecked") @Nullable public static <T> T getEnum(@NotNull Class<T> clazz, @Nullable String value) { if (value == null || value.length() == 0) { return null; } return (T) Enum.valueOf((Class<? extends Enum>) clazz, value); } }
理由はコメントの通りで、再帰的ジェネリクス周りの不具合が有り、Kotlin
から上手くEnum::valueOf
を呼ぶことができなかったためです。
Javaへの対応について
ここまで読んで頂いた皆様の中には「これならJava
でもできるのでは?/Java
のクラスに対しても利用できるのでは?」とお思いになる方もいらっしゃるかもしれませんが、残念ながらこのプログラムはJava
非対応です。
理由は、Java
のリフレクションで取得したコンストラクタからはパラメータ名が取得できないためです。
課題と今後の展望
最後に、課題と今後の展望についてまとめます。
課題
大きめな課題として、コンバータを渡せるように実装することを考えています。
例えば、Enum::valueOf
でマップできない内容など、このプログラムでマップできない内容を扱う場合には、「コンストラクタの型にマップ可能な型(e.g. String
)を指定 -> カスタムゲッター等で加工処理を実装」という流れを踏む必要があります。
無論これでも対応ができない訳ではありませんが、後述する展望を考えると、以下の機能は実装したいなと考えています。
- マッパーの初期化時にコンバータを渡し、それを用いてマッピングを行う
- メソッドにアノテーションを指定することで、そのメソッドをコンバータとして扱う
今後の展望
ここまで書いた内容をひねれば、Kotlin
フレンドリーなModelMapper
的なものを作ることも可能です。
ここから、Kotlin
で汎用的に使える「オブジェクト -> 関数やコンストラクタ呼び出し
」を行うマッパーを作っていくこともできるんじゃないかな、とも思います。
ただ、そういったことをやっていくのであれば、課題にも書いた通り、もう少し作り込みが必要でしょう。
「上手くいけばOSS化できないかな」なんてことも妄想したりしていますが、一旦自分たちの持つ課題は解決できたので、このブログではこれ以上触れないことにします。
終わりに
今回はBeanPropertyRowMapper
を置き換えるために「Kotlin
フレンドリーなBeanPropertyRowMapper
的なもの」を作った件についてまとめました。
この内容が何かのお役に立てば幸いです。
ここまでお読みいただきありがとうございました。