Gsonにレスポンスを殺された日
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の資産は避けたほうがよいなと思いました。
株式会社 カラビナテクノロジーは「命綱や支点を素早く確実に繋ぐカラビナ。そんなカラビナのような役割をテクノロジーで実現したい」という想いのもと、福岡で設立。 主にシステム開発・アプリ開発・ Webサイト制作を行っています。採用情報→karabiner.tech/recruit/requirements/
Discussion