💀

Gsonにレスポンスを殺された日

2024/11/21に公開

API通信周りの実装をリファクタリングしたらバグを埋め込んでしまったのでそこから得られた知見を共有したいと思います。

この記事で得られる教訓

  • 使っているJava製ライブラリについてKotlin製の代替があるならそちらに移行したほうがよい
  • KotlinはJavaにはない機能があるため、Kotlinの実装は悪くなさそうに見えても予期しない振る舞いをとるケースがある
  • とくにデフォルトパラメータとかnon-null周り

レスポンスを格納するdata classをリファクタリングした

とあるエンドポイントのレスポンスを格納しているクラスをリファクタリングしました。
そのエンドポイントは処理の成功/失敗を表すstatusというフィールドを持ち、その値がsuccessか否かで成功/失敗を判定しています。

{ "status": "success" }

クラスは次のようになっていて、成功かどうかを判別するためのisSuccessというフィールドを用意していました。

// before
data class ResultResponse(
    private val status: String? = null,
) {
    val isSuccess: Boolean
        get() = "success" == this.status
}

別にstatusの値は参照するたびに変化するものでもないため、カスタムゲッターである必要はないなとおもったので、普通に割り当てることにしました。

// after
data class ResultResponse(
    private val status: String? = null,
) {
    val isSuccess = "success" == this.status
}

RepositoryではisSuccessがfalseだったら例外を吐いてネットワークエラー扱いされるように作られています。

    .map {
        if (!it.isSuccess) {
            throw RuntimeException()
        }
    }

Crashlyticsの非致命的例外の件数が増えている

この変更を含んだものをいざリリースしました。
最初は30%で公開して、1日様子をCrashlyticsで見て、クラッシュが発生していなかったので、99.99999%に更新しました。
更新後も特にクラッシュは出ていなかったので、ついでに非致命的例外を見ることにしました。
そこで今回変更を加えた箇所から例外が発生しているのを見つけました。

例外が吐かれた場合の挙動がどうなるのかを確認すると、ネットワークエラーが発生したことを示すスナックバーが表示されて、再起動しない限り、データが正しく更新されない状態でした。
これではユーザー操作に悪影響を与えてしまうので、リリースを停止してバグの原因を調査し始めました。

always失敗扱いされるレスポンス

先の例外が吐かれるということはレスポンスが失敗扱いになっているということです。再起動したら正しく描画できるということは処理自体は成功していたということです。ということは成功しているにも関らず、失敗扱いになっていることです。
そこで怪しんだのはレスポンスのJSONをパースするのに使っているGsonです。
手元で直接JSONをパースしてみたところ、statusはsuccessになっているのに、isSuccessはfalseになっているという謎の状態でした。

fun main() {
    val result = Gson().fromString(
        """{"status": "success"}""",
        ResultResponse::class.java,
    )
    println("result: ${result}")
    println("isSuccess: ${result.isSuccess}")
}

// result: ResultResponse(success)
// isSuccess: false ← ファッ!?

Gsonがインスタンス生成時にnullをセットしている

インスタンスが作られたinitの時点でstatusがどうなっているかを確認したところ、この時にnullになってました。

data class ResultResponse(
    private val status: String? = null,
) {
    init {
        println("status: $status")
    }
    val isSuccess = "success" == this.status
}

fun main() {
    val result = Gson().fromString(
        """{"status": "success"}""",
        ResultResponse::class.java,
    )
    println("isSuccess: ${result.isSuccess}")
}

// status: null ← ファッ!?!?
// isSuccess: false

GsonがJava製であることと、パースにリフレクションを利用していることを鑑みると、インスタンス生成時には全てのフィールドがnullでセットされていて、その後リフレクションの黒魔術によって本来の値で更新されているんだろうと推測しました。
isSuccessは、インスタンス生成時の値を使って計算しているため "success" == null => false ということになったのではないでしょうか
Kotlinの実装を見る限りでは全く問題なさそうにみえるのでレビューをすり抜けてしまいました。。
しょうがないのでカスタムゲッターの実装に戻しました。

GsonのREADMEを見るとKotlinとかで使うとサポートしていない機能によって意図しない挙動とるかもしれないから注意してねと書いてありますね。。

[!IMPORTANT]
Gson's main focus is on Java. Using it with other JVM languages such as Kotlin or Scala might work fine in many cases, but language-specific features such as Kotlin's non-null types or constructors with default arguments are not supported. This can lead to confusing and incorrect behavior.
When using languages other than Java, prefer a JSON library with explicit support for that language.

kotlinx-serializationを使ってみる

純Kotlin製のパーサーであるkotlinx-serializationで挙動を見てみたところ、こちらは期待どおりの動きをしていました。

fun main() {
    val result = Json.decodeFromString<ResultResponse>(
        """{"status": "success"}""",
    )
    println("isSuccess: ${result.isSuccess}")
}

// status: success
// isSuccess: true

コードベースがKotlinである以上、完全に期待どおりの振る舞いを実装するためにはできるだけJavaの資産は避けたほうがよいなと思いました。

GitHubで編集を提案
カラビナテクノロジー デベロッパーブログ

Discussion