0
0

More than 1 year has passed since last update.

jackon-module-kotlinでvalue class(inline class)をシリアライズする【解説編】

Posted at

この記事はSwift/Kotlin愛好会 Advent Calendar 2021の9日目の記事になりました。

前書き

前回の記事では、jackon-module-kotlinvalue class(inline class)をシリアライズする方法についてまとめました。

前回の記事ではどのようにすれば動くかに絞って解説したため、「なんでこの書き方をするの?/この書き方で動くの?」という疑問を抱いた方もいらっしゃったと思います(本当に?)。
そこで、この記事では前回の記事で紹介した内容の内、コードを読んでも分からないような点に関して、技術的な解説を行っていきます。

なお、記事では引き続きjackson-module-kotlin 2.13.0/Kotlin 1.5.0を前提にしています。

シリアライズが異常になる原因

コードの解説に入る前に、前回紹介したシリアライズ関連の不具合が発生する原因について解説します。

この原因は、Kotlin上でvalue classが戻り値となるgetterが、Java上で特殊な形になることです。
前回利用したTargetクラスを例に解説します。

前回利用したシリアライズ対象クラス
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)
)

このクラスをデコンパイルし、getterのみ抜き出して整形すると以下のようになります。

デコンパイル結果(getterのみ抜き出して整形済み)
public final class Target {
   public final long getFoo-s-VKNKU() { return this.foo; }
   public final int getBar-Eu_8zPo() { return this.bar; }
   @JsonSerialize(using = V2.CustomSerializer.class)
   public final int getBaz-18JmITk() { return this.baz; }
   public final int getQux-ePNLYys() { return this.qux; }
   public final int getQuux-6jTAmKA() { return this.quux;}
}

通常のクラスに比べると、以下2つの変化が起きています。

  1. 戻り値がunboxedされた値になっている
  2. getterの名前にサフィックスが付いている

この内、今回の問題の原因になっているのは1です1
この変化が起きているため、シリアライズ時に参照される型が異なってしまい、期待した通りのシリアライズ結果が得られないという問題が発生します。

補足: nullableな内容が絡む場合

前回の例では出すことができなかったため、value classの中身がnullableな場合と、プロパティがnullableな場合に関しても見てみます。

@JvmInline
value class Value(val value: Int?)

data class Data(
    val hoge: Value,
    val fuga: Value?
)

デコンパイル結果は以下のようになります。

デコンパイル結果(getterのみ抜き出して整形済み)
public final class Data {
   @NotNull
   public final Integer getHoge-aj8DijA() { return this.hoge; }
   @Nullable
   public final Value getFuga-DESORPU() { return this.fuga; }
}

中身がnullableの場合unboxされた形で取り扱われ、プロパティがnullableの場合boxされた形で取り扱われていることが分かります。

ValueClassSerializeAnnotationIntrospectorについて

次に、ValueClassSerializeAnnotationIntrospectorの実装について解説します。

ValueClassSerializeAnnotationIntrospector(全文)
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 ->
                val innerClazz = getter.returnType

                ValueClassJsonValueSerializer.createdOrNull(outerClazz, innerClazz)
                    ?: @Suppress("UNCHECKED_CAST")
                    ValueClassBoxSerializer(outerClazz, innerClazz)
            }
    } 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 }

findSerializerForValueClass関数について

findSerializerForValueClass関数はvalue classに対するシリアライザを返す関数です。

この関数で最も重要なのは、value classに対するシリアライザが必要かの判定です。
getterの戻り値がKotlin上でvalue classでないかboxされている場合、Jackson側にシリアライズを任せる必要があるため、この判定を行なっています。

value classに対するシリアライザが必要かの判定部分(抜粋)
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 }

findNullSerializerもoverrideする理由について

ValueClassSerializeAnnotationIntrospectorfindNullSerializeroverrideしています。
これは、value classの中身がnullだった場合にfindSerializerが呼び出されないためです。

findSerializer/findNullSerializer(抜粋)
override fun findSerializer(am: Annotated): Any? = findSerializerForValueClass(am)
// value classの中身がnullだった場合、findNullSerializerで処理する必要がある
override fun findNullSerializer(am: Annotated): Any? = findSerializerForValueClass(am)

value classに対するカスタムシリアライザについて

次に、value classに対するカスタムシリアライザの実装について解説します。

ValueClassBoxSerializer(全文)
ValueClassBoxSerializer
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する
        val boxed: Any = boxMethod.invoke(null, value)
        // boxされた値に対するシリアライザを検索し、シリアライズする
        provider.findValueSerializer(outerClazz).serialize(boxed, gen, provider)
    }
}

ValueClassJsonValueSerializer(全文)
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) }
    }
}

value classをインスタンス化する方法について

まず、value classをインスタンス化する方法について解説します。

一般的な例として、以下のようなinitブロックで値のチェックを行うvalue classについて見ます。

value classの例
@JvmInline
value class Value(val value: Int) {
    init { if (value == 0) throw IllegalArgumentException("") }
}

このクラスのデコンパイル結果(重要な部分だけ抜粋)は以下のようになります。

デコンパイル結果(整形・抜粋)
@JvmInline
@Metadata(/* 略 */)
public final class Value {
   private final int value;

   public final int getValue() { return this.value; }

   // $FF: synthetic method
   private Value(int value) { this.value = value; }

   public static int constructor-impl(int value) {
      if (value == 0) {
         throw (Throwable)(new IllegalArgumentException(""));
      } else {
         return value;
      }
   }

   // $FF: synthetic method
   public static final Value box-impl(int v) { return new Value(v); }

   // $FF: synthetic method
   public final int unbox-impl() { return this.value; }
}

このデコンパイル結果から以下の3点が分かります。

  1. インスタンス生成にはbox-implを用いる
  2. initブロック内の処理はconstructor-implに実装される
  3. box-implでもコンストラクタの実体でもconstructor-implは呼び出されない

つまり、一般にvalue classを適切にインスタンス化するにはconstructor-impl -> box-implの順番で呼び出す必要があります。

一方、ValueClassBoxSerializerではconstructor-implの呼び出しを行なっていません。
これは、serialize関数の引数はunboxされた値になることから、プログラム中のどこかではconstructor-implが呼び出されていると考えられるためです2

value classのインスタンス関数の実体について

次に、value classのインスタンス関数の実体がどのようになるかについて解説します。
この解説には、前回の記事で利用したV3クラスを利用します。

前回の記事で利用したV3
@JvmInline
value class V3(val value: Int) {
    @get:JsonValue
    val jsonValue get() = "JsonValue $value"
}

このクラスのデコンパイル結果(jsonValueに関わる部分だけ抜粋)は以下のようになります。

デコンパイル結果(整形・抜粋)
public final class V3 {
   @JsonValue
   @NotNull
   public static final String getJsonValue-impl(int $this) { return "JsonValue " + $this; }
}

デコンパイル結果の通り、jsonValueunboxされた値を引数に取るstaticメソッドになります。
通常のクラスと違い、インスタンスメソッドは生成されないため、JsonValueアノテーションが働かない状況となっています。

ValueClassJsonValueSerializerでは、このstaticメソッドを呼び出すことでシリアライズを行なっています。

serialize関数の入力について

通常のユースケースでは、値がnullだった場合それに対応したシリアライザが呼び出されるため、serialize関数の引数valueはほぼ確実にnon-nullとなります。
一方、今回定義した2つのカスタムシリアライザでは引数をnullableにしています。

これはfindNullSerializeroverrideしている理由と同様に、value classの中身がnullのパターンに対応するためです。

JsonSerializeに設定したシリアライザの挙動について

最後に、JsonSerializeに設定したシリアライザの挙動についてです。

JsonSerializeが機能しない理由は、冒頭で紹介した通り、Kotlin上でvalue classが戻り値となるgetterが、Java上で特殊な形になることです。
これに関しては、記事中で紹介した通り、Serializerの処理をunboxされた値に対するものに書き換えることで対応することができます。

前回の記事で利用したV2
@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}")
        }
    }
}

  1. 2の問題に関しては、jackson-module-kotlinの方で一応の対策が入っています。 

  2. 言うまでもありませんが、リフレクションで不適切な取り扱いをされていた場合まで考慮すると、constructor-implがどこでも呼び出されていない可能性はあります。 

0
0
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
0
0