Kotlin serializationでJSONをパース(JSONの記述⇔クラスのオブジェクト)する場合に、一般クラスは未対応です。
例えば、データクラス(コンストラクタの引数でプロパティを指定するクラス)以外の、ユーザ定義のクラスはパース出来ません。また、ライブラリ提供のクラスもパース出来ません。
対応させるためには、そのクラスのカスタムSerializerを作成します。
そして、相互変換する方法をプログラマー側で定義します。
カスタムSerializer(ユーザ定義クラス、Handwritten版)の作成方法をまとめます。
※環境:Android Studio Meerkat | 2024.3.1
Kotlin 2.0.0
Kotlin serialization json 1.7.1
目次
標準で対応する型、しない型
Kotlin serializationでJSONをパース(JSONの記述⇔クラスのオブジェクト)する場合に、標準で対応する型は次の通りです。
- ・Int,Long,Float,Double,Booleanなど(プリミティブ型)
- ・String(文字列)※プリミティブ型へ含める場合もある
- ・Array(配列)
- ・List,Map,Set(コレクション型)
- ・Enum(列挙型)
- ・データクラス
上記以外の一般クラス(ユーザ定義のクラス、ライブラリ提供のクラス)は未対応です。
例えば、次のようなクラスは標準でパース出来ません。
class LogTime(hour: Int, min: Int, sec: Int) { val hh: Int = hour val mm: Int = min val ss: Int = sec override fun toString() = "%s:%s:%s".format(hh, mm, ss) }
一般クラスをパースするには、そのクラスのカスタムSerializerが必要です。
Serializerの種類
Serializerは、表のような種類(書き方)があります。
特徴 | シリアル化の構成 | |
---|---|---|
Primitive | Primitive型(Stringを含む)限定 | PrimitiveSerialDescriptor |
Delegating | 定義済みのSerializerへ委任する | SerialDescriptor |
Surrogate | 代理クラスを立て(作成し)、 そのクラスのSerializerに代行させる | SerialDescriptor |
Handwritten | 全ての要素のシリアル化を細かく定義 | buildClassSerialDescriptor |
この記事で取り上げるのは、Handwritten版です。
※種類は「Custom serializers」に紹介されています。
カスタムSerializerの作成
Serializer(KSerializerインターフェース)は「Kotlin serializerがJSONをパースする際に呼び出される関数」を持ちます。
object XXXSerializer : KSerializer<XXX> { override val descriptor: SerialDescriptor get() = TODO("識別子を実装") override fun serialize(encoder: Encoder, value: XXX) { // Json.encodeToString( )で呼び出される // Obj->JSONの変換を実装 } override fun deserialize(decoder: Decoder): XXX { // Json.decodeFromString( )で呼び出される // JSON->Objの変換を実装 return XXX() } }
「JSONの記述⇐クラスのオブジェクト」で呼び出し
KSerializer#deserialize( )「JSONの記述⇒クラスのオブジェクト」で呼び出し
ですので、カスタムSerializerは、このKSerializerインターフェースを継承して、相互変換の方法をserialiseとdesirializeへ実装します。
object LogTimeHandwrittenSerializer : KSerializer<LogTime> { override val descriptor: SerialDescriptor = buildClassSerialDescriptor("com.example.LogTimeHandwriteSerializer") { element<Int>("hh") // index:0を割り当て element<Int>("mm") // index:1を割り当て element<Int>("ss") // index:2を割り当て } // Obj->JSONの変換 override fun serialize(encoder: Encoder, value: LogTime) { encoder.encodeStructure(descriptor) { encodeIntElement(descriptor, 0, value.hh) encodeIntElement(descriptor, 1, value.mm) encodeIntElement(descriptor, 2, value.ss) } } // JSON->Objの変換 override fun deserialize(decoder: Decoder): LogTime { return decoder.decodeStructure(descriptor) { var _hh = 0 var _mm = 0 var _ss = 0 while(true) { when(val _index = decodeElementIndex(descriptor)) { 0 -> _hh = decodeIntElement(descriptor, 0) 1 -> _mm = decodeIntElement(descriptor, 1) 2 -> _ss = decodeIntElement(descriptor, 2) CompositeDecoder.DECODE_DONE -> break // elementの終わり else -> error("Unexpected index: ${_index}") } } LogTime(_hh, _mm, _ss) } } }
SerialDescriptor(シリアル化の構成)
SerialDescriptorでシリアル化の構成を定義しています。つまり、「JSON上でどのように表現するか!」です。
Handwritten(手書き)版の場合、プログラマが全ての要素を細かく定義しなければなりません。
定義にbuildClassSerialDescriptor( )を使います。
override val descriptor: SerialDescriptor = // ↓↓ buildClassSerialDescriptor("com.example.LogTimeHandwriteSerializer") { element<Int>("hh") // index:0を割り当て element<Int>("mm") // index:1を割り当て element<Int>("ss") // index:2を割り当て }
elementは対象クラスの持つ要素を表しています。
要素は3つあり、それぞれのkey名が「hh,mm,ss」で、value値の型がIntであることを表します。ここで、elementの並ぶ順番に、indexが割り当てられます。
JSONの要素の並ぶ順番は任意です。順番は「mm,ss,hh」でも「hh,ss,mm」でも問題ありません。
deserializeにおいて、順番の違いへ対応するために、indexが大きな役割を果たします。
deserialize(JSON⇒Objの変換方法)
シリアル化されたデータ(JSONの記述)は、全てのデータが連結されてリボンの様になっており、先頭から順番に送られてきます。
変換は、このリボン上を「なぞる」ように行う必要があります。要素の処理を行う毎に、ポインター(処理を行う点)が移動すると考えれば良いです。
図のように、indexを取り出すdecodeElementIndexとvalueを取り出すdecodeIntElementが対になります。
override fun deserialize(decoder: Decoder): LogTime { return decoder.decodeStructure(descriptor) { var _hh = 0 var _mm = 0 var _ss = 0 while(true) { when(val _index = decodeElementIndex(descriptor)) { 0 -> _hh = decodeIntElement(descriptor, 0) 1 -> _mm = decodeIntElement(descriptor, 1) 2 -> _ss = decodeIntElement(descriptor, 2) CompositeDecoder.DECODE_DONE -> break // elementの終わり else -> error("Unexpected index: ${_index}") } } LogTime(_hh, _mm, _ss) } }
whenを用いてindex毎に制御を分岐させているのは、先に述べた「JSONの要素の並ぶ順番は任意」のためです。
このようにしておけば、要素の順番が入れ代わっていても、問題ありません。
ちなみに、「順番が一意、要素数が3固定」であると保証されれば、whenは省略できます。
override fun deserialize(decoder: Decoder): LogTime { return decoder.decodeStructure(descriptor) { decodeElementIndex(descriptor) val _hh = decodeIntElement(descriptor, 0) decodeElementIndex(descriptor) val _mm = decodeIntElement(descriptor, 1) decodeElementIndex(descriptor) val _ss = decodeIntElement(descriptor, 2) LogTime(_hh, _mm, _ss) } }
serialize(Obj⇒JSONの変換方法)
SerialDescriptorで定義されたindexの番号を使って、要素のシリアル出力を並べます。
並ぶ順番はencodeIntElementの記述順です。
override fun serialize(encoder: Encoder, value: LogTime) { encoder.encodeStructure(descriptor) { encodeIntElement(descriptor, 0, value.hh) // index:0は「"hh":value」 encodeIntElement(descriptor, 1, value.mm) // index:1は「"mm":value」 encodeIntElement(descriptor, 2, value.ss) // index:2は「"ss":value」 } }
ちなみに、「JSONの要素の並ぶ順番は任意」なので、順番を変えても問題ありません。
override fun serialize(encoder: Encoder, value: LogTime) { encoder.encodeStructure(descriptor) { encodeIntElement(descriptor, 1, value.mm) // index:1は「"mm":value」 encodeIntElement(descriptor, 2, value.ss) // index:2は「"ss":value」 encodeIntElement(descriptor, 0, value.hh) // index:0は「"hh":value」 } }
カスタムSerializerの使用
カスタムSerializerはアノテーション@Serializableの引数で登録します。
Android Studioはビルドをする際に、@Serializable付きのデータクラスを認識して、データクラスのJavaバイナリへ、引数のカスタムSerializerクラスを埋め込みます。
@Serializable(LogTimeHandwrittenSerializer::class) class LogTime(hour: Int, min: Int, sec: Int) { val hh: Int = hour val mm: Int = min val ss: Int = sec override fun toString() = "%s:%s:%s".format(hh, mm, ss) } @Serializable data class Person( val id: Int, val name: String, @SerialName("position") val post: String, // JSONのキーに別名を定義 val times: LogTime )
Json⇒Obj変換
Json#decodeFromStringを呼び出します。
内部で対象クラスのdeserialize( )が呼び出されます。
val _jPerson = """ { "id":1, "name":"Android", "position":"Manager", "times":{"hh":8,"mm":55,"ss":22} } """.replace("\\s+".toRegex(), "") // 空白の削除 val _oPerson = Json.decodeFromString<Person>(_jPerson) Log.i(TAG, "(Serialization) Person Obj = ${_oPerson}")
(Serialization) Person Obj = Person(id=1, name=Android, post=Manager, times=8:55:22)
Obj⇒JSON変換
Json#encodeToStringを呼び出します。
内部で対象クラスのserialize( )が呼び出されます。
val _oPerson = Person( id = 2, name = "Droid", post = "Staff", times = LogTime(12, 2, 0) ) val _jPerson = Json.encodeToString(_oPerson) Log.i(TAG, "(Serialization) Person Json = ${_jPerson}")
(Serialization) Person Json = {"id":2,"name":"Droid","position":"Staff","times":{"hh":12,"mm":2,"ss":0}}
Exceptionのメッセージ、オフセットとは
以下のdeserializeは、「index:1」で処理を打ち切ったため、Exceptionが発生します。
override fun deserialize(decoder: Decoder): LogTime { return decoder.decodeStructure(descriptor) { var _hh = 0 var _mm = 0 var _ss = 0 decodeElementIndex(descriptor) _hh = decodeIntElement(descriptor, 0) decodeElementIndex(descriptor) _mm = decodeIntElement(descriptor, 1) // decodeElementIndex(descriptor) // 原因:index:2が未処理で終了 // _ss = decodeIntElement(descriptor, 2) LogTime(_hh, _mm, _ss) } }
この時、次のようなメッセージが出力されます。
kotlinx.serialization.json.internal.JsonDecodingException: Unexpected JSON token at offset 69: Trailing comma before the end of JSON at path: $.times.mm Trailing commas are non-complaint JSON and not allowed by default. Use 'allowTrailingCommas = true' in 'Json {}' builder to support them. JSON input: {"id":1,"name":"Android","position":"Manager","times":{"hh":8,"mm":55,"ss":22}} --- オフセット69に予期しないJSONトークン。 JSONの末尾にコンマがあります。 末尾のコンマは非準拠のJSONであり、デフォルトでは許可されません。
メッセージに登場する「offset XX」は、シリアル化データ(JSONの記述)の先頭らからの文字数です。
シリアル化データ(JSONの記述)に空白や制御文字(\n,\tなど)があれば、それらの文字もoffsetに含まれるので注意して下さい。
関連記事: