KMapperはKotlin向けのマッパーライブラリであり、以下の機能を提供します。
- オブジェクトや
Map、Pairをソースとした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つに限らず、複数のオブジェクトや、Pair、Map等を指定することもできます。
val dst = KMapper(::Dst).map(
"param1" to "value of param1",
mapOf("param2" to 1, "param3" to 2L),
src1,
src2
)KMapperはJitPackにて公開しており、MavenやGradleといったビルドツールから手軽に利用できます。
各ツールでの正確なインストール方法については下記をご参照ください。
以下は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は以下のように動作します。
- 呼び出し対象の
KFunctionを取り出す KFunctionを解析し、必要な引数とその取り出し方を決定する- 入力からそれぞれの引数に対応する値の取り出しを行い、
KFunctionを呼び出す
最終的にはコンストラクタやcompanion objectに定義したファクトリーメソッドなどを呼び出してマッピングを行うため、結果はKotlin上の引数・nullability等の制約に従います。
つまり、Kotlinのnull安全が壊れることによる実行時エラーは発生しません(ただし、型引数のnullabilityに関してはnull安全が壊れる場合が有ります)。
また、Kotlin特有の機能であるデフォルト引数等にも対応しています。
このプロジェクトでは以下の3種類のマッパークラスを提供しています。
KMapperPlainKMapperBoundKMapper
以下にそれぞれの特徴と使いどころをまとめます。
また、これ以降共通の機能に関してはKMapperを例に説明を行います。
KMapperはこのプロジェクトの基本となるマッパークラスです。
内部ではキャッシュを用いたマッピングの高速化などを行っているため、マッパーを使い回す形での利用に向きます。
PlainKMapperはKMapperからキャッシュ機能を取り除いたマッパークラスです。
複数回マッピングを行った場合の性能はKMapperに劣りますが、キャッシュ処理のオーバーヘッドが無いため、マッパーを使い捨てる形での利用に向きます。
BoundKMapperはソースとなるクラスが1つに限定できる場合に利用できるマッピングクラスです。
KMapperに比べ高速に動作します。
KMapperは呼び出し対象のmethod reference(KFunction)、またはマッピング先のKClassから初期化できます。
以下にそれぞれの初期化方法をまとめます。
ただし、BoundKMapperの初期化の内可能なものは全てダミーコンストラクタによって簡略化した例を示します。
プライマリコンストラクタを呼び出し対象とする場合、以下のように初期化を行うことができます。
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)KMapperはKClassからも初期化できます。
デフォルトではプライマリーコンストラクタが呼び出し対象になります。
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()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では、そのような状況に対応するため、豊富な変換機能を提供しています。
ただし、この変換処理は以下の条件でのみ行われます。
- 入力が非
nullnullが絡む場合はKParameterRequireNonNullアノテーションとデフォルト引数を組み合わせることを推奨します
- 入力が引数に直接代入できない
いくつかの変換機能は、特別な記述無しに利用することができます。
引数をそのまま用いることができず、かつその他の変換も行えない場合、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だった場合、入力と対応する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だった場合、入力をtoStringする変換が行われます。
自作のクラスで、かつ単一引数から初期化できる場合、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: コンバートアノテーションのTypeS: 変換前のTypeD: 変換後の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の初期化時に呼び出されます。
例の通り、アノテーションに定義した引数は適宜参照することができます。
ここまでで定義したコンバートアノテーションとコンバーターをまとめて書くと以下のようになります。
InstantToZonedDateTimeConverterはjava.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アノテーションはネストしたクラスの引数名の扱いについて2つのオプションを持ちます。
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は引数名と引数名の結合方法の指定です。
例えばSrcがsnake_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_caseとkebab-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では命名変換処理を提供していませんが、プロジェクトでよく用いられるライブラリでも命名変換処理が提供されている場合が有ります。
Jackson、Guavaの2つのライブラリで実際に「キャメルケース -> スネークケース」の変換処理を渡すサンプルコードを示します。
import com.fasterxml.jackson.databind.PropertyNamingStrategy
val parameterNameConverter: (String) -> String = PropertyNamingStrategy.SnakeCaseStrategy()::translate
val mapper: KMapper<Dst> = KMapper(::Dst, parameterNameConverter)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 = ""
)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. マッパークラスを用いた変換処理
ここまでの変換条件に合致しなかった場合、マッパークラスを用いてネストした変換処理を行います。
このマッピング処理には、PlainKMapperはPlainKMapperを、それ以外はBoundKMapperを用います。
KMapperでは、基本的に先に入った入力可能な引数を優先します。
例えば、以下の例ではparam1として先にvalue1が指定されているため、"param1" to "value2"は無視されます。
val mapper: KMapper<Dst> = ...
val dst = mapper.map("param1" to "value1", "param1" to "value2")ただし、KParameterRequireNonNullアノテーションが指定された引数に対応する入力としてnullが指定された場合、その入力は無視され、後から入った引数が優先されます。