Kotlin 也有json解析問題?

語言: CN / TW / HK

這是我參與11月更文挑戰的第9天,活動詳情查看:2021最後一次更文挑戰

前言

我們開發用到的最多的json解析工具就是Gson了,毫無疑問它一直兢兢業業的,沒有出現過什麼問題,而且還非常好用,但是,在kotlin中使用,Gson沒有頂住,出現了點小問題,下面總結下問題。

問題一:class字段默認值失效

所有字段都有默認值的情況

``` @JsonClass(generateAdapter = true)data class DefaultAll( val name: String = "me", val age: Int = 17) fun testDefaultAll() { val json = """{}""" val p1 = gson.fromJson(json, DefaultAll::class.java) println("gson parse json: $p1") val p2 = moshi.adapter(DefaultAll::class.java).fromJson(json) println("moshi parse json: $p2")}// 結果// gson parse json: DefaultAll(name=me, age=17)// moshi parse json: DefaultAll(name=me, age=17)

```

可以看到這種情況下gson和moshi都沒有問題

部分字段有默認值

@JsonClass(generateAdapter = true)data class DefaultPart( val name: String = "me", val gender: String = "male", val age: Int) fun testDefaultPart() { // 這裏必須要有age字段,moshi為了保持空安全不允許age為null val json = """{"age": 17}""" val p1 = gson.fromJson(json, DefaultPart::class.java) println("gson parse json: $p1") val p2 = moshi.adapter(DefaultPart::class.java).fromJson(json) println("moshi parse json: $p2")} // 結果// gson parse json: DefaultPart(name=null, gender=null, age=17)// moshi parse json: DefaultPart(name=me, gender=male, age=17)

這種情況下gson忽略了name字段和gender字段默認值,給非空類型設置了一個null值,這個就不符合預期了。而moshi則沒有影響。

問題分析: gson丟失默認值原因

Gson反序列化對象時

  • 先嚐試獲取無參構造函數
  • 失敗則嘗試List、Map等情況的構造函數
  • 最後使用Unsafe.newInstance兜底(此兜底不會調用構造函數,導致所有對象初始化代碼不會調用)

顯然出現這種情況是因為Gson獲取類的無參構造函數失敗了,所以最後走到了unsafe方案。讓我們來看看Kotlin代碼對應的java代碼,一探究竟。AS tools -> kotlin -> show kotlin bytecode可以查看kotlin編譯後的字節碼,decompile後可以查看對應的java代碼。

所有字段都有默認值

public final class DefaultAll { @NotNull private final String name; private final int age; @NotNull public final String getName() { return this.name; } public final int getAge() { return this.age; } public DefaultAll(@NotNull String name, int age) { Intrinsics.checkNotNullParameter(name, "name"); super(); this.name = name; this.age = age; } // $FF: synthetic method public DefaultAll(String var1, int var2, int var3, DefaultConstructorMarker var4) { if ((var3 & 1) != 0) { var1 = "me"; } if ((var3 & 2) != 0) { var2 = 17; } this(var1, var2); } public DefaultAll() { this((String)null, 0, 3, (DefaultConstructorMarker)null); } } 可以看到這種情況下該類會生成空參構造函數,但是空參構造函數中並沒有賦值,而是調用了synthetic method這個額外生成的輔助構造函數對字段賦默認值。synthetic method倒數第二個參數是一個int類型,用於標記哪些字段使用默認值賦值,按字段聲明順序它們對應的flag值為2^n也就是1 2 4 8....

因為存在空參構造函數而且會賦值默認值,所以這種情況下gson使用正常。

部分字段有默認值

public final class DefaultPart { @NotNull private final String name; @NotNull private final String gender; private final int age; @NotNull public final String getName() { return this.name; } @NotNull public final String getGender() { return this.gender; } public final int getAge() { return this.age; } public DefaultPart(@NotNull String name, @NotNull String gender, int age) { Intrinsics.checkNotNullParameter(name, "name"); Intrinsics.checkNotNullParameter(gender, "gender"); super(); this.name = name; this.gender = gender; this.age = age; } // $FF: synthetic method public DefaultPart(String var1, String var2, int var3, int var4, DefaultConstructorMarker var5) { // 最低不為0表示第一個默認值字段在json中無值,需要默認值 if ((var4 & 1) != 0) { var1 = "me"; } if ((var4 & 2) != 0) { var2 = "male"; } this(var1, var2, var3); } } 這種情況下該類並沒有生成空參構造函數,所以gson實例化時使用了Unsafe,自然默認值不生效。實際上只有所有字段都有默認值時才會生成空參構造函數。

解決方案:

分析了這麼多,避免默認值無效的方法已經顯而易見了

  1. 定義類時所有字段都給一個默認值,這樣gson就可以正常工作
  2. 使用Moshi庫

Moshi庫屬於square公司,最初由Jake Wharton主導,他是kotlin的擁躉,不難推測moshi對Kotlin做了兼容,實際上也是這樣。

Moshi序列化/反序列化時根據每個類反射創建對應的JsonAdapter,用它來進行具體操作,同時支持使用annotationProcessor編譯時預先生成各個類的JsonAdapter,空間換時間提升性能。 從它的源碼可以看出來,它做了2件事: 1. 用一個int記錄(字段超過32個使用多個int)默認值字段在將要解析的json中是否存在,從最低位到最高位依次記錄第一個到最後一個默認值字段在json中是否有key,0表示存在,1表示不存在 1. 判斷是否所有默認字段在json中都有值,若為true則不用管默認值,直接使用json字段生成實例,若為false則反射調用(synthetic構造器只能夠反射調用)synthetic構造器實例化對象,synthetic構造器會根據標誌位為默認值字段賦值

一言蔽之,Moshi通過遵循Kotlin的機制做到了兼容。

問題二: Json中value為null的情況

正常情況下後端返回的Json數據中只應該存在Object類型字段為null的情況,但是現實很骨感,不乏String類型/list類型丟過來也是null的情況。

  • 在Java中,null value會覆蓋掉默認值,使用時get方法中判空就可以了。
  • 但是在Kotlin中,如果該字段聲明為非空類型,使用gson序列化後非空類型字段會被賦予null值,雖然由於空安全檢查是在編譯器進行不會報異常,但是這明顯非常不符合預期。
  • 而Moshi中對這個情況做了處理,非空字段對應的json value為null時拋JsonDataException,對應的key都不存在時也做同樣處理

這些處理邏輯看起來都很合情合理,但是實際開發中不可預期的null value情況又確實存在,我們也不太可能將所有字段都聲明為可空類型,那麼將Json中null value自定義解析成預設值或許是一個比較好的方法。

Gson自定義解析替換null value

Gson自定義解析使用TypeAdapterFactory或者單TypeAdapter,下面示例將聲明為String和List的字段通過自定義解析器替換Json中null value為空字符串和空list ``` class GsonDefaultAdapterFactory: TypeAdapterFactory { override fun create(gson: Gson, type: TypeToken): TypeAdapter? { if (type.type == String::class.java) { return createStringAdapter() } if (type.rawType == List::class.java || type.rawType == Collection::class.java) { return createCollectionAdapter(type, gson) } return null }

/**
 * null替換成空List
 */
private fun <T : Any> createCollectionAdapter(
    type: TypeToken<T>,
    gson: Gson
): TypeAdapter<T>? {
    val rawType = type.rawType
    if (!Collection::class.java.isAssignableFrom(rawType)) {
        return null
    }

    val elementType: Type = `$Gson$Types`.getCollectionElementType(type.type, rawType)
    val elementTypeAdapter: TypeAdapter<Any> =
        gson.getAdapter(TypeToken.get(elementType)) as TypeAdapter<Any>

    return object : TypeAdapter<Collection<Any>>() {
        override fun write(writer: JsonWriter, value: Collection<Any>?) {
            writer.beginArray()
            value?.forEach {
                elementTypeAdapter.write(writer, it)
            }
            writer.endArray()
        }

        override fun read(reader: JsonReader): Collection<Any> {
            val list = mutableListOf<Any>()
            // null替換為空list
            if (reader.peek() == JsonToken.NULL) {
                reader.nextNull()
                return list
            }
            reader.beginArray()
            while (reader.hasNext()) {
                val element = elementTypeAdapter.read(reader)
                list.add(element)
            }
            reader.endArray()
            return list
        }

    } as TypeAdapter<T>
}

/**
 * null 替換成空字符串
 */
private fun <T : Any> createStringAdapter(): TypeAdapter<T> {
    return object : TypeAdapter<String>() {
        override fun write(writer: JsonWriter, value: String?) {
            if (value == null) {
                writer.value("")
            } else {
                writer.value(value)
            }
        }

        override fun read(reader: JsonReader): String {
            // null替換為""
            if (reader.peek() == JsonToken.NULL) {
                reader.nextNull()
                return ""
            }
            return reader.nextString()
        }

    } as TypeAdapter<T>
}

} ```

測試代碼:

``` val gson: Gson = GsonBuilder() .registerTypeAdapterFactory(GsonDefaultAdapterFactory()) .create()

data class Person( val name: String, val friends: List )

fun testGsonNullValue() { // 這裏必須要有age字段,moshi為了保持空安全不允許age為null val json = """{"name":null, "friends":null}""" val p1 = gson.fromJson(json, Person::class.java) println("gson parse json: $p1") } `` 運行結果gson parse json: Person(name=, friends=[])`,符合預期

同樣的 Moshi 庫也可以完全解決Gson這種null值帶來的問題

解決方案:

  1. 使用Gson給所有定義字段賦值默認值+自定義解析將不可預期的null值過濾
  2. 使用Moshi,自定義解析過濾不可預期的null值

總結:

遇到kotlin中json解析的問題,可以參考以上解決方案,希望可以幫到大家。