MicroAd Developers Blog

マイクロアドのエンジニアブログです。インフラ、開発、分析について発信していきます。

KotlinフレンドリーなBeanPropertyRowMapper的なものを作った

アプリケーションエンジニアの宮田です。
自分のチームでは、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を使う場合

BeanPropertyRowMapperorg.springframework.jdbc.coreに定義されたRowMapperの一種で、POJOに対してマッピングを行うことができます。
先ほどの例をBeanPropertyRowMapperで書き換える場合、以下のようになります。

jdbcTemplate.query("select * from sample_table", BeanPropertyRowMapper(SampleClass::class.java))

カラム名対フィールド名の対応などはBeanPropertyRowMapperがよしなに扱ってくれるため、Classを渡すだけでマッピングができます。
このため、RowMapperを手動で書いた場合に比べると省力化できます。

BeanPropertyRowMapperの問題点

先述の通り便利なBeanPropertyRowMapperですが、コンストラクタでvalのフイールドを初期化するような場合には使うことができません。
BeanPropertyRowMapperは、以下の手順でマッピングを行うからです。

  1. 引数無しコンストラクタでオブジェクトを初期化
  2. セッター経由でフィールドを初期化

この仕様は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がマップ処理です。

  1. KClassからプライマリコンストラクタを取得する
  2. 1で取得したコンストラクタから引数(KParameter)一覧を取得する
  3. 2に対してそれぞれ(カラム名と対応するよう変換した)引数名とClassを取得する
  4. 3に対してResultSet::getObjectでオブジェクトを取得する(Enumの場合は文字列として取得した上でEnumMapperによる変換を行う)
  5. 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的なもの」を作った件についてまとめました。
この内容が何かのお役に立てば幸いです。

ここまでお読みいただきありがとうございました。