LoginSignup
15
4

More than 1 year has passed since last update.

Kotlin serializationで多態なJSONを入出力する

Last updated at Posted at 2022-12-07

やりたいこと

JSON
[
  {
    "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())
    }
}

こういうカスタムシリアライザーを @Serializablewith で指定すれば、 status: "thinking" という JSON 文字列が StringStatus(stringValue=thinking) にデシリアライズ(その逆の、 data class から JSON 文字列にシリアライズも)できる。

コンテンツに合わせて別のクラスにデシリアライズする

今回扱いたい JSON をデシリアライズするとき、 JSON 上には status の型を明示する識別子のようなものはないので、 JSON 上の値やその型を直接みて TextStatusStructuredStatus を使い分ける必要がある。ここでもカスタムシリアライザーを使うが、 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 として必ず存在する

となっていれば、カスタムシリアライザーなしに実装可能。

JSON
[
  {
    "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 を使って実装するのだろうし、仕様としてはこちらのほうが扱いやすいのだろう。

TypeScript
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

参考リンク

15
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
4