Gson 和 Kotlin data class 的避坑指南

在蠻久前有同事問過我關於一個 Gson 和 Kotlin dataClass 的問題,當時答不上來也沒去細究,但一直都放在心底,今天就認真探究下原因,也輸出總結了一下,希望能幫助你避開這個坑😂😂

來看個小例子,猜猜其運行結果會是怎樣的

/**
 * @Author: leavesC
 * @Date: 2020/12/21 12:23
 * @Desc:
 * GitHub:https://github.com/leavesC
 */
data class UserBean(val userName: String, val userAge: Int)

fun main() {
    val json = """{"userName":null,"userAge":26}"""  
    val userBean = Gson().fromJson(json, UserBean::class.java) //第一步
    println(userBean) //第二步
    printMsg(userBean.userName) //第三步
}

fun printMsg(msg: String) {

}

UserBean 是一個 dataClass,其 userName 字段被聲明爲非 null 類型,而 json 字符串中 userName 對應的值明確就是 null,那用 Gson 到底能不能反序列化成功呢?程序能不能成功運行完以上三個步驟?

實際上程序能夠正常運行到第二步,但在執行第三步的時候反而直接報 NPE 異常了

UserBean(userName=null, userAge=26)
Exception in thread "main" java.lang.NullPointerException: Parameter specified as non-null is null: method temp.TestKt.printMsg, parameter msg
    at temp.TestKt.printMsg(Test.kt)
    at temp.TestKt.main(Test.kt:16)
    at temp.TestKt.main(Test.kt)

一、爲啥會拋出 NEP?

printMsg 方法接收了參數後實際上什麼也沒做,爲啥會拋出 NPE?

通過 IDEA 將printMsg反編譯爲 Java 方法,可以發現方法內部會對入參進行空校驗,當發現爲 null 時就會直接拋出 NullPointerException

   public static final void printMsg(@NotNull String msg) {
      Intrinsics.checkNotNullParameter(msg, "msg");
   }

這個比較好理解,畢竟 Kotlin 的類型系統會嚴格區分可 null不可爲 null 兩種類型,其區分手段之一就是會自動在我們的代碼裏插入一些類型校驗邏輯,即自動加上了非空斷言,當發現不可爲 null 的參數傳入了 null 的話就會馬上就拋出 NPE,即使我們並沒有用到該參數

當然,這個自動插入的校驗邏輯只會在 Kotlin 代碼中生成,如果我們是將 userBean.userName傳給 Java 方法的話,就不會有這個效果,而是會等到我們使用到了該參數時才發生 NPE

二、Kotlin 的 nullSafe 失效了嗎?

既然 UserBean 中的 userName 字段已經被聲明爲非 null 類型了,那麼爲什麼還可以反序列化成功呢?按照我自己的第一直覺,應該在進行反序列的時候就直接拋出異常纔對,Gson 是怎麼繞過 Kotlin 的 null 檢查的呢?

這個需要來看下 Gson 是如何實現反序列的

通過斷點,可以發現 UserBean 是在 ReflectiveTypeAdapterFactory 裏完成構建的,這裏的主要步驟就分爲兩步:

  • 通過 constructor.construct()得到一個 UserBean 對象,此時該對象內部的屬性值都爲默認值
  • 遍歷 JsonReader,根據 Json 內部的 key 值和 UserBean 包含的字段進行對應,對應得上的話就進行賦值

第二步很好理解,那第一步又是具體怎麼實現的?再斷點看下constructor.construct()是如何實現的

constructor 的取值途徑可以在 ConstructorConstructor 這個類中看到

分爲三種可能:

  • newDefaultConstructor。通過反射無參構造函數來生成對象
  • newDefaultImplementationConstructor。通過反射爲 Collection 和 Map 等集合框架類型來生成對象
  • newUnsafeAllocator。通過 Unsafe 包來生成對象,是最後兜底的方案

首先,第二個肯定不符合條件,看第一個和第三個就行

作爲一個 dataClass,UserBean 是否有無參構造函數呢?反編譯後可以看到是沒有的,只有一個包含兩個參數的構造函數,所以第一步也肯定會反射失敗

public final class UserBean {
   @NotNull
   private final String userName;
   private final int userAge;

   @NotNull
   public final String getUserName() {
      return this.userName;
   }

   public final int getUserAge() {
      return this.userAge;
   }

   public UserBean(@NotNull String userName, int userAge) {
      Intrinsics.checkNotNullParameter(userName, "userName");
      super();
      this.userName = userName;
      this.userAge = userAge;
   }

   ···
    
}

此外,還有一種方法可以驗證出來 UserBean 沒有被調用到構造函數。我們知道,子類在通過構造函數來進行初始化的時候,肯定是需要先連鎖調用父類的構造函數,那麼就可以通過爲 UserBean 聲明一個父類,然後通過判斷父類的 init 方法塊是否有打印日誌就可以知道 UserBean 是否有被調用到構造函數了

open class Person() {

    init {
        println("Person")
    }

}

data class UserBean(val userName: String, val userAge: Int) : Person()

前兩種都無法滿足,再來看 newUnsafeAllocator 是如何進行兜底的

Unsafe 是位於 sun.misc 包下的一個類,主要提供一些用於執行低級別、不安全操作的方法,如直接訪問系統內存資源、自主管理內存資源等,這些方法在提升Java運行效率、增強Java語言底層資源操作能力方面起到了很大的作用。但由於Unsafe 類使 Java 語言擁有了類似 C 語言指針一樣操作內存空間的能力,這無疑也增加了程序發生相關指針問題的風險。在程序中過度、不正確使用 Unsafe 類會使得程序出錯的概率變大,使得Java這種安全的語言變得不再“安全”,因此對 Unsafe 的使用一定要慎重

Unsafe 提供了一個非常規實例化對象的方法。Unsafe 中包含一個 allocateInstance 方法,僅通過 Class 對象就可以創建此類的實例對象,而且不需要調用其構造函數、初始化代碼、JVM安全檢查等。它抑制修飾符檢測,也就是即使構造器是 private 修飾的也能通過此方法實例化,只需提類對象即可創建相應的對象

Gson 的 UnsafeAllocator 類中就通過 allocateInstance 方法來完成了 UserBean 的初始化,因此也不會調用到其構造函數

做下總結:

  • UserBean 的構造函數只有一個,其包含兩個構造參數,在構造函數內部也對 userName 這個字段進行了 null 檢查,當發現爲 null 時會直接拋出 NPE
  • Gson 是通過 Unsafe 包來實例化 UserBean 對象的,並不會調用到其構造函數,相當於繞過了 Kotlin 的 null 檢查,所以即使 userName 值爲 null 最終也能夠反序列化成功

三、構造參數默認值失效?

再看個例子

如果我們爲 UserBean 的 userName 字段設置了一個默認值,且 json 中不包含該 key,那麼會發現默認值並不會生效,還是爲 null

/**
 * @Author: leavesC
 * @Date: 2020/12/21 12:23
 * @Desc:
 * GitHub:https://github.com/leavesC
 */
data class UserBean(val userName: String = "leavesC", val userAge: Int)

fun main() {
    val json = """{"userAge":26}"""
    val userBean = Gson().fromJson(json, UserBean::class.java)
    println(userBean)
}
UserBean(userName=null, userAge=26)

爲構造參數設置默認值是一個很常見的需求,能降低使用者初始化對象的成本,而且如果將 UserBean 作爲網絡請求接口的承載體的話,接口可能不會返回該字段,此時也希望該字段能夠有個默認值

通過上一節內容的分析,我們知道 Unsafe 包是不會調用 UserBean 的任何構造函數的,所以默認值也一定不會生效,那就只能找其它解決方案了,有以下幾種方案可以解決:

1、無參構造函數

UserBean 提供一個無參構造函數,讓 Gson 通過反射該函數來實例化 UserBean,從而同時進行默認值賦值

data class UserBean(val userName: String, val userAge: Int) {

    constructor() : this("leavesC", 0)

}

2、添加註解

可以通過向構造函數添加一個 @JvmOverloads 註解來解決,這種方式實際上也是通過提供一個無參構造函數來解決問題的。所以缺點就是需要每個構造參數都提供默認值,所以才能生成無參構造函數

data class UserBean @JvmOverloads constructor(
    val userName: String = "leavesC",
    val userAge: Int = 0
)

3、聲明爲字段

這種方式和前兩種類似,也是通過間接提供一個無參構造函數來實現的。將所有字段都聲明在類內部而非構造參數,此時聲明的字段也一樣具有默認值

class UserBean {

    var userName = "leavesC"

    var userAge = 0

    override fun toString(): String {
        return "UserBean(userName=$userName, userAge=$userAge)"
    }

}

4、改用 moshi

Gson 由於本身定位就是用於 Java 語言的,所以目前對於 Kotlin 的友好程度不高,導致默認值無法直接生效。我們可以改用另外一個 Json 序列化庫:moshi

moshi 是 square 提供的一個開源庫,對 Kotlin 的支持程度會比 Gson 高很多

導入依賴:

dependencies {
    implementation 'com.squareup.moshi:moshi-kotlin:1.11.0'
}

此時不需要做特殊操作,在反序列化的時候默認值就可以直接生效

data class UserBean(val userName: String = "leavesC", val userAge: Int)

fun main() {
    val json = """{"userAge":26}"""
    val moshi = Moshi.Builder()
        .addLast(KotlinJsonAdapterFactory())
        .build()
    val jsonAdapter: JsonAdapter<UserBean> = moshi.adapter(UserBean::class.java)
    val userBean = jsonAdapter.fromJson(json)
    println(userBean)
}

UserBean(userName=leavesC, userAge=26)

但如果 json 字符串中 userName 字段明確返回了 null 的話,此時也會由於類型校驗不通過導致直接拋出異常,而這嚴格來說也更加符合 Kotlin 風格

fun main() {
    val json = """{"userName":null,"userAge":26}"""
    val moshi = Moshi.Builder()
        .addLast(KotlinJsonAdapterFactory())
        .build()
    val jsonAdapter: JsonAdapter<UserBean> = moshi.adapter(UserBean::class.java)
    val userBean = jsonAdapter.fromJson(json)
    println(userBean)
}

Exception in thread "main" com.squareup.moshi.JsonDataException: Non-null value 'userName' was null at $.userName
    at com.squareup.moshi.internal.Util.unexpectedNull(Util.java:663)
    at com.squareup.moshi.kotlin.reflect.KotlinJsonAdapter.fromJson(KotlinJsonAdapter.kt:87)
    at com.squareup.moshi.internal.NullSafeJsonAdapter.fromJson(NullSafeJsonAdapter.java:41)
    at com.squareup.moshi.JsonAdapter.fromJson(JsonAdapter.java:51)
    at temp.TestKt.main(Test.kt:21)
    at temp.TestKt.main(Test.kt)

四、擴展知識

再來看以下例子,和 Gson 無直接關聯,但是在開發中也是蠻重要的一個知識點

json 爲空字符串,此時 Gson 可以成功反序列化,且得到的 userBean 爲 null

fun main() {
    val json = ""
    val userBean = Gson().fromJson(json, UserBean::class.java)
}

如果加上類型聲明:UserBean?,那也可以成功反序列化

fun main() {
    val json = ""
    val userBean: UserBean? = Gson().fromJson(json, UserBean::class.java)
}

如果加上的類型聲明是 UserBean 的話,那就比較好玩了,會直接拋出 NullPointerException

fun main() {
    val json = ""
    val userBean: UserBean = Gson().fromJson(json, UserBean::class.java)
}

Exception in thread "main" java.lang.NullPointerException: Gson().fromJson(json, UserBean::class.java) must not be null
    at temp.TestKt.main(Test.kt:22)
    at temp.TestKt.main(Test.kt)

以上三個例子會有不同區別的原因是什麼呢?

這就要牽扯到 Kotlin 的平臺類型了。Kotlin 的一大特色就可以和 Java 實現百分百互通,平臺類型是 Kotlin 對 Java 所作的一種平衡性設計。Kotlin 將對象的類型分爲了可空類型和不可空類型兩種,但 Java 平臺的一切對象類型均爲可空的,當在 Kotlin 中引用 Java 變量時,如果將所有變量均歸爲可空類型,最終將多出許多 null 檢查;如果均看成不可空類型,那麼就很容易就寫出忽略了NPE 風險的代碼。爲了平衡兩者,Kotlin 引入了平臺類型,即當在 Kotlin 中引用 Java 變量值時,既可以將之看成可空類型,也可以將之看成不可空類型,由開發者自己來決定是否進行 null 檢查

因此,當我們從 Kotlin 承接 Gson 這個 Java 類返回的變量時,既可以將其當做 UserBean 類型,也可以當做 UserBean? 類型。而如果我們直接顯式聲明爲 UserBean 類型,就說明我們確信返回的是非空類型,當返回的是 null 時就會觸發 Kotlin 的 null 檢查,導致直接拋出 NullPointerException

關於平臺類型的知識點摘抄自我的另一篇 Kotlin 教程文章:兩萬六千字帶你 Kotlin 入門

五、參考資料

一個人走得快,一羣人走得遠,寫了文章就只有自己看那得有多孤單,只希望對你有所幫助😂😂😂

查看更多文章請點擊關注:字節數組

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章