jackson-moduole-kotlin
のvalue class
に関するシリアライズサポートは2.15にて大幅に強化されました(しました)。
value class
のシリアライズサポートが必要であれば、まずバージョンアップを検討するようお願いします。
また、jackson-module-kotlin
に関する実験的プロジェクトも有ります。
プロダクションコードでの利用は推奨しませんが、こちらではvalue class
のデシリアライズ、パラメータに付与されたアノテーションの有効化といった機能が利用できます。
この記事はSwift/Kotlin愛好会 Advent Calendar 2021
の8日目の記事になりました。
jackson-module-kotlinでのvalue classのシリアライズについて
jackson-module-kotlin
では、2.13.0
現在value class
/inline class
1のシリアライズに以下のような不具合が有ります。
- シリアライズ結果が異常な値になる場合がある
- カスタムシリアライザが機能しない場合がある
-
JsonSerialize
やJsonValue
を使ってのシリアライズ方法指定が機能しない
この記事では2.13.0
以前のjackson-module-kotlin
でもvalue class
を正常にシリアライズする方法について説明します。
技術的な解説については非常に長くなってしまったため別記事として公開予定ですしました。
なお、記事ではjackson-module-kotlin 2.13.0
/Kotlin 1.5.0
を前提にしています。
また、簡単のためkotlin-reflect
も利用します。
用語に関する補足
この記事の中では、value class
からプロパティの値を取り出すことを「unbox
する」、value class
のインスタンスを生成することを「box
する」と表現します。
ここで、「unbox
する」とした場合、取り出される値は必ずvalue class
のprimary constructor
の引数と同じになる点にはご注意下さい。
例えば以下のようなvalue class
が有った場合、Kotlin
からはjsonValue
プロパティにしかアクセスできませんが、unbox
した際に取り出されるのはvalue
プロパティの値になります。
@JvmInline
value class Value(private val value: Int) {
@get:JsonValue
val jsonValue get() = "JsonValue $value"
}
挙動に関する補足
jackson-module-kotlin
では、value class
のシリアライズ結果について、指定が無ければunbox
された値のシリアライズ結果に合わせる方針となっています。
一方、そうなっていない場面もいくつかあったため、バージョンアップ毎に段階的に修正が行われている状況です。
このため、利用するバージョンによってはこの記事で記載する内容と異なる挙動になる可能性が有る点にご注意下さい。
なお、Java Reflection
から見たvalue class
に関しては以前作成した資料が有りますので、よろしければそちらもご参照ください。
やること
以下に示す、value class
をプロパティに持つTarget
クラスをシリアライズするとします。
import com.fasterxml.jackson.annotation.JsonValue
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.SerializerProvider
import com.fasterxml.jackson.databind.annotation.JsonSerialize
import com.fasterxml.jackson.databind.ser.std.StdSerializer
data class Target(
// ULongとしてシリアライズしたい(※unsigned integersはvalue classとして定義されている)
val foo: ULong = ULong.MAX_VALUE,
// ObjectMapperに登録したカスタムシリアライザでシリアライズしたい
val bar: V1 = V1(1),
// JsonSerializeに設定したシリアライザでシリアライズしたい(この状態ではシリアライズが失敗するため一旦コメントアウト)
// @get:JsonSerialize(using = V2.CustomSerializer::class)
val baz: V2 = V2(2),
// JsonValueでシリアライズしたい
val qux: V3 = V3(3),
// 中身の値をunboxした形にシリアライズしたい(通常挙動の動作を変えていないことの確認)
val quux: V4 = V4(4)
)
@JvmInline
value class V1(val value: Int) {
object CustomSerializer : StdSerializer<V1>(V1::class.java) {
override fun serialize(value: V1, gen: JsonGenerator, provider: SerializerProvider) {
gen.writeString("CustomSerializer ${value.value}")
}
}
}
@JvmInline
value class V2(val value: Int) {
class CustomSerializer : StdSerializer<V2>(V2::class.java) {
override fun serialize(value: V2, gen: JsonGenerator, provider: SerializerProvider) {
gen.writeString("JsonSerializer ${value.value}")
}
}
}
@JvmInline
value class V3(val value: Int) {
@get:JsonValue
val jsonValue get() = "JsonValue $value"
}
@JvmInline
value class V4(val value: Int)
これをそのままシリアライズすると以下のようになります。
import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.module.kotlin.jacksonMapperBuilder
val simpleModule = SimpleModule()
.addSerializer(V3.CustomSerializer)
val writer = jacksonMapperBuilder()
.addModule(simpleModule)
.build()
.writerWithDefaultPrettyPrinter()
println(writer.writeValueAsString(Target()))
{
"foo" : -1,
"bar" : 1,
"baz" : 2,
"qux" : 3,
"quux" : 4
}
ご覧の通り、quux
(unbox
した形にシリアライズしたい)プロパティ以外は正常にシリアライズされていません。
この記事では、Target
クラスを期待通りの形にシリアライズする方法について説明します。
やり方
Target
クラスを期待通りの形にシリアライズするには幾つかの問題を解決する必要が有るため、その方法を順番に解説していきます。
また、今回紹介する方法はリフレクションを多く使うため、シリアライズ性能が低下する点にご注意ください。
ULongをシリアライズする/ObjectMapperに登録したカスタムシリアライザを機能させる
まず、プロパティがULong
の場合と、ObjectMapper
に登録したカスタムシリアライザで正常にシリアライズする方法を紹介します。
はじめに以下のようなシリアライザを定義します。
このシリアライザはjackson
が処理する値の見え方をKotlin
に合わせるような効果が有ります。
ただし、このシリアライザをSerializers
などでObjectMapper
に登録する方法は機能しない点にご注意ください。
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.SerializerProvider
import com.fasterxml.jackson.databind.ser.std.StdSerializer
// outerClazzはvalue class、innerClazzはvalue classの中身の型
class ValueClassBoxSerializer<T : Any>(
private val outerClazz: Class<out Any>, innerClazz: Class<T>
) : StdSerializer<T>(innerClazz) {
private val boxMethod = outerClazz.getMethod("box-impl", innerClazz)
// valueはvalue classの中身、nullの場合が有る
override fun serialize(value: T?, gen: JsonGenerator, provider: SerializerProvider) {
// unboxされている値をvalue classにboxする
// 元々constructor-implを経由した値だと思われるため、その呼び出しは省略してもよい
val boxed: Any = boxMethod.invoke(null, value)
// boxされた値に対するシリアライザを検索し、シリアライズする
provider.findValueSerializer(outerClazz).serialize(boxed, gen, provider)
}
}
次に、このシリアライザを機能させるためのAnnotationIntrospector
を定義します。
import com.fasterxml.jackson.databind.introspect.Annotated
import com.fasterxml.jackson.databind.introspect.AnnotatedMethod
import com.fasterxml.jackson.databind.introspect.NopAnnotationIntrospector
import java.lang.reflect.Method
import kotlin.reflect.KClass
import kotlin.reflect.KProperty
import kotlin.reflect.full.memberProperties
import kotlin.reflect.jvm.javaGetter
object ValueClassSerializeAnnotationIntrospector : NopAnnotationIntrospector() {
private fun findSerializerForValueClass(am: Annotated): Any? = if (am is AnnotatedMethod) {
val getter = am.member.apply {
// getterが直接value classを返している場合は何もしない
if (this.returnType.isValue) return null
}
val kotlinProperty = getter.getKotlinProperty()
val returnClazz = kotlinProperty?.returnType?.classifier as? KClass<*>
// 取得したプロパティのKotlin上の戻り値がvalue classであれば、対応するシリアライザを返す
returnClazz
?.takeIf { it.isValue }
?.java
?.let { outerClazz ->
@Suppress("UNCHECKED_CAST")
ValueClassBoxSerializer(outerClazz, getter.returnType)
}
} else {
// value classはJvmFieldにできないため、getter以外のパターンは無視してよい
null
}
override fun findSerializer(am: Annotated): Any? = findSerializerForValueClass(am)
// value classの中身がnullだった場合、findNullSerializerで処理する必要がある
override fun findNullSerializer(am: Annotated): Any? = findSerializerForValueClass(am)
}
// Classがvalue classかの判定
private val Class<*>.isValue: Boolean get() = this.kotlin.isValue
// Java GetterからKotlin Propertyへの変換
private fun Method.getKotlinProperty(): KProperty<*>? = this
.declaringClass
.kotlin
.let {
// memberPropertiesの取得はエッジケースで失敗することが有るため、エラーが出た場合は取得できなかったと見なす
try { it.memberProperties } catch (e: Error) { null }
}?.find { it.javaGetter == this }
最後に、このAnnotationIntrospector
を以下のようなSimpleModule
経由でObjectMapper
に登録します。
val simpleModule = object : SimpleModule() {
init { addSerializer(V1.CustomSerializer) }
override fun setupModule(context: SetupContext) {
super.setupModule(context)
context.appendAnnotationIntrospector(ValueClassSerializeAnnotationIntrospector)
}
}
すると、実行結果が以下のように変化します。
ULong
とObjectMapper
に登録したカスタムシリアライザで正常にシリアライズできるようになっています。
{
+ "foo" : 18446744073709551615,
- "foo" : -1,
+ "bar" : "CustomSerializer 1",
- "bar" : 1,
"baz" : 2,
"qux" : 3,
"quux" : 4
}
JsonSerializeに設定したシリアライザでシリアライズする
次に、JsonSerialize
に設定したシリアライザでシリアライズする方法を紹介します。
これを機能させるためには、カスタムシリアライザ側でunbox
された値を処理する形に修正する必要があります。
@JvmInline
value class V2(val value: Int) {
+ class CustomSerializer : StdSerializer<Int>(Int::class.java) {
+ // valueにはunboxされた値が入力される
+ override fun serialize(value: Int, gen: JsonGenerator, provider: SerializerProvider) {
+ gen.writeString("JsonSerializer ${V2(value).value}")
- class CustomSerializer : StdSerializer<V2>(V2::class.java) {
- override fun serialize(value: V2, gen: JsonGenerator, provider: SerializerProvider) {
- gen.writeString("JsonSerializer ${value.value}")
}
}
}
次に、Target
クラスでJsonSerializ
アノテーションに対して行っていたコメントアウトを解除します。
// JsonSerializeに設定したシリアライザでシリアライズしたい(この状態ではシリアライズが失敗するため一旦コメントアウト)
+ @get:JsonSerialize(using = V2.CustomSerializer::class)
- // @get:JsonSerialize(using = V2.CustomSerializer::class)
この修正を行うと、実行結果が以下のように変化します。
JsonSerialize
に設定したシリアライザで正常にシリアライズできるようになっています。
{
"foo" : 18446744073709551615,
"bar" : "CustomSerializer 1",
+ "baz" : "JsonSerializer 2",
- "baz" : 2,
"qux" : 3,
"quux" : 4
}
注意点
JsonSerialize
を用いる場合の注意点が2つ有ります。
1つ目は、serialize
に入力される値についてです。
用語に関する補足で書いた通り、ここに入ってくる値はprimary constructor
の引数と同じになります。
このため、Kotlin
上でアクセスできる値とは異なる値が入力となる場合が有ります。
例えば、プロパティの値がUInt.MAX_VALUE
だった場合、serialize
への入力は-1
になります。
2つ目は、アノテーションの付与方法についてです。
jackson-module-kotlin
では、通常get:
無しでアノテーションを付与した場合も正常にシリアライズが行われます。
一方、value class
に対してはget:
を付与しなければ正常にシリアライズされません。
JsonValueでシリアライズする
最後に、JsonValue
でシリアライズする方法を紹介します。
はじめに、以下のようなシリアライザを定義します。
このシリアライザに関しても、ObjectMapper
に登録する方法は機能しません。
import com.fasterxml.jackson.annotation.JsonValue
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.SerializerProvider
import com.fasterxml.jackson.databind.ser.std.StdSerializer
import java.lang.reflect.Method
import java.lang.reflect.Modifier
class ValueClassJsonValueSerializer<T> private constructor(
innerClazz: Class<T>,
private val jsonValueGetter: Method
) : StdSerializer<T>(innerClazz) {
override fun serialize(value: T?, gen: JsonGenerator, provider: SerializerProvider) {
// ファクトリメソッドの通り、jsonValueGetterは必ずstaticメソッド
val jsonValue: Any? = jsonValueGetter.invoke(null, value)
jsonValue
?.let { provider.findValueSerializer(it::class.java).serialize(it, gen, provider) }
?: provider.findNullValueSerializer(null).serialize(null, gen, provider)
}
companion object {
fun <T> createdOrNull(
outerClazz: Class<out Any>,
innerClazz: Class<T>
): ValueClassJsonValueSerializer<T>? = outerClazz.declaredMethods.find { method ->
// JsonValueを持つstaticメソッドのみ対象とする
// インスタンスメソッドに付与されるパターンはunboxされるのと等価なため無視してよい
method.annotations.any { it is JsonValue } && Modifier.isStatic(method.modifiers)
}?.let { ValueClassJsonValueSerializer(innerClazz, it) }
}
}
次に、先ほど作成していたValueClassSerializeAnnotationIntrospector
に、このシリアライザに関する処理を追加します。
これによって、JsonValue
の設定されたvalue class
はValueClassJsonValueSerializer
で、それ以外はValueClassBoxSerializer
で処理されるようになります。
// 取得したプロパティのKotlin上の戻り値がvalue classであれば、対応するシリアライザを返す
returnClazz
?.takeIf { it.isValue }
?.java
?.let { outerClazz ->
+ val innerClazz = getter.returnType
+
+ ValueClassJsonValueSerializer.createdOrNull(outerClazz, innerClazz)
+ ?: @Suppress("UNCHECKED_CAST")
+ ValueClassBoxSerializer(outerClazz, getter.returnType)
- @Suppress("UNCHECKED_CAST")
- ValueClassBoxSerializer(outerClazz, getter.returnType)
}
この修正を行うと、実行結果が以下のように変化します。
JsonValue
に設定した値でシリアライズできるようになっています。
{
"foo" : 18446744073709551615,
"bar" : "CustomSerializer 1",
"baz" : "JsonSerializer 2",
+ "qux" : "JsonValue 3",
- "qux" : 3,
"quux" : 4
}
終わりに
この記事ではjackon-module-kotlin
でvalue class
をシリアライズする方法についてまとめました。
value class
はKotlin 1.5
で正式化した機能ですが、Java
/Kotlin
間の見え方の違いや、Kotlin
のバージョンアップに伴い名称・記法の変更が有ったことから、jackon-module-kotlin
での対応が難しい状況があります。
この記事がjackson-module-kotlin
での対応を待たずにvalue class
を使いたい方のお役に立てれば幸いです。
技術的な解説に関する記事も後日公開予定ですしましたので、もしよろしければそちらもご覧下さい。
-
以後は現在の名称である
value class
に合わせます。 ↩