この記事はSwift/Kotlin愛好会 Advent Calendar 2021
の9日目の記事になりました。
前書き
前回の記事では、jackon-module-kotlin
でvalue 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
のみ抜き出して整形すると以下のようになります。
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つの変化が起きています。
- 戻り値が
unboxed
された値になっている -
getter
の名前にサフィックスが付いている
この内、今回の問題の原因になっているのは1です1。
この変化が起きているため、シリアライズ時に参照される型が異なってしまい、期待した通りのシリアライズ結果が得られないという問題が発生します。
補足: nullableな内容が絡む場合
前回の例では出すことができなかったため、value class
の中身がnullable
な場合と、プロパティがnullable
な場合に関しても見てみます。
@JvmInline
value class Value(val value: Int?)
data class Data(
val hoge: Value,
val fuga: Value?
)
デコンパイル結果は以下のようになります。
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
側にシリアライズを任せる必要があるため、この判定を行なっています。
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する理由について
ValueClassSerializeAnnotationIntrospector
はfindNullSerializer
もoverride
しています。
これは、value class
の中身がnull
だった場合にfindSerializer
が呼び出されないためです。
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
(全文)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
について見ます。
@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点が分かります。
- インスタンス生成には
box-impl
を用いる -
init
ブロック内の処理はconstructor-impl
に実装される -
box-impl
でもコンストラクタの実体でもconstructor-impl
は呼び出されない
つまり、一般にvalue class
を適切にインスタンス化するにはconstructor-impl
-> box-impl
の順番で呼び出す必要があります。
一方、ValueClassBoxSerializer
ではconstructor-impl
の呼び出しを行なっていません。
これは、serialize
関数の引数はunbox
された値になることから、プログラム中のどこかではconstructor-impl
が呼び出されていると考えられるためです2。
value classのインスタンス関数の実体について
次に、value class
のインスタンス関数の実体がどのようになるかについて解説します。
この解説には、前回の記事で利用した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; }
}
デコンパイル結果の通り、jsonValue
はunbox
された値を引数に取るstatic
メソッドになります。
通常のクラスと違い、インスタンスメソッドは生成されないため、JsonValue
アノテーションが働かない状況となっています。
ValueClassJsonValueSerializer
では、このstatic
メソッドを呼び出すことでシリアライズを行なっています。
serialize関数の入力について
通常のユースケースでは、値がnull
だった場合それに対応したシリアライザが呼び出されるため、serialize
関数の引数value
はほぼ確実にnon-null
となります。
一方、今回定義した2つのカスタムシリアライザでは引数をnullable
にしています。
これはfindNullSerializer
もoverride
している理由と同様に、value class
の中身がnull
のパターンに対応するためです。
JsonSerializeに設定したシリアライザの挙動について
最後に、JsonSerialize
に設定したシリアライザの挙動についてです。
JsonSerialize
が機能しない理由は、冒頭で紹介した通り、Kotlin
上でvalue class
が戻り値となるgetter
が、Java
上で特殊な形になることです。
これに関しては、記事中で紹介した通り、Serializer
の処理を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}")
}
}
}