potatotips #67 で kotlinx.serialization を使ったオブジェクトのシリアライズの手順について話してきたのでまとめておきます。いくつか発表のときにはできなかった部分を掘り下げています。
イベントリンク
potatotips #67 (iOS/Android開発Tips共有会) - connpass
資料
kotlinx.serialization とは
Kotlin の各種オブジェクトを直列化して JSON や Protocol Buffers などのフォーマットに落とすためのしくみです。
2019年12月現在で最新版は 0.14.0
です。
導入
Project root にある build.gradle に kotlinx.serialization の classpath を追加し、各モジュールの build.gradle で apply plugin します。 Plugin のバージョンは Kotlin のバージョンと合わせます。
buildscript { ext.kotlin_version = '1.3.61' repositories { jcenter() } dependencies { classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version" } }
apply plugin: "kotlin" apply plugin: "kotlinx-serialization"
そして kotlinx-serialization-runtime を各モジュールの build.gradle の dependencies に追加します。
dependencies {
implementation "org.jetbrains.kotlinx:kotlinx-serialization-runtime:0.14.0"
}
使い方
kotlinx.serialization では、オブジェクトを直列化する方法と、直列化したものを何らかのフォーマットに落とす方法が分かれていて、ここでは直列化する方法の部分を見ていきます。
まずクラスが Serializable かどうかをアノテーションで表明します。
import kotlinx.serialization.Serializable @Serializable data class SampleScreenState( val familyName: String, val givenName: String, val age: Int )
メンバーがすべてプリミティブな型か、@Serializable
がついている型の場合は自動で直列化のロジックも吐き出してくれます。これは直列化の方法を知っている KSerializer<T>
の実装をプラグインが作ってくれて、そのインスタンスを返すメソッドを Companion Object に設定してくれるようになっています。
val sampleScreenState: SampleScreenState = // ... // 直列化のロジックを持った KSerializer<SampleScreenState> の実装が得られる val serializer = SampleScreenState.serializer()
次のように、@Serializable
がついていない型をメンバーに持つ場合は、Serializer has not been found for type 'Any'. To use context serializer as fallback, explicitly annotate type or property with @ContextualSerialization
というメッセージを吐き出してコンパイルエラーになります。なぜなら直列化の方法がわからないメンバーは直列化できないからです。java.io.Serializable
とおなじく、@Serializable
をつけたクラスのメンバーはすべてプリミティブな型 (Int
や Double
、String
、あるいは enum class
)か KSerializer<T>
を定義した型 (@Serializable
) でなければなりません。
シリアライズについての実装の間違いをコンパイル時に検知できてよいですね。
import kotlinx.serialization.Serializable data class SampleData( val text: String ) @Serializable data class SampleScreenState( val familyName: String, val givenName: String, val age: Int, val something: SampleData = SampleData("sample") // ここがコンパイルエラー )
SampleData
を直列化するには、SampleData
にも @Serializable
をつけます。
import kotlinx.serialization.Serializable @Serializable data class SampleData( val text: String ) @Serializable data class SampleScreenState( val familyName: String, val givenName: String, val age: Int, val something: SampleData = SampleData("sample") )
もしメンバーを直列化の対象外としたい場合は、@kotlinx.serialization.Transient
アノテーションをつかいます。Kotlin JVM に標準の @kotlin.jvm.Transient
ではないことに気をつけてください。
import kotlinx.serialization.Serializable import kotlinx.serialization.Transient @Serializable data class SampleScreenState( val familyName: String, val givenName: String, val age: Int, @Transient val something: SampleData = SampleData("sample") )
KSerializer
が生成できたら、あとはフォーマッターにシリアライズしたいオブジェクトと KSerializer
を渡せば、好きなフォーマットに落とせます。
import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonConfiguration fun main() { val state = SampleScreenState(/* ... */) val json = Json(JsonConfiguration.Stable) val serialized: String = json.stringify(SampleScreenState.serializer(), state) val deserialized: SampleScreenState = json.parse(SampleScreenState.serializer(), serialized) }
ライブラリに定義された型をシリアライズする
次のような型がライブラリに定義されているとします。
sealed class Abstract class Sample4( val text: String, val number: Int ) : Abstract() class class Sample5( val number: Int ) : Abstract()
ライブラリにあるので直接 @Serializable
をつけられません。また KSerializer<T>
も生成されないので自分で直列化の方法を定義する必要があり、上記の Abstract
型をメンバーとすると、Abstract
型と Sample4
や Sample5
の親子関係も知らせなければなりません。
kotlinx.serialization では、クラスの親子関係を後付けで定義するための仕組みとして SerializersModule
を用意しています。これをつかって親子関係を知らせたうえで、Sample4
や Sample5
の直列化の方法も合わせて定義します。
import kotlinx.serialization.Decoder import kotlinx.serialization.Encoder import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialDescriptor import kotlinx.serialization.internal.SerialClassDescImpl import kotlinx.serialization.modules.SerializersModule @UseExperimental(ImplicitReflectionSerializer::class) // フォーマッタに渡して使う val abstractTypeModule = SerializersModule { polymorphic<Abstract> { addSubclass(Sample4Serializer) addSubclass(Sample5Serializer) } } object Sample4Serializer : KSerializer<Sample4> { override val descriptor: SerialDescriptor = object : SerialClassDescImpl(Sample4::class.java.name) { init { addElement("string") // 0 番目のプロパティ addElement("number") // 1 番目のプロパティ } } override fun deserialize(decoder: Decoder): Sample4 { val structure = decoder.beginStructure(descriptor) val string = structure.decodeStringElement(descriptor, 0) // 0 番目のプロパティから String をデコードする val number = structure.decodeIntElement(descriptor, 1) // 1 番目のプロパティから Int をデコードする structure.endStructure(descriptor) return Sample4(string) } override fun serialize(encoder: Encoder, obj: Sample4) { val structure = encoder.beginStructure(descriptor) structure.encodeStringElement(descriptor, 0, obj.string) // 0 番目のプロパティに String をエンコードする structure.encodeIntElement(descriptor, 1, obj.number) // 1 番目のプロパティに Int をエンコードする structure.endStructure(descriptor) } } object Sample5Serializer : KSerializer<Sample5> { override val descriptor: SerialDescriptor = object : SerialClassDescImpl(Sample5::class.java.name) { init { addElement("number") } } override fun deserialize(decoder: Decoder): Sample5 { val structure = decoder.beginStructure(descriptor) val number = structure.decodeIntElement(descriptor, 0) structure.endStructure(descriptor) return Sample5(number) } override fun serialize(encoder: Encoder, obj: Sample5) { val structure = encoder.beginStructure(descriptor) structure.encodeIntElement(descriptor, 0, obj.number) structure.endStructure(descriptor) } }
これらを使って次の Sample6
を JSON にフォーマットすると、{"abstractSample":{"type":"dev.keithyokoma.Sample4","string":"foo","number":1}}
が出力されます。"abstractSample"
に対応する部分が JsonObject
となり、この JsonObject ではもとがどの型だったかを覚えておくために "type"
に FQCN が出力されます。
import kotlinx.serialization.Polymorphic import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonConfiguration @Serializable data class Sample6( @Polymorphic val abstractSample: Abstract ) fun main() { val json = Json(JsonConfiguration.Stable, abstractTypeModule) val serialized = json.stringify(Sample6.serializer(), Sample6(Sample4("foo", 1))) println(serialized) }
sealed class のシリアライズ
sealed class もシリアライズできます。ただし、Kotlin と kotlinx.serialization のバージョンの組み合わせでJSON フォーマッタを使った場合の結果が変わります。
Kotlin 1.3.61 と kotlinx.serialization 0.14.0
次のように @Serializable
で表明します。sealed class とその子クラスすべてに @Serializable
をつけます。
import kotlinx.serialization.Serializable @Serializable sealed class Sealed @Serializable data class Sample2( val text: String ) : Sealed() @Serializable data class Sample3( val number: Int ) : Sealed() @Serializable data class Sample1( val text: String, val sealed: Sealed )
これを次のように JSON でフォーマットすると、{"text":"foo","sealed":{"type":"dev.keithyokoma.Sample2","text":"bar"}}
が出力されます。
import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonConfiguration fun main() { val sample = Sample1("foo", Sample2("bar")) val json = Json(JsonConfiguration.Stable) val serialized: String = json.stringify(Sample1.serializer(), sample) println(serialized)
Kotlin 1.3.30 以上 と kotlinx.serialization 0.11.0 から 0.13.0 まで
このバージョンでは、sealed class には @Serializable
をつけずその子クラスに @Serializable
をつけます。また sealed class の型のメンバーには @Polymorphic
をつけます。
しかし実はこれだけでは不十分で、ライブラリの型をシリアライズするときに利用したSerializersModule
で sealed class の親と子の継承関係を教えてあげる必要があります。今回は sealed class の子クラスには KSerializer
がアノテーションで生成されるので、自分で記述することはありません。
import kotlinx.serialization.Polymorphic import kotlinx.serialization.Serializable import kotlinx.serialization.modules.SerializersModule sealed class Sealed @Serializable data class Sample2( val text: String ) : Sealed() @Serializable data class Sample3( val number: Int ) : Sealed() // フォーマッタに渡して使う @UseExperimental(ImplicitReflectionSerializer::class) val sampleTypeModule = SerializersModule { polymorphic<Sealed> { addSubclass<Sample2>() addSubclass<Sample3>() } } @Serializable data class Sample1( val text: String, @Polymorphic val sealed: Sealed )
これを次のように JSON でフォーマットすると、{"text":"foo","sealed":{"type":"dev.keithyokoma.Sample2","text":"bar"}}
が出力されます。
import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonConfiguration fun main() { val sample = Sample1("foo", Sample2("bar")) val json = Json(JsonConfiguration.Stable, sampleTypeModule) val serialized: String = json.stringify(Sample1.serializer(), sample) println(serialized)
kotlinx.serialization 0.10.0 まで
このバージョンでは次の記述で問題なくシリアライズできます。
import kotlinx.serialization.Polymorphic import kotlinx.serialization.Serializable sealed class Sealed @Serializable data class Sample2( val text: String ) : Sealed() @Serializable data class Sample3( val number: Int ) : Sealed() @Serializable data class Sample1( val text: String, @Polymorphic val sealed: Sealed )
これを次のようにフォーマットすると {"text":"foo","sealed":["dev.keithyokoma.Sample2",{"text":"bar"}]}
が出力されます。"sealed"
の構造に注目すると、kotlinx.serialization 0.10.0 では JsonArray にして出力することがわかります。JsonArray の最初の要素が元の型の FQCN で、2番目の要素にメンバーをフォーマットした JsonObject が入ります。
import kotlinx.serialization.json.Json fun main() { val sample = Sample1("foo", Sample2("bar")) val json = Json() val serialized: String = json.stringify(Sample1.serializer(), sample) println(serialized)
互換モード
元がだどの型だったかを覚えておくための FQCN が 0.10.0 以前は JsonArray の最初の要素に入っていたのに対し、0.11.0 以降は JsonObject のプロパティに出力されます。これを、0.11.0 以降でも 0.10.0 以前のように JsonArray の形に出力して互換性を保つようにするオプションが用意されています。
val json = Json(JsonConfiguration.Stable.copy(useArrayPolymorphism = true), abstractTypeModule)
"type" という名前のメンバーを持つ型をシリアライズする
次のように、sealed class
で表される型のひとつに type
というメンバーを持つものがあるとします。
import kotlinx.serialization.Polymorphic import kotlinx.serialization.Serializable @Serializable sealed class Sealed @Serializable data class Sample2( val type: String ) : Sealed() @Serializable data class Sample3( val number: Int ) : Sealed() @Serializable data class Sample1( val text: String, val sealed: Sealed )
これをそのまま次のようにフォーマットすると、{"text":"foo","sealed":{"type":"dev.keithyokoma.Sample2","type":"bar"}}
と出力されます。見事に "type"
が 2 つできてしまいました。これをデシリアライズすると、2つ目の "type"
を見に行ってしまい知らない型 (bar
) になってしまいます。
import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonConfiguration fun main() { val json = Json(JsonConfiguration.Stable) val serialized = json.stringify(Sample1.serializer(), Sample1("foo", Sample2("bar"))) println(serialized) }
これを回避する方法は3つあります。
1つめはメンバーに Json にフォーマットするときの名前を与える方法です。次に示すように @SerialName
で Json のプロパティ名を設定すると、結果は{"text":"foo","sealed":{"type":"dev.keithyokoma.Sample2","my_type":"bar"}}
となります。
@Serializable data class Sample2( @SerialName("my_type") val type: String ) : Sealed()
2つめはJsonの設定で FQCN を入れるプロパティ名を変える方法です。JsonConfiguration#classDiscriminator
に任意の文字列を指定して、FQCN のプロパティ名が変えられます。次の例では {"text":"foo","sealed":{"clazz":"dev.keithyokoma.Sample2","type":"bar"}}
を出力します。
import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonConfiguration fun main() { val json = Json(JsonConfiguration.Stable.copy(classDiscriminator = "clazz")) val serialized = json.stringify(Sample1.serializer(), Sample1("foo", Sample2("bar"))) println(serialized) }
3つめは先ほどの互換モードを利用する方法です。JsonArray の要素の位置で決め打ちになるので名前が衝突することがなくなります。