例えば、 type
というキーの値によってデータ構造が違うようなJSONをエンコード、デコードしたい場合があります。このようなJSONを扱う場合の実装を紹介します。
ここではターゲットはJVMでフォーマットはJSONとしています。Kotlin Serializationはマルチプラットフォームで、いくつかのフォーマットがあるため、もしかしたら他の環境だと異なる可能性があるので注意してください。
使用バージョン
plugins {
kotlin("jvm") version "1.9.23"
kotlin("plugin.serialization") version "1.9.23"
}
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
}
sealed classを使った実装
一番簡単なのは sealed class
を使う方法です。
例として以下のようなJSONを考えてみます。
[
{
"type":"rectangle",
"name":"四角形",
"width":100,
"height":200
},
{
"type":"circle",
"name":"丸",
"radius":50
}
]
type
によって構造が異なります。 name
についてはどちらの構造でも保持しています。このJSONをデコード、エンコードできるような定義を考えていきます。
まずは sealed class
を使ってシンプルに定義してみます。
@Serializable
sealed class Shape {
abstract val name: String
}
@Serializable
data class Rectangle(override val name: String, val width: Int, val height: Int) : Shape()
@Serializable
data class Circle(override val name: String, val radius: Int) : Shape()
Shape
という親を作って、それを継承した Rectangle
と Circle
という data class
で定義しています。
これをエンコードしてみます。
fun main() {
// 四角形と丸のオブジェクトを生成
val rectangle = Rectangle("四角形", 100, 200)
val circle = Circle("丸", 50)
// オブジェクトをStringにエンコード
println(Json.encodeToString(rectangle))
println(Json.encodeToString(circle))
}
これを実行すると以下のようになります。
{"name":"四角形","width":100,"height":200}
{"name":"丸","radius":50}
それぞれのオブジェクトをJSONにできていますが、 type
がないため希望している状態ではないです。これは各変数の型がサブクラスのものになってるのが原因です。
これを今度は変数定義のときに明示的に親クラス型を指定するようにします。
fun main() {
// 明示的に`Shape`を指定して宣言
val rectangle: Shape = Rectangle("四角形", 100, 200)
val circle: Shape = Circle("丸", 50)
println(Json.encodeToString(rectangle))
println(Json.encodeToString(circle))
}
これを実行すると今度は次のようになります。
{"type":"com.example.Rectangle","name":"四角形","width":100,"height":200}
{"type":"com.example.Circle","name":"丸","radius":50}
type
が入るようになりました。ただし、 type
の値はクラス名となっています。次にこれを解決します。(デフォルトでは type
というキー名が使われます。これを変更する方法は後述します。)
@SerialName
を使うことで type
の値が指定できるので、サブクラスにそれぞれおアノテーションを追加します。
@Serializable
@SerialName("rectangle") // typeの値を指定
data class Rectangle(override val name: String, val width: Int, val height: Int) : Shape()
@Serializable
@SerialName("circle") // typeの値を指定
data class Circle(override val name: String, val radius: Int) : Shape()
この定義をして、再び実行すると、以下のような結果になります。
{"type":"rectangle","name":"四角形","width":100,"height":200}
{"type":"circle","name":"丸","radius":50}
これで希望通りの結果を得ることが出来ます。
sealed classを使うときの課題
そもそも sealed class
の制限として、同一package内でしかサブクラスを定義できません。そのため、サブクラスを違うpackageやmoduleで定義するような方法が出来なくなります。
これを解決するには sealed class
ではなく、 open class
や abstract class
や interface
を使う必要があります。
サブクラスの登録
例として abstract class
を使った実装を考えてみます。 abstract class
の場合はコンパイル時にはサブクラスが決定できないため、明示的に実行時に登録してあげる必要があります。
先ほどと同じ例で、 sealed class
ではなく、 abstract class
で定義し直してみます。
@Serializable
abstract class Shape { // `selaed class`から`abstract class`に変更
abstract val name: String
}
// ここから下は変わってない
@Serializable
@SerialName("rectangle")
data class Rectangle(override val name: String, val width: Int, val height: Int) : Shape()
@Serializable
@SerialName("circle")
data class Circle(override val name: String, val radius: Int) : Shape()
これを単純に実行すると例外が発生します。
Exception in thread "main" kotlinx.serialization.SerializationException: Serializer for subclass 'Rectangle' is not found in the polymorphic scope of 'Shape'.
Check if class with serial name 'Rectangle' exists and serializer is registered in a corresponding SerializersModule.
これを解決するために SerializersModule
を使って明示的にサブクラスを指定してあげます。
val module = SerializersModule {
polymorphic(Shape::class) { // 親クラス
subclass(Rectangle::class) // サブクラス
subclass(Circle::class) // サブクラス
}
}
val json = Json { serializersModule = module }
polymorphic
で親クラスを指定して、そのビルダーブロック内で対象となるサブクラスを subclass
で登録していきます。
最後に Json
のブロックで作成した SerializersModule
を指定してあげます。
あとはこれまで通り実行するだけです。
fun main() {
val rectangle: Shape = Rectangle("四角形", 100, 200)
val circle: Shape = Circle("丸", 50)
// 注意: `Json`ではなく、上記作成した`json`変数を使ってエンコード
println(json.encodeToString(rectangle))
println(json.encodeToString(circle))
}
Serializableではない親クラスに対応する
あまり無いケースかなとは個人的に思いますが、例えば Any
のようなSerializableではないものを親クラスとしてポリモーフィズムを実現したい場合です。
@Serializable
@SerialName("rectangle")
data class Rectangle(val name: String, val width: Int, val height: Int)
@Serializable
@SerialName("circle")
data class Circle(val name: String, val radius: Int)
val module = SerializersModule {
polymorphic(Any::class) { // Anyのサブクラスとして登録する
subclass(Rectangle::class)
subclass(Circle::class)
}
}
val json = Json { serializersModule = module }
ここまでは先程の abstract class
と同様です。
ただ、これを今まで通り実行すると、以下の例外が発生します。
Exception in thread "main" kotlinx.serialization.SerializationException: Serializer for class 'Any' is not found.
Please ensure that class is marked as '@Serializable' and that the serialization compiler plugin is applied.
これに対応するには encodeToString
に PolymorphicSerializer
を渡してあげる必要があります。
fun main() {
val rectangle: Any = Rectangle("四角形", 100, 200)
val circle: Any = Circle("丸", 50)
// 第一引数に`PolymorphicSerializer`を渡す
println(json.encodeToString(PolymorphicSerializer(Any::class), rectangle))
println(json.encodeToString(PolymorphicSerializer(Any::class), circle))
}
これでこれまで同様の結果が得られます。
今回はトップレベルでしたが、別のオブジェクトのプロパティとして使用する場合は少し対応が必要になります。
@Serializable
data class Canvas(
@Polymorphic // これが必要
val shape: Any
)
fun main() {
val canvas = Canvas(Circle("丸", 50))
println(json.encodeToString(canvas))
}
上記のように、 @Polymorphic
をつける必要があります。
デフォルトのデコード
JSONをデコードする際に不明な type
が渡される可能性があるときに、デフォルトでデコードされる型を指定することもできます。
例えば、以下のような感じです。
fun main() {
val shape: Shape = json.decodeFromString("""
{"type":"unknown", "name":"不明な形"}
""")
println(shape)
}
この場合、対応するタイプが存在しないため例外が発生します。
Exception in thread "main" kotlinx.serialization.json.internal.JsonDecodingException: Unexpected JSON token at offset 0: Serializer for subclass 'unknown' is not found in the polymorphic scope of 'Shape' at path: $
Check if class with serial name 'unknown' exists and serializer is registered in a corresponding SerializersModule.
このような場合に対応するために、不明なタイプがきたばあいはデフォルトのオブジェクトを指定することが出来ます。
// 不明なタイプがきたときのデフォルトの型
@Serializable
data class UnknownShape(override val name: String) : Shape()
val module = SerializersModule {
polymorphic(Shape::class) {
subclass(Rectangle::class)
subclass(Circle::class)
// デフォルトのシリアライザーを指定
defaultDeserializer { UnknownShape.serializer() }
}
}
val json = Json {
serializersModule = module
}
defaultDeserializer
でデフォルトのシリアライザーを指定しておくと、不明なタイプが来たときにそのシリアライザーで処理されることになります。
キー名を変更する
これまで見てきた実装だと、すべて type
というキーが自動で使用されていました。これを変更する方法もあります。
まずは Json
オブジェクト全体で指定する方法です。
val json = Json {
classDiscriminator = "shape_type"
}
classDiscriminator を使ってキー名を指定することができます。これで実行すると、これまで type
だったキーが shape_type
に変更されます。
{"shape_type":"rectangle","name":"四角形","width":100,"height":200}
{"shape_type":"circle","name":"丸","radius":50}
別の方法として、親クラス側の定義で指定することもできます。
@Serializable
@JsonClassDiscriminator("shape_type")
abstract class Shape {
abstract val name: String
}
@JsonClassDiscriminator
を親クラス側に追加することで同じ結果が得られます。
いくつか実装を紹介しましたが、もっと細かい制御も可能になっています。興味がある人は下記の公式ガイドを参考にしてみてください。