やりたいこと
[
{
"id": "1",
"name": "Alice",
"status": "thinking"
},
{
"id": "2",
"name": "Bob",
"status": {
"label": "考え中",
"emoji": ":thinking_face:"
}
}
]
上記のように、status
の値の型に多態性がある JSON 文字列を、 kotlinx.serialization を使って以下のようなクラスからシリアライズ/にデシリアライズしたい。
data class User(
val id: String,
val name: String,
val status: Status,
)
sealed class Status {
data class TextStatus(
val textValue: String
) : Status()
data class StructuredStatus(
val label: String,
val emoji: String,
) : Status()
}
JSON String と 独自クラス 間のシリアライズ
多態性のために val status: Status
のところを val status: String
と定義できない以上、どうにかして JSON String の値を Status
のサブクラスである TextStatus
に変換する必要がある。
そのためには カスタムシリアライザー を作る。
@Serializable(with = TextStatusSerializer::class)
data class TextStatus(
val textValue: String,
) : Status()
object TextStatusSerializer : KSerializer<TextStatus> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("TextStatus", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: TextStatus) {
encoder.encodeString(value.textValue)
}
override fun deserialize(decoder: Decoder): TextStatus {
return TextStatus(textValue = decoder.decodeString())
}
}
こういうカスタムシリアライザーを @Serializable
の with
で指定すれば、 status: "thinking"
という JSON 文字列が StringStatus(stringValue=thinking)
にデシリアライズ(その逆の、 data class から JSON 文字列にシリアライズも)できる。
コンテンツに合わせて別のクラスにデシリアライズする
今回扱いたい JSON をデシリアライズするとき、 JSON 上には status
の型を明示する識別子のようなものはないので、 JSON 上の値やその型を直接みて TextStatus
と StructuredStatus
を使い分ける必要がある。ここでもカスタムシリアライザーを使うが、 JsonContentPolymorphicSerializer を利用する。
@Serializable(with = StatusSerializer::class)
sealed class Status
object StatusSerializer : JsonContentPolymorphicSerializer<Status>(Status::class) {
override fun selectDeserializer(element: JsonElement) = when {
element is JsonObject -> StructuredStatus.serializer()
element is JsonPrimitive && element.isString -> TextStatus.serializer()
else -> throw IllegalArgumentException("unknown type")
}
}
これで、 "status": "thinking"
は StringStatus(stringValue=thinking)
に、 "status": { "label": "考え中", "emoji": ":thinking_face:" }
は StructuredStatus(label=考え中, emoji=:thinking_face:)
にデシリアライズされる。
また、 JsonContentPolymorphicSerializer を使っていることで、逆のJSONへのシリアライズにおいても、適切に各フォーマットで出力される。
ここまでのまとめ
@Serializable
data class User(
val id: String,
val name: String,
val status: Status,
)
@Serializable(with = StatusSerializer::class)
sealed class Status
@Serializable(with = TextStatusSerializer::class)
data class TextStatus(
val textValue: String,
) : Status()
@Serializable
data class StructuredStatus(
val label: String,
val emoji: String,
) : Status()
// 各カスタムシリアライザーの実装は省略
// 以下、テスト
val jsonString = """[{"id":"1","name":"Alice","status":"thinking"},{"id":"2","name":"Bob","status":{"label":"考え中","emoji":":thinking_face:"}}]"""
val decodedUsers: List<User> = Json.decodeFromString(jsonString)
val users = listOf(
User(id = "1", name = "Alice", status = TextStatus(textValue = "thinking")),
User(id = "2", name = "Bob", status = StructuredStatus(label = "考え中", emoji = ":thinking_face:")),
)
val encodedJsonString = Json.encodeToString(users)
assert(users == decodedUsers)
assert(jsonString == encodedJsonString)
このように、カスタムシリアライザーを実装することで、多態性のあるフォーマットの JSON 文字列を、 sealed class との間で変換することが出来た。
余談: JSON の中に型を明示する識別子があれば、話は簡単
もし JSON フォーマットが、以下のように
-
status
の値が必ず JSON Object である - その中に、型を識別する文字列が
status_type
として必ず存在する
となっていれば、カスタムシリアライザーなしに実装可能。
[
{
"id": "1",
"name": "Alice",
"status": {
"status_type": "text",
"text_value": "thinking"
}
},
{
"id": "2",
"name": "Bob",
"status": {
"status_type": "label_with_emoji",
"label": "考え中",
"emoji": ":thinking_face:"
}
}
]
ベースクラスに @JsonClassDiscriminator
、サブクラスに @SerialName
で それぞれ識別のキーと値を指定することで、カスタムシリアライザーなしに、自動的に各サブクラスにマッピングされる。
@Serializable
data class User(
val id: String,
val name: String,
val status: Status
)
@Serializable
@JsonClassDiscriminator("status_type")
sealed class Status
@Serializable
@SerialName("text")
data class TextStatus(
@SerialName("text_value") val textValue: String,
) : Status()
@Serializable
@SerialName("label_with_emoji")
data class StructuredStatus(
@SerialName("label") val label: String,
@SerialName("emoji") val emoji: String,
) : Status()
// これだけ。
// 以下、テスト。
val jsonString = """[{"id":"1","name":"Alice","status":"thinking"},{"id":"2","name":"Bob","status":{"label":"考え中","emoji":":thinking_face:"}}]"""
val decodedUsers: List<User> = Json.decodeFromString(jsonString)
val users = listOf(
User(id = "1", name = "Alice", status = TextStatus(textValue = "thinking")),
User(id = "2", name = "Bob", status = StructuredStatus(label = "考え中", emoji = ":thinking_face:")),
)
val encodedJsonString = Json.encodeToString(users)
assert(users == decodedUsers)
assert(jsonString == encodedJsonString)
この JSON フォーマットであれば例えば TypeScript の場合は以下のように discriminated union を使って実装するのだろうし、仕様としてはこちらのほうが扱いやすいのだろう。
type User = {
id: string
name: string
status: Status
}
type Status = TextStatus | StructuredStatus
type TextStatus = {
status_type: "text"
text_value: string
}
type StructuredStatus = {
status_type: "label_with_emoji"
label: string
emoji: string
}
動作環境
- Kotlin: 1.6.21
- kotlinx-serialization-json: 1.3.3
参考リンク
-
Kotlin Serialization Guide
- ここ見ればだいたい欲しい情報は載ってる
- 今回は特に、Serializers, Polymorphism, JSON features を参照した