potatotips #67 で kotlinx.serialization を使ったオブジェクトのシリアライズの手順について話してきたのでまとめておきます。いくつか発表のときにはできなかった部分を掘り下げています。
イベントリンク
potatotips #67 (iOS/Android開発Tips共有会) - connpass
資料
speakerdeck.com
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 =
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")
addElement("number")
}
}
override fun deserialize(decoder: Decoder): Sample4 {
val structure = decoder.beginStructure(descriptor)
val string = structure.decodeStringElement(descriptor, 0)
val number = structure.decodeIntElement(descriptor, 1)
structure.endStructure(descriptor)
return Sample4(string)
}
override fun serialize(encoder: Encoder, obj: Sample4) {
val structure = encoder.beginStructure(descriptor)
structure.encodeStringElement(descriptor, 0, obj.string)
structure.encodeIntElement(descriptor, 1, obj.number)
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 の要素の位置で決め打ちになるので名前が衝突することがなくなります。