Skip to content
This repository has been archived by the owner on Jan 20, 2023. It is now read-only.

Latest commit

 

History

History
622 lines (472 loc) · 25.1 KB

README.ja.md

File metadata and controls

622 lines (472 loc) · 25.1 KB

License CircleCI codecov

KMapper

KMapperKotlin向けのマッパーライブラリであり、以下の機能を提供します。

  • オブジェクトやMapPairをソースとしたBeanマッピング
  • Kotlinのリフレクションを用いた関数呼び出しベースの安全なマッピング
  • 豊富な機能による、より柔軟かつ労力の少ないマッピング

以下のリポジトリに簡単なベンチマーク結果を掲載しています。

デモコード

手動でマッピングコードを書いた場合とKMapperを用いた場合を比較します。
手動で書く場合引数が多ければ多いほど記述がかさみますが、KMapperを用いることで殆どコードを書かずにマッピングを行えます。
また、外部の設定ファイルは一切必要ありません。

// 手動でマッピングを行う場合
val dst = Dst(
    param1 = src.param1,
    param2 = src.param2,
    param3 = src.param3,
    param4 = src.param4,
    param5 = src.param5,
    ...
)

// KMapperを用いる場合
val dst = KMapper(::Dst).map(src)

ソースは1つに限らず、複数のオブジェクトや、PairMap等を指定することもできます。

val dst = KMapper(::Dst).map(
    "param1" to "value of param1",
    mapOf("param2" to 1, "param3" to 2L),
    src1,
    src2
)

インストール方法

KMapperJitPackにて公開しており、MavenGradleといったビルドツールから手軽に利用できます。
各ツールでの正確なインストール方法については下記をご参照ください。

Mavenでのインストール方法

以下はMavenでのインストール例です。

1. JitPackのリポジトリへの参照を追加する

<repositories>
    <repository>
        <id>jitpack.io</id>
        <url>https://jitpack.io</url>
    </repository>
</repositories>

2. dependencyを追加する

<dependency>
    <groupId>com.github.ProjectMapK</groupId>
    <artifactId>KMapper</artifactId>
    <version>Tag</version>
</dependency>

動作原理

KMapperは以下のように動作します。

  1. 呼び出し対象のKFunctionを取り出す
  2. KFunctionを解析し、必要な引数とその取り出し方を決定する
  3. 入力からそれぞれの引数に対応する値の取り出しを行い、KFunctionを呼び出す

最終的にはコンストラクタやcompanion objectに定義したファクトリーメソッドなどを呼び出してマッピングを行うため、結果はKotlin上の引数・nullability等の制約に従います。
つまり、Kotlinnull安全が壊れることによる実行時エラーは発生しません(ただし、型引数のnullabilityに関してはnull安全が壊れる場合が有ります)。

また、Kotlin特有の機能であるデフォルト引数等にも対応しています。

マッパークラスの種類について

このプロジェクトでは以下の3種類のマッパークラスを提供しています。

  • KMapper
  • PlainKMapper
  • BoundKMapper

以下にそれぞれの特徴と使いどころをまとめます。
また、これ以降共通の機能に関してはKMapperを例に説明を行います。

KMapper

KMapperはこのプロジェクトの基本となるマッパークラスです。
内部ではキャッシュを用いたマッピングの高速化などを行っているため、マッパーを使い回す形での利用に向きます。

PlainKMapper

PlainKMapperKMapperからキャッシュ機能を取り除いたマッパークラスです。
複数回マッピングを行った場合の性能はKMapperに劣りますが、キャッシュ処理のオーバーヘッドが無いため、マッパーを使い捨てる形での利用に向きます。

BoundKMapper

BoundKMapperはソースとなるクラスが1つに限定できる場合に利用できるマッピングクラスです。
KMapperに比べ高速に動作します。

KMapperの初期化

KMapperは呼び出し対象のmethod reference(KFunction)、またはマッピング先のKClassから初期化できます。

以下にそれぞれの初期化方法をまとめます。
ただし、BoundKMapperの初期化の内可能なものは全てダミーコンストラクタによって簡略化した例を示します。

method reference(KFunction)からの初期化

プライマリコンストラクタを呼び出し対象とする場合、以下のように初期化を行うことができます。

data class Dst(
    foo: String,
    bar: String,
    baz: Int?,

    ...

)

// コンストラクタのメソッドリファレンスを取得
val dstConstructor: KFunction<Dst> = ::Dst

// KMapperの場合
val kMapper: KMapper<Dst> = KMapper(dstConstructor)
// PlainKMapperの場合
val plainMapper: PlainKMapper<Dst> = PlainKMapper(dstConstructor)
// BoundKMapperの場合
val boundKMapper: BoundKMapper<Src, Dst> = BoundKMapper(dstConstructor)

KClassからの初期化

KMapperKClassからも初期化できます。
デフォルトではプライマリーコンストラクタが呼び出し対象になります。

data class Dst(...)

// KMapperの場合
val kMapper: KMapper<Dst> = KMapper(Dst::class)
// PlainKMapperの場合
val plainMapper: PlainKMapper<Dst> = PlainKMapper(Dst::class)
// BoundKMapperの場合
val boundKMapper: BoundKMapper<Src, Dst> = BoundKMapper(Dst::class, Src::class)

ダミーコンストラクタを用い、かつジェネリクスを省略することで、それぞれ以下のようにも書けます。

// KMapperの場合
val kMapper: KMapper<Dst> = KMapper()
// PlainKMapperの場合
val plainMapper: PlainKMapper<Dst> = PlainKMapper()
// BoundKMapperの場合
val boundKMapper: BoundKMapper<Src, Dst> = BoundKMapper()

KConstructorアノテーションによる呼び出し対象指定

KClassから初期化を行う場合、全てのマッパークラスではKConstructorアノテーションを用いて呼び出し対象の関数を指定することができます。

以下の例ではセカンダリーコンストラクタが呼び出されます。

data class Dst(...) {
    @KConstructor
    constructor(...) : this(...)
}

val mapper: KMapper<Dst> = KMapper(Dst::class)

同様に、以下の例ではファクトリーメソッドが呼び出されます。

data class Dst(...) {
    companion object {
        @KConstructor
        fun factory(...): Dst {
            ...
        }
    }
}

val mapper: KMapper<Dst> = KMapper(Dst::class)

詳細な使い方

マッピング時の値の変換

マッピングを行うに当たり、入力の型を別の型に変換したい場合が有ります。
KMapperでは、そのような状況に対応するため、豊富な変換機能を提供しています。

ただし、この変換処理は以下の条件でのみ行われます。

  • 入力が非null
    • nullが絡む場合はKParameterRequireNonNullアノテーションとデフォルト引数を組み合わせることを推奨します
  • 入力が引数に直接代入できない

デフォルトで利用可能な変換

いくつかの変換機能は、特別な記述無しに利用することができます。

1対1変換(ネストしたマッピング)

引数をそのまま用いることができず、かつその他の変換も行えない場合、KMapperは内部でマッピングクラスを用い、1対1マッピングを試みます。
これによって、デフォルトで以下のようなネストしたマッピングを行うことができます。

data class InnerDst(val foo: Int, val bar: Int)
data class Dst(val param: InnerDst)

data class InnerSrc(val foo: Int, val bar: Int)
data class Src(val param: InnerSrc)

val src = Src(InnerSrc(1, 2))
val dst = KMapper(::Dst).map(src)

println(dst.param) // -> InnerDst(foo=1, bar=2)
ネストしたマッピングに用いられる関数の指定

ネストしたマッピングは、BoundKMapperをクラスから初期化して用いることで行われます。
このため、KConstructorアノテーションを用いて呼び出し対象を指定することができます。

その他の変換
StringからEnumへの変換

入力がStringで、かつ引数がEnumだった場合、入力と対応するnameを持つEnumへの変換が試みられます。

enum class FizzBuzz {
    Fizz, Buzz, FizzBuzz;
}

data class Dst(val fizzBuzz: FizzBuzz)

val dst = KMapper(::Dst).map("fizzBuzz" to "Fizz")
println(dst) // -> Dst(fizzBuzz=Fizz)
Stringへの変換

引数がStringだった場合、入力をtoStringする変換が行われます。

KConverterアノテーションを設定することによる変換

自作のクラスで、かつ単一引数から初期化できる場合、KConverterアノテーションを用いた変換が利用できます。
KConverterアノテーションは、コンストラクタ、もしくはcompanion objectに定義したファクトリーメソッドに対して付与できます。

// プライマリーコンストラクタに付与した場合
data class FooId @KConverter constructor(val id: Int)
// セカンダリーコンストラクタに付与した場合
data class FooId(val id: Int) {
    @KConverter
    constructor(id: String) : this(id.toInt())
}
// ファクトリーメソッドに付与した場合
data class FooId(val id: Int) {
    companion object {
        @KConverter
        fun of(id: String): FooId = FooId(id.toInt())
    }
}
// fooIdにKConverterが付与されていればDstでは何もせずに正常にマッピングができる
data class Dst(
    fooId: FooId,
    bar: String,
    baz: Int?,

    ...

)

コンバートアノテーションを自作しての変換

1対1の変換でKConverterを用いることができない場合、コンバートアノテーションを自作してパラメータに付与することで変換を行うことができます。

コンバートアノテーションの自作はコンバートアノテーションとコンバータの組を定義することで行います。
例としてjava.sql.Timestampもしくはjava.time.Instantから指定したタイムゾーンのZonedDateTimeに変換を行うZonedDateTimeConverterの作成の様子を示します。

コンバートアノテーションを定義する

@Target(AnnotationTarget.VALUE_PARAMETER)KConvertByアノテーション、他幾つかのアノテーションを付与することで、コンバートアノテーションを定義できます。

KConvertByアノテーションの引数は、後述するコンバーターのKClassを渡します。
このコンバーターはソースとなる型ごとに定義する必要があります。

また、この例ではアノテーションに引数を定義していますが、この値はコンバーターから参照することができます。

@Target(AnnotationTarget.VALUE_PARAMETER)
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
@KConvertBy([TimestampToZonedDateTimeConverter::class, InstantToZonedDateTimeConverter::class])
annotation class ZonedDateTimeConverter(val zoneIdOf: String)
コンバーターを定義する

コンバーターはAbstractKConverter<A, S, D>を継承して定義します。
ジェネリクスA,S,Dはそれぞれ以下の意味が有ります。

  • A: コンバートアノテーションのType
  • S: 変換前のType
  • D: 変換後のType

以下はjava.sql.TimestampからZonedDateTimeへ変換を行うコンバーターの例です。

class TimestampToZonedDateTimeConverter(
    annotation: ZonedDateTimeConverter
) : AbstractKConverter<ZonedDateTimeConverter, Timestamp, ZonedDateTime>(annotation) {
    private val timeZone = ZoneId.of(annotation.zoneIdOf)

    override val srcClass: KClass<Timestamp> = Timestamp::class

    override fun convert(source: Timestamp): ZonedDateTime = ZonedDateTime.of(source.toLocalDateTime(), timeZone)
}

コンバーターのプライマリコンストラクタの引数はコンバートアノテーションのみ取る必要が有ります。
これはKMapperの初期化時に呼び出されます。

例の通り、アノテーションに定義した引数は適宜参照することができます。

付与する

ここまでで定義したコンバートアノテーションとコンバーターをまとめて書くと以下のようになります。
InstantToZonedDateTimeConverterjava.time.Instantをソースとするコンバーターです。

@Target(AnnotationTarget.VALUE_PARAMETER)
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
@KConvertBy([TimestampToZonedDateTimeConverter::class, InstantToZonedDateTimeConverter::class])
annotation class ZonedDateTimeConverter(val zoneIdOf: String)

class TimestampToZonedDateTimeConverter(
    annotation: ZonedDateTimeConverter
) : AbstractKConverter<ZonedDateTimeConverter, Timestamp, ZonedDateTime>(annotation) {
    private val timeZone = ZoneId.of(annotation.zoneIdOf)

    override val srcClass: KClass<Timestamp> = Timestamp::class

    override fun convert(source: Timestamp): ZonedDateTime = ZonedDateTime.of(source.toLocalDateTime(), timeZone)
}

class InstantToZonedDateTimeConverter(
    annotation: ZonedDateTimeConverter
) : AbstractKConverter<ZonedDateTimeConverter, Instant, ZonedDateTime>(annotation) {
    private val timeZone = ZoneId.of(annotation.zoneIdOf)

    override val srcClass: KClass<Instant> = Instant::class

    override fun convert(source: Instant): ZonedDateTime = ZonedDateTime.ofInstant(source, timeZone)
}

これを付与すると以下のようになります。

data class Dst(
    @ZonedDateTimeConverter("Asia/Tokyo")
    val t1: ZonedDateTime,
    @ZonedDateTimeConverter("-03:00")
    val t2: ZonedDateTime
)

複数引数からの変換

以下のようなDstで、InnerDstをマップ元の複数のフィールドから変換したい場合、KParameterFlattenアノテーションが利用できます。

data class InnerDst(val fooFoo: Int, val barBar: String)
data class Dst(val bazBaz: InnerDst, val quxQux: LocalDateTime)

Dstのフィールド名をプレフィックスに指定する場合以下のように付与します。
ここで、KParameterFlattenを指定されたクラスは、前述のKConstructorアノテーションで指定した関数またはプライマリコンストラクタから初期化されます。

data class InnerDst(val fooFoo: Int, val barBar: String)
data class Dst(
    @KParameterFlatten
    val bazBaz: InnerDst,
    val quxQux: LocalDateTime
)
data class Src(val bazBazFooBoo: Int, val bazBazBarBar: String, val quxQux: LocalDateTime)

// bazBazFooFoo, bazBazBarBar, quxQuxの3引数が要求される
val mapper = KMapper(::Dst)
KParameterFlattenアノテーションのオプション

KParameterFlattenアノテーションはネストしたクラスの引数名の扱いについて2つのオプションを持ちます。

fieldNameToPrefix

KParameterFlattenアノテーションはデフォルトでは引数名をプレフィックスに置いた名前で一致を見ようとします。
引数名をプレフィックスに付けたくない場合はfieldNameToPrefixオプションにfalseを指定します。

data class InnerDst(val fooFoo: Int, val barBar: String)
data class Dst(
    @KParameterFlatten(fieldNameToPrefix = false)
    val bazBaz: InnerDst,
    val quxQux: LocalDateTime
)

// fooFoo, barBar, quxQuxの3引数が要求される
val mapper = KMapper(::Dst)

fieldNameToPrefix = falseを指定した場合、nameJoinerオプションは無視されます。

nameJoiner

nameJoinerは引数名と引数名の結合方法の指定です。
例えばSrcsnake_caseだった場合、以下のように利用します。

data class InnerDst(val fooFoo: Int, val barBar: String)
data class Dst(
    @KParameterFlatten(nameJoiner = NameJoiner.Snake::class)
    val bazBaz: InnerDst,
    val quxQux: LocalDateTime
)

// baz_baz_foo_foo, baz_baz_bar_bar, qux_quxの3引数が要求される
val mapper = KMapper(::Dst) { /* キャメル -> スネークの命名変換関数 */ }

デフォルトではcamelCaseが指定されており、snake_casekebab-caseのサポートも有ります。
NameJoinerクラスを継承したobjectを作成することで自作することもできます。

他の変換方法との併用

KParameterFlattenアノテーションを付与した場合も、これまでに紹介した変換方法は全て機能します。
また、KParameterFlattenアノテーションは何重にネストした中でも利用が可能です。

マッピング時に用いる引数名・フィールド名の設定

KMapperは、デフォルトでは引数名に対応する名前のフィールドをソースからそのまま探します。
一方、引数名とソースで違う名前を用いたいという場合も有ります。

KMapperでは、そのような状況に対応するため、マッピング時に用いる引数名・フィールド名を設定するいくつかの機能を提供しています。

引数名の変換

KMapperでは、初期化時に引数名の変換関数を設定することができます。
例えば引数の命名規則がキャメルケースかつソースの命名規則がスネークケースというような、一定の変換が要求される状況に対応することができます。

data class Dst(
    fooFoo: String,
    barBar: String,
    bazBaz: Int?
)

val mapper: KMapper<Dst> = KMapper(::Dst) { fieldName: String ->
    /* 命名変換処理 */
}

// 例えばスネークケースへの変換関数を渡すことで、以下のような入力にも対応できる
val dst = mapper.map(mapOf(
    "foo_foo" to "foo",
    "bar_bar" to "bar",
    "baz_baz" to 3
))

また、当然ながらラムダ内で任意の変換処理を行うこともできます。

引数名の変換処理の伝播について

引数名の変換処理は、ネストしたマッピングにも反映されます。
また、後述するKParameterAliasアノテーションで指定したエイリアスに関しても変換が適用されます。

実際の変換処理

KMapperでは命名変換処理を提供していませんが、プロジェクトでよく用いられるライブラリでも命名変換処理が提供されている場合が有ります。
JacksonGuavaの2つのライブラリで実際に「キャメルケース -> スネークケース」の変換処理を渡すサンプルコードを示します。

Jackson
import com.fasterxml.jackson.databind.PropertyNamingStrategy

val parameterNameConverter: (String) -> String = PropertyNamingStrategy.SnakeCaseStrategy()::translate
val mapper: KMapper<Dst> = KMapper(::Dst, parameterNameConverter)
Guava
import com.google.common.base.CaseFormat

val parameterNameConverter: (String) -> String = { fieldName: String ->
    CaseFormat.LOWER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, fieldName)
}
val mapper: KMapper<Dst> = KMapper(::Dst, parameterNameConverter)

ゲッターにエイリアスを設定する

以下のようなコードで、マッピング時にのみScrクラスの_fooフィールドの名前を変更する場合、KGetterAliasアノテーションを用いるのが最適です。

data class Dst(val foo: Int)
data class Src(val _foo: Int)

実際に付与すると以下のようになります。

data class Src(
    @get:KGetterAlias("foo")
    val _foo: Int
)

引数名にエイリアスを設定する

以下のようなコードで、マッピング時にのみDstクラスの_barフィールドの名前を変更する場合、KParameterAliasアノテーションを用いるのが最適です。

data class Dst(val _bar: Int)
data class Src(val bar: Int)

実際に付与すると以下のようになります。

data class Dst(
    @KParameterAlias("bar")
    val _bar: Int
)

その他機能

制御してデフォルト引数を用いる

KMapperでは、引数が指定されていなかった場合デフォルト引数を用います。
また、引数が指定されていた場合でも、それを用いるか制御することができます。

必ずデフォルト引数を用いる

必ずデフォルト引数を用いたい場合、KUseDefaultArgumentアノテーションを利用できます。

class Foo(
    ...,
    @KUseDefaultArgument
    val description: String = ""
)
対応する内容が全てnullの場合デフォルト引数を用いる

KParameterRequireNonNullアノテーションを指定することで、引数としてnon nullな値が指定されるまで入力をスキップします。
これを利用することで、対応する内容が全てnullの場合デフォルト引数を用いるという挙動が実現できます。

class Foo(
    ...,
    @KParameterRequireNonNull
    val description: String = ""
)

マッピング時にフィールドを無視する

何らかの理由でマッピング時にフィールドを無視したい場合、KGetterIgnoreアノテーションを用いることができます。
例えば、以下のSrcクラスを入力した場合、param1フィールドは読み出し処理が行われません。

data class Src(
    @KGetterIgnore
    val param1: Int,
    val param2: Int
)

引数のセットアップ

引数読み出しの対象

KMapperは、オブジェクトのpublicフィールド、もしくはPair<String, Any?>Map<String, Any?>のプロパティを読み出しの対象とすることができます。

引数のセットアップ

KMapperは、値がnullでなければセットアップ処理を行います。
セットアップ処理では、まずparameterClazz.isSuperclassOf(inputClazz)で入力が引数に設定可能かを判定し、そのままでは設定できない場合は後述する変換処理を行い、結果を引数とします。

値がnullだった場合はKParameterRequireNonNullアノテーションの有無を確認し、設定されていればセットアップ処理をスキップ、されていなければnullをそのまま引数とします。

KUseDefaultArgumentアノテーションが設定されていたり、KParameterRequireNonNullアノテーションによって全ての入力がスキップされた場合、デフォルト引数が用いられます。
ここでデフォルト引数が利用できなかった場合は実行時エラーとなります。

引数の変換処理

KMapperは、以下の順序で変換内容のチェック及び変換処理を行います。

1. アノテーションによる変換処理の指定の確認 まず初めに、入力のクラスに対応する、KConvertByアノテーションやKConverterアノテーションによって指定された変換処理が無いかを確認します。

2. Enumへの変換可否の確認 入力がStringで、かつ引数がEnumだった場合、入力と対応するnameを持つEnumへの変換を試みます。

3. 文字列への変換可否の確認 引数がStringの場合、入力をtoStringします。

4. マッパークラスを用いた変換処理 ここまでの変換条件に合致しなかった場合、マッパークラスを用いてネストした変換処理を行います。
このマッピング処理には、PlainKMapperPlainKMapperを、それ以外はBoundKMapperを用います。

入力の優先度

KMapperでは、基本的に先に入った入力可能な引数を優先します。
例えば、以下の例ではparam1として先にvalue1が指定されているため、"param1" to "value2"は無視されます。

val mapper: KMapper<Dst> = ...

val dst = mapper.map("param1" to "value1", "param1" to "value2")

ただし、KParameterRequireNonNullアノテーションが指定された引数に対応する入力としてnullが指定された場合、その入力は無視され、後から入った引数が優先されます。