Infinito Nirone 7

白羽の矢を刺すスタイル

kotlinx.serialization でオブジェクトをシリアライズする #potatotips 67

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 = // ...

// 直列化のロジックを持った 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 をつけたクラスのメンバーはすべてプリミティブな型 (IntDoubleString、あるいは 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 型と Sample4Sample5 の親子関係も知らせなければなりません。

kotlinx.serialization では、クラスの親子関係を後付けで定義するための仕組みとして SerializersModule を用意しています。これをつかって親子関係を知らせたうえで、Sample4Sample5 の直列化の方法も合わせて定義します。

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 の要素の位置で決め打ちになるので名前が衝突することがなくなります。