1. 程式人生 > 其它 >gson typetoken_使用Gson解析data class引發的一點思考

gson typetoken_使用Gson解析data class引發的一點思考

技術標籤:gson typetoken

Gson是Android解析Json的老牌子了,它的使用和原理也被大家研究的極其透徹了,可以說這是一個相當成熟的庫。但是伴隨kotlin的普及,有一個問題也越發明顯地暴露了出來。

kotlin裡有一個 data class 的概念,倒不是什麼“黑科技”的東西,但是確實相當好用,它會自動生成hashcode、equals以及toString等方法,都是對於一個bean來說很重要的方法。但是這麼好用的東西在和gson一起使用時就出現了一點意外。讓我們看下邊的例子:

//定義
dataclassTestBean(
valname:String,
valage:Int
)

//資料
valjson="""
{"name":null,"age":null}
""".trimIndent()

//解析
valbean=gson.fromJson(json,TestBean::class.java)

//輸出
TestBean(name=null,age=0)

把json換成 {}{"name":null}{"age":null},甚至 {"age":0} 都不會影響輸出結果。也就是說,當gson解析data class時,kotlin的null-safe失效了。

其實這個問題不是data class造成的,問題主要在null-safe,只是data class和gson打交道最多而已。當然也不能怪gson,誰讓gson火起來的時候kotlin還沒多少知名度呢。

追溯問題產生原因

遇到問題自然要追蹤原始碼了,想必很多人這樣試過,最終都會定位到 ReflectiveTypeAdapterFactory.java 這個類中。為了節約大家的時間,這裡把相關的部分貼出來:

publicfinalclassReflectiveTypeAdapterFactoryimplementsTypeAdapterFactory{
//...

@OverridepublicTypeAdaptercreate(Gsongson,finalTypeTokentype){//...
ObjectConstructorconstructor=constructorConstructor.get(type);returnnewAdapter(constructor,getBoundFields(gson,type,raw));
}privateReflectiveTypeAdapterFactory.BoundFieldcreateBoundField(finalGsoncontext,finalFieldfield,finalStringname,finalTypeToken>fieldType,booleanserialize,booleandeserialize){//...returnnewReflectiveTypeAdapterFactory.BoundField(name,serialize,deserialize){//[email protected]voidread(JsonReaderreader,Objectvalue)throwsIOException,IllegalAccessException{
ObjectfieldValue=typeAdapter.read(reader);if(fieldValue!=null||!isPrimitive){
field.set(value,fieldValue);
}
}
};
}//...publicstaticfinalclassAdapter<T>extendsTypeAdapter<T>{//[email protected]publicTread(JsonReaderin)throwsIOException{if(in.peek()==JsonToken.NULL){
in.nextNull();returnnull;
}
Tinstance=constructor.construct();try{
in.beginObject();while(in.hasNext()){
Stringname=in.nextName();
BoundFieldfield=boundFields.get(name);if(field==null||!field.deserialized){
in.skipValue();
}else{
field.read(in,instance);
}
}
}//...returninstance;
}
}
}

這裡有兩處需要我們關注,第一處就是 T instance = constructor.construct(); 這個 constructor 是一個 ObjectConstructor 物件,在 ConstructorConstructor 類裡可以找到它的實現:

publicfinalclassConstructorConstructor{
//...

publicObjectConstructorget(TypeTokentypeToken){finalTypetype=typeToken.getType();finalClasssuperT>rawType=typeToken.getRawType();//firsttryaninstance[email protected]("unchecked")//typesmustagreefinalInstanceCreatortypeCreator=(InstanceCreator)instanceCreators.get(type);if(typeCreator!=null){returnnewObjectConstructor(){@OverridepublicTconstruct(){returntypeCreator.createInstance(type);
}
};
}//...
ObjectConstructordefaultConstructor=newDefaultConstructor(rawType);if(defaultConstructor!=null){returndefaultConstructor;
}
ObjectConstructordefaultImplementation=newDefaultImplementationConstructor(type,rawType);if(defaultImplementation!=null){returndefaultImplementation;
}//finallytryunsafereturnnewUnsafeAllocator(type,rawType);
}

Gson 例項化物件分為四種情況:

  1. 使用我們自定義的 InstanceCreator,可以在初始化時加入它;
  2. 使用預設構造器,也就是無參建構函式;
  3. 如果是 Collection 或 Map,則返回對應的物件;
  4. 使用 UnSafe。

自定義 InstanceCreator 不現實,在這個問題上有多少 data class,就得準備多少 InstanceCreator。Collection 或 Map 也排除了,我們要處理的是物件。也就是說只有方式 2 和 4 可用,我們沒有提供預設構造器,所以 Gson 使用了 UnSafe 這種手段。我們這裡不追究 UnSafe 是什麼,只要確認使用了 UnSafe,就會產生上述結果就好了,不過有一句必須注意,它不會走我們的構造器

第二處需要注意的就是為什麼 String 被賦值為 null,但 Int 沒有問題?這個玄機就在 createBoundField 方法裡,我們再貼一遍:

privateReflectiveTypeAdapterFactory.BoundFieldcreateBoundField(finalGsoncontext,finalFieldfield,finalStringname,finalTypeToken>fieldType,booleanserialize,booleandeserialize){
//...

returnnewReflectiveTypeAdapterFactory.BoundField(name,serialize,deserialize){
//...
@Overridevoidread(JsonReaderreader,Objectvalue)throwsIOException,IllegalAccessException{
ObjectfieldValue=typeAdapter.read(reader);
//如果有值,或者不是Primitive型別,就賦值
if(fieldValue!=null||!isPrimitive){
field.set(value,fieldValue);
}
}
};
}

if (fieldValue != null || !isPrimitive) 在這裡起了很大作用,像 int、char、boolean 以及對應的包裝類等都屬於基本型別,條件不成立所以不會賦值,但字串和普通物件不是基本型別,於是就發生了一開始我們看到的現象。

如何解決

現在是解決問題的時候了,一個自然的想法是避免 UnSafe,只要提供預設構造器即可。讓我們試試看:

dataclassTestBean2(valname:String="",valage:Int=0)

@Test
fundeserializeWithDefaultConstructor(){
valjson1="""
{}
""".trimIndent()
valbean1=gson.fromJson(json1,TestBean2::class.java)
println(bean1)

valjson2="""
{"name":null}
""".trimIndent()
valbean2=gson.fromJson(json2,TestBean2::class.java)
println(bean2)

valjson3="""
{"age":null}
""".trimIndent()
valbean3=gson.fromJson(json3,TestBean2::class.java)
println(bean3)

valjson4="""
{"age":0}
""".trimIndent()
valbean4=gson.fromJson(json4,TestBean2::class.java)
println(bean4)
}

輸出的結果是這樣的:

TestBean2(name=,age=0)
TestBean2(name=null,age=0)
TestBean2(name=,age=0)
TestBean2(name=,age=0)

看起來好了很多,只有 json 返回了 name=null 才會出現問題,這說明我們解決了問題一,但沒解決問題二。gson正確地拿到了物件,隨後又把 null 賦值給了 name,而且是用反射強行賦值的。如何解決問題二,反而成為了關鍵。

使用預設構造器是比較常見的解決方式,但當json顯式返回null時該問題依然存在,所以還需要進一步處理。

既然使用預設值不管用,那麼宣告所有欄位為可空 ? 型別就可以很簡單地規避這個問題。但還需要考慮另一個問題:fail-fast,也就是快速失敗。Json裡的資料也許大部分是可空的,但總有幾個欄位是不可空的,這是由業務本身決定的,例如一個使用者的uid明顯不能為空。而使用可空引數就可能讓一個空的uid“混”進來,在後續操作中引發一連串的錯誤。當然使用預設引數也有同樣的問題。

在可空引數的基礎上,提供一個不可空的getter可以有效地避免以上問題,例如對data class做以下處理:

dataclassTestBean3(
@SerializedName("name")
privateval_name:String?,
valage:Int
){
valname:String
get()=_name?:""//返回預設值或者丟擲異常
}

這是一個行之有效的方案,也是我認為當代碼庫和gson深度耦合後較好的解決方案,雖不能在解析時就發現問題,但也比使用之後出問題強的多。只不過這樣一來比較繁瑣,二來每個 bean 都會比原來大一些。

除此之外,square出品的moshi還提供了兩種不同的思路,一種是使用kotlin-reflection,kotlin的反射和java不太一致,你需要依賴一個至少2.5M的jar檔案,而且反射的效能肯定差一些。另一種方案是在編譯時為每個data class生成TypeAdapter。要參考這兩種方案,可以檢視moshi的原始碼,地址是:moshi[1]。另外,kotlin官方也提供了自己的解析庫,它更考慮了kotlin本身的全部特性,這個庫是kotlinx.serialization[2]。JakeWharton也為之增加了對應的retrofit converter:retrofit2-kotlinx-serialization-converter[3]

也就是說,如果可以擺脫gson,使用moshi或serialization在kotlin程式設計時可以獲得更好的體驗。若要使用gson,要麼按照上述方式使用兩個變數實現非空校驗,要麼參考moshi的做法自己寫一套gson的實現。(其實是重複造輪子了,若無必要不建議這樣操作==!)

我的思考

gson是谷歌出品的解析庫,kotlin又是谷歌力推的開發語言,中間出現這樣的不相容問題的確出乎所料,但作為開發者應當總有自己的應對之法。現在的專案大多使用Retrofit進行網路請求,json的轉換也是通過ConverterFactory完成的,其他需要手動解析json的地方也可以作簡單的封裝,而不是隨用隨建立Gson物件,因此專案本身對gson的依賴並不強烈。如果專案和gson發生了深度耦合,就應該考慮下自己寫程式碼時是不是太隨意了一些?

另外一點是將data class全部宣告為可空 ? 型別只能算是一種臨時方案,因為問題的根源在gson不相容kotlin特性,而不是data class出現了問題。解決問題應該從根源出發,而不是破壞其餘部分的結構,造成問題範圍擴大,也是設計程式碼時應該遵守的原則之一。

參考資料

[1]

moshi: https://github.com/square/moshi/tree/master/kotlin

[2]

kotlinx.serialization: https://github.com/Kotlin/kotlinx.serialization

[3]

retrofit2-kotlinx-serialization-converter: https://github.com/JakeWharton/retrofit2-kotlinx-serialization-converter

更多文章正在火速連載中,感謝您的關注!

50ad7b92833cf5478c979915ff3a267d.png

掃描一下二維碼就可以關注哦