BLOGサブスレッドの日常

2022.06.11

GSONでジェネリクスの型を4つ以上入れ子にするとパース後の型がLinkedHashMapになる

s.kono

むおおおお!

androidアプリ開発で、GSONを使って
JSON文字列をJavaオブジェクトに変換した後の型が意図した型にならない現象が起きたので、そのことについて書きます。

何が起きたのか

サーバーから取得したJSON文字列をアプリ内で利用するため、GSONライブラリを使ってJSONをJavaオブジェクトに変換するプログラムを書きました。
プログラムを実行したところ、変換したJavaオブジェクトを参照したタイミングでClassCastExceptionが発生してしまうという現象が発生しました。

通常、ClassCastExceptionはインスタンスを別の型にキャストできなかった場合に発生する例外です。しかしキャストするプログラムを書いておらず首をかしげてしまいました。

何が起きたのかを調べるため、デバッガを使って例外が発生した箇所のプログラムを追ってみました。
すると、GSONから返ってきたインスタンスのうち、ジェネリクスの型を4つ以上入れ子にしたインスタンスの型が、自分が定義した型のインスタンスではなくLinkedHashMap型のインスタンスとなっていました。

再現コード

以下のコードで再現できます。
kotlin言語で書いたJUnitTestノコードです。
GSONのバージョンは 2.9.0 です。

class GsonNestedGenericsUnitTest {

    open class JsonApiEnvelope<Data>(
        val data: Data? = null,
    )

    class DataEnvelope<T>(
        val id: String,
        val type: String,
        val attributes: T,
    )

    class MyResponse : JsonApiEnvelope<List<DataEnvelope<MyResponse.Attributes>>>() {
        class Attributes(
            val name: String,
            val created_at: String,
        )
    }

    val jsonString = """
            {
                "data": [
                    {
                        "type": "user",
                        "id": "1",
                        "attributes": {
                            "name": "むおおおお",
                            "created_at": "2022-01-01T12:00:59+09:00"
                        }
                    }
                ]
            }
        """.trimIndent()

    @Test
    fun example_gson_nested_generics() {
        // GSONを使ってJavaオブジェクトにマッピングする
        val gson = Gson()
        val obj = gson.fromJson(jsonString, MyResponse::class.java)

        // テストコード (JUnit)
        val arrayOfData = obj.data!!
        assertEquals(arrayOfData.size, 1)

        val firstData = arrayOfData[0]
        assertEquals(firstData.id, "1")
        assertEquals(firstData.type, "user")
        assertEquals(firstData.attributes.name, "むおおおお") // ここでClassCastExceptionが発生する
        assertEquals(firstData.attributes.created_at, "2022-01-01T12:00:59+09:00")
    }

}

上記を実行し「ここでClassCastExceptionが発生する」のコメントの箇所でClassCastExceptionが発生します。
発生箇所でブレークポイントを張ってattributes変数の型を見ると、attributesがMyResponse.Attributes型ではなく、LinkedHashMap型になっていることが分かります。

原因

はっきりとした原因はわかりませんが、GSONの仕様…?と思っています。
プログラムをちょっとずつ組み替えてみても、やはりジェネリクスの型を4つ以上入れ子にしたインスタンスの型がLinkedHashMapになっていました。

対策

4つ以上入れ子にしないようにプログラムを組み替えればOKです。
以下のMyResponse2クラスのように、GSONに渡すクラスの定義を変更します。

(...省略...)
    class MyResponse2 : JsonApiEnvelope<List<MyResponse2.Data>>() {

        class Data(
            val id: String,
            val type: String,
            val attributes: Attributes,
        )

        class Attributes(
            val name: String,
            val created_at: String,
        )
    }

    @Test
    fun example_gson_nested_generics_2() {
        // GSONを使ってJavaオブジェクトにマッピングする
        val gson = Gson()
        val obj = gson.fromJson(jsonString, MyResponse2::class.java)

        // テストコード (JUnit)
        val arrayOfData = obj.data!!
        assertEquals(arrayOfData.size, 1)

        val firstData = arrayOfData[0]
        assertEquals(firstData.id, "1")
        assertEquals(firstData.type, "user")
        assertEquals(firstData.attributes.name, "むおおおお") // OK
        assertEquals(firstData.attributes.created_at, "2022-01-01T12:00:59+09:00")
    }
(...省略...)

🍺

ある程度ルールが決まったJSON構造が返ることが分かっている場合、共通のクラスを作っておいて使い回すとプログラムが短くなってよいのですが、今回みたいな制限に当たることもあるよ、という話でした。

原因次第ではありますが、最近は

  • GSONよりもmoshi
  • ライブラリ側はリフレクションを使わず、コードジェネレータを使ってリフレクション使わなくて済むようにする

みたいな風潮があるので、当記事のような現象を踏むことも減ってくるかもしれませんね。

この記事を書いた人

s.kono