Gsonからkotlin serializationへの移行

この記事はトリスタinsideで書かれた記事です。
現在トリスタinsideはBOOK☆WALKER Tech Blogに統合されました。

f:id:bookwalker_developers:20210930222907p:plain アプリチームのKです。Android版ニコニコ漫画 / 読書メーターの開発を担当しています。 ニコニコ漫画アプリではserialization / deserialization libraryとしてGsonを利用していましたが、kotlin-serializationへmigrationを行いました。 公式のドキュメントは豊富であり、また漫画アプリでは複雑なモデルを利用していなかったこともありスムースに移行できたと思います。とは言え、ドキュメントが豊富なおかげで逆に必要な情報を見つけ出すのに多少苦労したことも事実ですし、またstable版がリリースされて日が浅いこともあり一般の情報が少ないとも感じました。そこでこの記事ではGsonからのmigrationに着目し、移行方法、及び注意点について紹介していきたいと思います。

環境

記事中のコードは次の環境下で動作確認しています。

target version
kotlin 1.4.31
kotlin-serialization 1.1.0
retrofit 2.5.0
retrofit2-kotlinx-serialization-converter 0.8.0
okhttp 4.9.1
Gson 2.7

Http clientとしてretrofit、レスポンスコンバーターとしてJake氏によるretrofit2-kotlinx-serialization-converterを利用しています。okhttpはMediaType生成用に4系を利用しています。

移行方法

  1. シリアル化対象クラスへ@Serializableを追加
+    @Serializable
     class Color(val rgb: Int)

kotlin-serializationではシリアル化可能なクラスへ@Serializableを付与する必要があります。このマークを元にpluginはserializerを生成します。これにより意図しないクラスが処理されることを防いでいるようです。なお、enum classであった場合には追加不要です。ただし、@SerialNameを持つ場合は必要となりますのでご注意ください。

  1. @SerializedNameを@SerialNameへ
     class Project(
          val name: String,
+         @SerialName("lang")
-         @Serialized("lang")
          val language: String)

フィールド名とシリアル名が異なる場合、@SerialNameを使用します。Gsonではプロパティ(メソッド)のみに付与可能でしたがkotlin-serializationではClassに対しても付与可能です。

  1. enumクラスの扱い

未定義のenum valueに対しての処理に違いがあります。次のようなクラス、及びserial valueがあるとします。

enum class Status { ACTIVE, INACTIVE, UNKNOWN }

// enum未定義のvalue
{'status': 'PENDING'}

この場合、Gsonではnullとして処理されますが、kotlin-serializationはdeserializeエラーとなります。その為、enumクラスに対しては基本的にvalue解決用のserializerを独自実装する必要があります。

@Serializer(with = StatusSerializer::class)
enum class Status { ACTIVE, INACTIVE, UNKNOWN }

object StatusSerializer : KSerializer<Status> {
    override val descriptor: SerialDescriptor = StringDescriptor
    
    override fun serialize(output: Encoder, obj: Status) {}

    override fun deserialize(input: Decoder): Status {
      // resolove value.
      return Status.resolve(decoder.decodeString())
    }
  }

執筆時点においてはdefalut valueの設定機能は存在していませんが、要望としては多いようなので1近い将来実装されるかもしれません。

  1. non-null types

Gson with koltin環境においてよく知られている問題としてGsonがkotlinのnon-null typeを処理できない件があります。次のようなモデル、シリアル値があるとします。

data class Project(val name: String, val type: Int)

{"name": null, "type": 0}

nameプロパティはnon-null typeですが、シリアル値はnullとなっています。デシリアライズでエラーとなってくれそうですがGsonではエラーとなりません。これはGsonがリフレクションを利用し、言語ルールを回避できているためです。kotlin serializationではエラーとなります。

  1. オプション値の扱い

deserialization時、Gsonではmodelのプロパティが存在しシリアル値が存在しない場合、その値はオプション値として扱われます。次のケースではtypeはオプションとして扱われ、0(タイプに応じたデフォルト値)がセットされます。

data class Project(val name: String, val type: Int)

{"name": "kotlinx.serialization"}

kotlin-serializationで上記を処理する場合、デシリアライズエラーとなります。kotlin-serializationでオプション値を扱う場合は手動でデフォルト値をセットする必要があります。

+    @Serializable
     data class Project(
          val name: String,
-         val type: Int
+         val type: Int = 0
     )

{"name": "kotlinx.serialization"}
  1. json decode

Gson、kotlin-serializationそれぞれの標準的なdecode処理ですが次のような形になると思います。

Gson

    fun <T> decodeData(json: String, typeReference: TypeToken<T>): T {
        return gson.fromJson(json, type.getType());
    }

kotlin-serialization

    inline fun <reified T> decodeData(json: String): T {
        return decodeFromString<T>(json)
    }

kotlin-serializationはリフレクションに対応していません。コンパイル時点で型が定義されていることが必須となりますが、inline展開することでこの問題を回避しています。

collectionの場合

Listのケースで説明します。タイプTに対するシリアライザーのシリアライザーを作成する必要があります。ListではListSerializerとなります。

Gson

class TestJsonObject

object : TypeToken<List<TestJsonObject>>() {}

kotlin-serialization

@Serializable
class TestJsonObject

ListSerializer(TestJsonObject.serializer())
  1. deserialize時の不明キーの取り扱い

Gsonの場合、モデル未定義のシリアル値についてはデフォルトで無視されますが、kotlin-serializationではデフォルト設定ではdeserializeエラーとなります。次のコードを実行した場合、

@Serializable 
data class Project(val name: String)
    
fun main() {          
    val format = Json()   
    val data = format.decodeFromString<Project>("""
        {"name":"kotlinx.serialization","language":"Kotlin"}
    """)
    println(data)
}

次のエラーが発生します。

kotlinx.serialization.SerializationException: Strict JSON encountered unknown key: language

未定義のシリアル値を無視する場合、次のようにJsonオブジェクトにignoreUnknownKeysを設定する必要があります。

-    val format = Json()
+    val format = Json { ignoreUnknownKeys = true }
  1. 構文解析

kotlin-serializationデフォルトのJsonパーサーは可能な限り仕様(RFC4627)に準拠しています。これはGsonに比べて厳格であることを意味し、結果、Gson利用時は発生していなかったパースエラーが発生する可能性があります(実際ニコニコ漫画アプリではエラーとなるケースがありました)。例としてタイプ不一致があります。次のようなケースです。

data class Project(val id: String)

{"id": 12345}

シリアル値は数値、デシリアライズするモデルのプロパティがStringであった場合、Gsonではエラーとなりませんがkolin-serializationでは次のエラーが発生します

kotlinx.serialization.json.internal.JsonDecodingException: String literal for key 'id' should be quoted.

isLenientプロパティを利用することでJsonの制約を緩和することができます。

-    val format = Json()
+    val format = Json { isLenient = true }

なお項番7, 8で紹介したjsonのパースに関しては公式のドキュメント、及びconfigのコードを確認することでより深い理解を得られると思います。

最後に

Gsonからのマイグレーションにフォーカスして移行方法・及び注意点を紹介してきました。リフレクションでなくなったことでコード量(アプリサイズ)の増加という影響はありますがその差はごく僅かであり、それ以上に享受できるパフォーマンス改善や曖昧性の解消等のメリットの方が大きいと思います。experimentalな機能も多く今後の拡張にも期待できますのでフォローし続けていきます。


  1. 最初の提案から3年経過していますが。https://github.com/Kotlin/kotlinx.serialization/issues/90