還在被數據類的序列化折磨?是時候丟棄 Gson 了

  • 2020 年 2 月 20 日
  • 筆記

今天我們來簡單介紹下 kotlinx.serialization 。

提示:點擊閱讀原文即可看到倉庫的鏈接。

認識一下

看名字就知道這是 Kotlin 官方的序列化框架了,它支持 JSON/CBOR/Protobuf,下面我們主要以 JSON 為例介紹它的功能(因為後面那倆不是給人看的啊)。

它作為一套專門為 Kotlin 的類開發的序列化框架,自然要兼顧到 Kotlin 的類型的各種特性,你會發現用 Gson 來序列化 Kotlin 類時遇到的奇怪的問題在這裡都沒了。

最重要的是,跟其他 Kotlinx 家族的成員一樣,它將來會以跨平台的身份活躍在 Kotlin 的所有應用場景,如果你想要構建可移植的程序,例如從 Android(Jvm)移植到 iOS(Native),用 Gson 那肯定是不行的了,但 kotlinx.serialization 就可以。儘管它現在在 Native 上的功能還有限制,不過,人家畢竟還是個寶寶嘛(0.6.1)。

開始用吧

閑話少說,咱們創建一個 Kotlin 的 Jvm 程序(畢竟它的功能最全,別的平台有的還不支持),創建好以後引入依賴,由於我用的是 Kotlin DSL 的 gradle,所以如果你用的仍然是 Groovy 的,請去參考 GitHub 倉庫的介紹。

plugins {      //注意 Kotlin 的版本要新,不要問舊版怎麼用,因為人家官方說了舊版不能用      kotlin("jvm") version "1.2.60"  }    buildscript {      repositories {          jcenter()          //這個庫因為還是個寶寶,所以還在自己的倉庫裏面,gradle 插件從這兒找          maven ("https://kotlin.bintray.com/kotlinx")      }      dependencies {          //序列化框架的重要部分:gradle 插件          classpath("org.jetbrains.kotlinx:kotlinx-gradle-serialization-plugin:0.6.1")      }  }    apply {      //咦,怎麼沒有 apply kotlin 呢?不知道為啥的看代碼的第一行      plugin("kotlinx-serialization")  }    dependencies {      compile(kotlin("stdlib", "1.2.60"))      //加載自定義的 Serializer 有些情況下需要反射      compile(kotlin("reflect", "1.2.60"))      //序列化框架的重要部分:運行時庫      compile("org.jetbrains.kotlinx:kotlinx-serialization-runtime:0.6.1")  }    repositories {      jcenter()      //運行時庫從這兒找      maven ("https://kotlin.bintray.com/kotlinx")  }

有了這些,你就可以寫這麼一段代碼運行一下了:

import kotlinx.serialization.*  import kotlinx.serialization.json.JSON    @Serializable  data class Data(val a: Int, @Optional val b: String = "42")    fun main(args: Array<String>) {      println(JSON.stringify(Data(42))) // {"a": 42, "b": "42"}      val obj = JSON.parse<Data>("""{"a":42}""") // Data(a=42, b="42")  }

很棒啊,官方的例子。不過你如果直接使用 IntelliJ 的運行按鈕,你就會發現一個編譯錯誤,看起來就是什麼版本不兼容啦之類的。別理它,這時候你只需要打開 Preference,找到 gradle->runner,把裏面的 Delegate IDE build/run actions to gradle 勾上

再運行,很好,你就會看到運行成功了:

來個嵌套的類型

像數值類型、字符串這樣的基本類型通常與 JSON 的類型都可以對應上,但如果是 JSON 中不存在的一個類型呢?

data class User(val name: String, val birthDate: Date)

然後:

println(JSON.stringify(User("bennyhuo", Calendar.getInstance().apply { set(2000, 3, 1, 10, 24,0) }.time)))

結果呢?

這日期我去,看了半天我才看懂,哪兒成啊。所以我要給 Date 自定義一個序列化的格式,怎麼辦?

我們需要定義一個 KSerializer 來實現自定義序列化:

@Serializer(forClass = Date::class)  object DateSerializer : KSerializer<Date> {      private val simpleDateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")        override fun load(input: KInput) = simpleDateFormat.parse(input.readStringValue())        override fun save(output: KOutput, obj: Date) {          output.writeStringValue(simpleDateFormat.format(obj))      }  }

然後在使用處註明要使用的 Serializer

@Serializable  data class User(val name: String,                  @Serializable(with = DateSerializer::class) val birthDate: Date)

這樣輸出的日期格式就是我指定的了:

日期當然是瞎寫的。。。

更複雜一點兒的情況

假設我們有需求要講一個 Date 序列化成一個數組,為了表達方便,我們先定義一個類:

@Serializable  class MyDate(var year: Int = 0, var month: Int = 0, var day: Int = 0,          var hour: Int = 0, var minute: Int = 0, var second: Int = 0){      ... //省略 toString()  }

我們希望下面的代碼的序列化的結果按照數組的形式輸出 MyDate 當中的參數:

MyDate(2000, 3, 1, 10, 24, 0)

這個對象序列化之後應該輸出: [2000,3,1,10,24,0]

我們要怎麼做呢?

@Serializer(forClass = MyDate::class)  object MyDateSerializer : KSerializer<MyDate> {      private val jClassOfMyDate = MyDate::class.java        override fun load(input: KInput): MyDate {          val myDate = MyDate()          val arrayInput = input.readBegin(ArrayClassDesc)          for (i in 0 until serialClassDesc.associatedFieldsCount) {              val index = arrayInput.readElement(ArrayClassDesc)              val value = arrayInput.readIntElementValue(ArrayClassDesc, index)              jClassOfMyDate.getDeclaredField(serialClassDesc.getElementName(i)).apply { isAccessible = true }.set(myDate, value)          }          arrayInput.readEnd(ArrayClassDesc)          return myDate      }        override fun save(output: KOutput, obj: MyDate) {          val arrayOutput = output.writeBegin(ArrayClassDesc, 0)          for (i in 0 until serialClassDesc.associatedFieldsCount) {              val value = jClassOfMyDate.getDeclaredField(serialClassDesc.getElementName(i)).apply { isAccessible = true }.get(obj) as Int              arrayOutput.writeIntElementValue(ArrayClassDesc, i + 1, value)          }          arrayOutput.writeEnd(ArrayClassDesc)      }  }

save 方法可以讓我們在序列化 MyDate 的對象時按數組的形式輸出,而 load 方法則用於反序列化。這段代碼看上去有些古怪,不過不要感到害怕,一般情況下我們不會需要這樣的代碼。

有了 MyDateSerializer 之後,我們需要註冊它才可以使用,即:

val json = JSON(context = SerialContext().apply { registerSerializer(MyDate::class, MyDateSerializer) })  val result = json.stringify(MyDate(2000, 3, 1, 10, 24, 0)) //result = "[2000,3,1,10,24,0]"

這似乎與前面的 Date 的情況不同。通常如果作為一個類的成員,我們可以通過註解 @Serializable(with=MyDateSerializer::class) 來指定序列化工具類,就像我們前面為 Date 指定序列化工具類一樣:

@Serializable  data class User(val name: String,                  @Serializable(with = DateSerializer::class) val birthDate: Date)

但如果我們針對類本身做序列化時,通過註解為一個類配置全局序列化工具則是徒勞的(也許是一個尚未實現的 feature,也許是一個 bug,也許是故意而為之呢),就像下面這種寫法,實際上是沒有意義的。

@Serializable(with = MyDateSerializer::class)  class MyDate(...){ ... }

當然你也可以通過自定義註解來為屬性增加額外的信息,但這個使用場景比較少,就不介紹了。

Gson 做不到的事兒

看到這裡 Gson 哥坐不住了,這事兒尼瑪我也會啊,不就解析個 Json 串嗎,有啥難的??

①構造方法默認值

這事兒還真不是說 Gson 的不是,Gson 作為 Java 生態中的重要一員,儘管它的速度不是最快的,但他的接口最好用啊,所以寫 Java 的時候每次測試 Maven 庫的時候我都會用引入 Gson 試試,嗯,它的 Maven id 是我認識 Kotlin 之前能背下來的唯一一個。

com.google.code.gson:gson:$version

那麼的問題是啥?問題就是,它不是為 Kotlin 專門定製的。大家都知道,如果你想要在你的項目中做出成績來,你必須要針對你的業務場景做優化,市面上所有的輪子都傾向於解決通用的問題,我們這些 GitHub 的搬運工的水平級別主要是看上輪子的時候誰的螺絲和潤滑油上的更好。

我們還是看官方的那個例子:

@Serializable  data class Data(val a: Int, @Optional val b: String = "42")
val obj = JSON.parse<Data>("""{"a":42}""") // Data(a=42, b="42")  val objGson = gson.fromJson("""{"a":42}""", Data::class.java) //Data(a=42, b="?")

不同的是,我們這回用 Gson 去反序列化同樣的字符串,結果呢?

為什麼會這樣?因為 Gson 在反序列化的時候,構造對象實例時沒有默認無參構造方法,同時又沒有設置 TypeAdapter 的話,它就不知道該怎麼實例化這個對象,於是用到了一個千年黑魔法 Unsafe 。儘管我們在 Data 的構造器裏面給出了默認值,但 Gson 聽了之後會說:啥玩意?啥默認值?

②屬性的初始化值

@Serializable  data class Data(val a: Int, @Optional val b: String = "42"){      @Optional      private val c: Long = 9        override fun toString(): String {          return "Data(a=$a, b='$b', c=$c)"      }  }

好的,我們現在給 Data 添加了一個成語,注意它不在構造方法中,所以後面的 9 不是默認值,而是構造的時候的初始化值。同時由於默認的 toString 方法只有構造器中的屬性,所以我們需要自己來一個,帶上 c

還是前面的程序,這次猜猜兩個框架是如何初始化 c 的值的?

val obj = JSON.parse<Data>("""{"a":42}""") // Data(a=42, b="42", c=?)  val objGson = gson.fromJson("""{"a":42}""", Data::class.java) //Data(a=42, b="null", c=?)

結果嘛,當然就是 Gson 沒有對 c 做任何初始化的操作。

你當然可以罵 Gson 「你瞎啊,那麼明顯的構造都不會執行?」,Gson 回復你的估計仍然是:

前面說過了,Gson 實例化的時候根本不會調用我們定義的構造器啊,這個初始化的值本身就是構造的一部分。

③屬性代理

如果你在數據類(不是 data class 但也被當數據結構用的類也算)裏面用到了屬性代理,就像這樣:

@Serializable  data class Data(val a: Int, @Optional val b: String = "42"){      @Optional      private val c: Long = 9        @Transient val d by lazy { b.length }        override fun toString(): String {          return "Data(a=$a, b='$b', c=$c, d=$d)"      }  }

我們定義了一個 d,它自己沒有 backing field,我們用屬性代理來讓它代理 b 的長度,這樣的用法本身也是經常見的。由於這個值本身自己只是一個代理,所以我們需要把它標記為 Transient,意思就是不參與序列化過程。

那麼這時候同樣,我們還是運行前面的那段代碼:

val obj = JSON.parse<Data>("""{"a":42}""") // Data(a=42, b="42", c=9, d=?)  val objGson = gson.fromJson("""{"a":42}""", Data::class.java) //Data(a=42, b=null, c=0, d=?)

其實猜結果的時候,我們能想到的差異就是,KS 能夠正常的執行 Data 的初始化流程,因此可以覆蓋到默認值、初始化值等等,而 Gson 不能,所以 Gson 一定不會處理 d。不過這次的結果可能就不是一個簡單的 null 了,而是:

用 Gson 解析之後,如果我們想要訪問 d,直接拋出空指針。這是為什麼呢?因為屬性代理會產生一個內部的代理屬性,反編譯之後我們就會看到是

private final Lazy d$delegate;

我們訪問 d 的時候實際上就是去訪問這個屬性的 getValue 方法,而這個屬性並沒有被正常初始化,所以就有了空指針的結果了。

小結

序列化 Kotlin 數據類型的時候,以後可以考慮使用 kotlinx.serialization 這個框架了,它不僅 API 簡單,還解決了我們經常遇到用別的 Java 框架帶來的問題。


對啦,我的 Kotlin 新課 「基於 GitHub App 業務深度講解 Kotlin1.2高級特性與框架設計」 上線之後,大家普遍反映有難度,有深度,如果哪位朋友想要吊打 Kotlin,不妨來看看哦!

https://coding.imooc.com/class/232.html