LoginSignup
3
1

jackon-module-kotlinでvalue class(inline class)をシリアライズする

Last updated at Posted at 2021-12-24

jackson-moduole-kotlinvalue 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 class1のシリアライズに以下のような不具合が有ります。

  • シリアライズ結果が異常な値になる場合がある
  • カスタムシリアライザが機能しない場合がある
  • JsonSerializeJsonValueを使ってのシリアライズ方法指定が機能しない

この記事では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 classprimary 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
}

ご覧の通り、quuxunboxした形にシリアライズしたい)プロパティ以外は正常にシリアライズされていません。
この記事では、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)
    }
}

すると、実行結果が以下のように変化します。
ULongObjectMapperに登録したカスタムシリアライザで正常にシリアライズできるようになっています。

実行結果
{
+ "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 classValueClassJsonValueSerializerで、それ以外は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-kotlinvalue classをシリアライズする方法についてまとめました。

value classKotlin 1.5で正式化した機能ですが、Java/Kotlin間の見え方の違いや、Kotlinのバージョンアップに伴い名称・記法の変更が有ったことから、jackon-module-kotlinでの対応が難しい状況があります。
この記事がjackson-module-kotlinでの対応を待たずにvalue classを使いたい方のお役に立てれば幸いです。

技術的な解説に関する記事も後日公開予定ですしましたので、もしよろしければそちらもご覧下さい。

  1. 以後は現在の名称であるvalue classに合わせます。

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1