Kotlin Serialization を使う利点について
こんにちは!アルダグラムのアプリチームです。
本記事は株式会社アルダグラム Advent Calendar 2023 4日目の記事です。
Androidのアプリでは JSON を通じてAPIサーバと通信を行うことは一般的なので、多くのアプリで JSON のシリアライゼーションライブラリを使っていると思います。
アルダグラムの Kanna の Android アプリでも moshi を使っているのですが、JetBrains が出している kotlinx.serialization について調べるきっかけがあり moshi と比べても利点があると感じたのでいくつか紹介してみようと思います。
導入方法
まず、Kotlin Serializationの導入方法を簡単に紹介します。
Serialization プラグイン の追加
app/build.gradle.kts
plugins {
...
id("org.jetbrains.kotlin.android") version "1.9.20"
kotlin("plugin.serialization") version "1.9.20"
...
}
JSON ライブラリの依存を追加
app/build.gradle.kts
dependencies {
...
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.1")
...
}
補足: ここでは JSON 用のライブラリを追加していますが、他フォーマット (Protobuf, Properties など) のライブラリも JetBrains またはサードパーティから提供されています。
以上のプラグインとライブラリを追加することで使う準備ができます。Kotlin Serialization ではクラスにアノテーションを付けることでシリアライズ対象とすることができます。
@Serializable
data class Image(
val imageUrl: String,
val caption: String,
val altText: String,
)
例えばこのように @Serializable
アノテーションをクラスにつけるだけでシリアライズ、デシリアライズを行うことができます。
val data = Image(
imageUrl = "https://example.com/image1",
caption = "image1",
altText = "Alt text"
)
val string = Json.encodeToString(data)
println(string) // "imageUrl":"https://example.com/image1","caption":"image1","altText":"Alt text"}
val deserialized = Json.decodeFromString<Image>(string)
println(deserialized) // Image(imageUrl=https://example.com/image1, caption=image1, altText=Alt text)
sealed クラスのシリアライズの容易さ
Kotlin Serialization を使うと、親子関係があるクラスに対して少ないコードでシリアライズ対応させることができると感じました。
例えば以下のような sealed クラスをシリアライズ対象にしたい場合を考えてみます。
sealed interface Item {
@Serializable
data class Article(
val headline: String,
val teaserImage: Image
) : Item
@Serializable
data class Video(
val headline: String,
val teaserImage: Image,
val videoUrl: String
) : Item
}
この場合でも対象としたいクラスに @Serializable
アノテーションをつけるだけで大丈夫です。
val article = Item.Article(
headline = "headline",
teaserImage = Image(
imageUrl = "https://example.com/image1",
caption = "image1",
altText = "Alt text"
),
)
val string = Json.encodeToString(article)
println(string) // "headline":"headline","teaserImage":{"imageUrl":"https://example.com/image1","caption":"image1","altText":"Alt text"}}
ここで、着目してほしいのがシリアライズを行う側 (Json
クラス) には Article
クラスの情報を渡していないにもかかわらずシリアライズが行えていることです。
例えば、moshi で同じようなことを行うには明示的に sealed クラスと各実装クラスの情報を渡す必要があります。これはコードが煩雑になることもありますし、他モジュールに data クラスが定義されている場合には Moshi
ビルダーを使う側から data クラスが見えていなければいけないといった制約も生まれてきます。
val moshi = Moshi.Builder()
.add(
PolymorphicJsonAdapterFactory.of(Item::class.java, "type")
.withSubtype(Item.Article::class.java, "article")
.withSubtype(Item.Video::class.java, "video")
)
.build()
Custom シリアライザーの柔軟性
Kotlin Serialization ではデフォルトではドキュメントにあるように Int
String
などの Primitive や List
などの 標準コレクションは Custom シリアライザーを作らないでもサポートされています。例えば、上の例で出した Article
クラスを見てみると、 String
と Image
クラスだけから構成されていて、 Image
クラスも Serializable
アノテーションが付けられているので、 Article
専用の Custom シリアライザーを作る必要がありません。
@Serializable
data class Image(
val imageUrl: String,
val caption: String,
val altText: String,
)
sealed interface Item {
@Serializable
data class Article(
val headline: String,
val teaserImage: Image
) : Item
...
}
ただ、以下のように標準コレクションではない ImmutableList
を持っているクラスをシリアライズしたい場合は
@Serializable
data class ListHolder(
val immutableStrings: ImmutableList<String>
)
どうやってシリアライズさせていいかが Kotlin Serialization のライブラリが知らないので実行時のエラーになってしまいます。
kotlinx.serialization.SerializationException: Serializer for subclass 'SmallPersistentVector' is not found in the polymorphic scope of 'ImmutableList'.
Check if class with serial name 'SmallPersistentVector' exists and serializer is registered in a corresponding SerializersModule.
To be registered automatically, class 'SmallPersistentVector' has to be '@Serializable', and the base class 'ImmutableList' has to be sealed and '@Serializable'.
こういう時は Custom シリアライザーを作る必要があります。 ImmutableList<String>
の場合は例えば以下のような実装が考えられます。
object ImmutableStringListSerializer : KSerializer<ImmutableList<String>> {
private val delegateSerializer = ListSerializer(String.serializer())
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("ImmutableList") {
element("values", delegateSerializer.descriptor)
}
override fun serialize(encoder: Encoder, value: ImmutableList<String>) {
encoder.encodeSerializableValue(delegateSerializer, value)
}
override fun deserialize(decoder: Decoder): ImmutableList<String> {
val list = decoder.decodeSerializableValue(delegateSerializer)
return list.toImmutableList()
}
}
そして、 ImmutableList<String>
のフィールドにどうやってシリアライズすればいいかの情報を渡します
@Serializable
data class ListHolder(
@Serializable(ImmutableStringListSerializer::class)
val immutableStrings: ImmutableList<String>
)
こうすることで ListHolder
クラスもシリアライズすることができるようになりました。
ただ、 ImmutableList<Int>
のように他の型の場合はどう対応するのがいいでしょうか? String
と同じように 専用のシリアライザーを作ることはできますが、いちいち他の型の分も作るのは面倒です。
object ImmutableIntListSerializer : KSerializer<ImmutableList<Int>> {
...
}
Kotlin Serializationのドキュメントを見ると、ジェネリック型のCustomシリアライザーについて以下のように言及されています。
A custom serializer for a generic class must be a
class
with a constructor that accepts as many KSerializer parameters as the type has generic parameters. Let us write aBox<T>
serializer that erases itself during serialization, delegating everything to the underlying serializer of itsdata
property.
つまり、コンストラクタで渡されるプロパティにシリアライズを移譲することができるようです。
それを利用して ImmutableList についてもジェネリックなシリアライザーを作れるか試してみます。
以下のようにジェネリックな型に対して ImmutableList
のシリアライザーを定義してみます。
class ImmutableListSerializer<T>(
private val dataSerializer: KSerializer<T>,
) : KSerializer<ImmutableList<T>> {
override val descriptor = listSerialDescriptor(dataSerializer.descriptor)
override fun serialize(encoder: Encoder, value: ImmutableList<T>) =
ListSerializer(dataSerializer).serialize(encoder, value.toList())
override fun deserialize(decoder: Decoder): ImmutableList<T> =
ListSerializer(dataSerializer).deserialize(decoder).toImmutableList()
}
それを ImmutableList
の各フィールドに対して宣言します。
@Serializable
data class ListHolder(
@Serializable(ImmutableListSerializer::class)
val immutableStrings: ImmutableList<String>,
@Serializable(ImmutableListSerializer::class)
val immutableInts: ImmutableList<Int>
)
こうすることで、一つの Custom シリアライザーで複数の型についても対応できました!
val listHolder = ListHolder(
immutableStrings = listOf(
"string1",
"string2"
).toImmutableList(),
immutableInts = listOf(1, 2).toImmutableList()
)
val string = Json.encodeToString(listHolder)
println(string) // {"immutableStrings":["string1","string2"],"immutableInts":[1,2]}
Multiplatform 対応
先日アナウンスされた通り Kotlin Multiplatform の安定版が 2023年11月に発表されました。今後はクロスプラットフォームの有力な候補として Kotlin Multiplatform が候補に上がることが予想できます。(アルダグラムでは幸運なことに現在 iOSとAndroid でネイティブなエンジニアがいるので、近い将来にはなさそうです)
Kotlin Multiplatformの特徴として、どの程度プラットフォーム間でコードを共有するか柔軟に選ぶことができます。
JSON シリアライズを行うロジックのようなコードは UI のコードに比べてプラットフォーム間の差異が少ないので一度書いたらプラットフォーム間で共有することは難易度が低いように思えます。
その観点で Kotlin Serialization を見てみると github のリポジトリでも
Kotlin multiplatform / multi-format reflectionless serialization
と言及されているようにそもそも multiplatform を念頭において作られたライブラリのようです。Serialization formats のページでも説明されているように現在でも JSON をはじめ、Protobuf, CBOR などは JetBrains から全てのプラットフォームに対応しているライブラリが出されているので(サードパーティも含めると Yaml, Toml などもあるらしい) 他プラットフォームに対応したいと思ったときも Serialization 部分のコードを再利用できる可能性は高そうに思えます。
パフォーマンス
前提として、パフォーマンスを改善するときはUI描画、ネットワークやディスク書き込みなども含めて一番負担になっている箇所をトップダウン的に探していく手法が一般的だと思います。また、その中でJSONシリアライズがボトルネックになっている時はあまり見られないという予想はあるのですが、とはいえライブラリのパフォーマンスが良いことに越したことはありません。
Kotlin Serializationはリフレクションを使用していないので、リフレクションを使用しているライブラリに比べるとパフォーマンスは良さそうです。
有志の方がいくつか Moshi vs Kotlin Serialization の比較をあげています (1, 2, 3) 。結果をみると、リフレクションを使っている実装の moshi よりは大分早いものの、リフレクションを使っていない実装の moshi に比べると大体同じくらいで実験結果によってどちらが勝っているかまちまちといった状況のように見えます。
どちらにしても、Kotlin Serialization と Moshi のリフレクションを使わない実装の比較でではパフォーマンス面では大きな差はなさそうです。
まとめ
Kotlin Serialization の利点についていくつかの観点から紹介してみました。GitHub に置いてあるガイドではすごく詳しいドキュメントも公開されているので興味を持った方は一度調べてみるのはいかがでしょうか。
もっとアルダグラムエンジニア組織を知りたい人、ぜひ下記の情報をチェックしてみてください!
株式会社アルダグラムのTech Blogです。 世界中のノンデスクワーク業界における現場の生産性アップを実現する現場DXサービス「KANNA」を開発しています。 採用情報はこちら: herp.careers/v1/aldagram0508/
Discussion