前言

在最近的项目中,我同时用到了 Moshikotlinx-serialization 这两个 JSON 库,两者的 API 都很简洁且实用。

Gson 的反射机制不同,Moshikotlinx-serialization 都提供了预编译机制,可以在编译期间分别生成 Adapter 和 Serializer,从而能够以类型安全、更高效的方式完成 JSON 的序列化和反序列化。

  • Moshi
    • 源于 Square ,与 Retrofit 的集成度较高,对 Android 平台的开发者比较友好
    • 可以借助 kapt/ksp 在编译期生成 XxxJsonAdapter.kt 文件
  • kotlinx-serialization
    • 源于 JetBrains,属于官方推出的扩展包,能够很方便的集成到 Ktor
    • 基于 kotlin compiler plugin,在编译期生成字节码文件(Xxx$$serializer.class)
    • 支持 KMP,能够跨平台使用。
      • 例如:定义一套 DTO,同时在 Android端、iOS端、前端、桌面端、服务端复用。
    • 官方支持 JSON、Protobuf、CBOR、Hocon、Properties 等格式
    • 有大量的三方扩展,支持 TOML、XML、YAML、BSON、NBT、SharePreference、Bundle 等格式

如果你在使用 kotlin 进行日常开发工作,非常推荐你去体验和使用这两个 JSON 库。

书归正传,在今天这篇文章中,主要想和大家聊一聊多态对象的序列化问题。

代码中的多态对象

在 Java 中,我们定义的任一接口都可以有多个不同的实现类。每个实现类可以声明自己特有的字段和方法。 Kotlin 中的 sealed class 的语法糖则进一步简化了这种代码组织行为。

开始之前,请确保 gradle.build 中含有以下依赖

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
plugins {
		// ...
    id 'org.jetbrains.kotlin.plugin.serialization' version '1.6.10'
    id "com.google.devtools.ksp" version "1.6.10-1.0.4"
}

dependencies {
		// ...
    ksp "com.squareup.moshi:moshi-kotlin-codegen:1.13.0"
    implementation "com.squareup.moshi:moshi:1.13.0"
    implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2"
    testImplementation 'junit:junit:4.13.2'
    // ...
}

假设有一个名为 Game 接口和一个含有 games 列表的 GamingRoom 类,以及 ZeldaEldenRing 两个实现类,如下所示。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package com.devwu.dto
interface Game
-----
package com.devwu.dto
@Serializable			           // kotlinx-serialization: 自动生成 GamingRoom$$serializer.class
@JsonClass(generateAdapter = true) // Moshi: 自动生成 GamingRoomJsonAdapter.kt
data class GamingRoom(
  val games: List<Game>
)
-----
package com.devwu.dto
@Serializable					   // kotlinx-serialization: 自动生成 Zelda$$seriliazer.class
@JsonClass(generateAdapter = true) // Moshi: 自动生成 ZeldaJsonAdapter.kt
data class Zelda(
  val title: String,
  val platform: String,
  val releaseAt: Long
) : Game
-----
package com.devwu.dto
@Serializable					  // kotlinx-serialization: 自动生成 EldenRing$$seriliazer.class
@JsonClass(generateAdapter = true) // Moshi: 自动生成 EldenRingJsonAdapter.kt
data class EldenRing(
  val title: String,
  val platforms: List<String>,
  val releaseAt: String
) : Game

ZeldaEldenRing 在以下字段上有略微区别,

  • platform/platforms: 在 Zelda 中是 String 类型,在 EldenRing 中是 List<String>类型,且有 s 后缀。
  • releaseAt: 在 Zelda 中是 Long 类型,在 EldenRing 中是 String 类型

GamingRoomgames 列表字段使用 Game 类型,可以同时接收 ZeldaEldenRing的实例对象作为列表的成员,如下所示。

1
2
3
4
5
6
val room = GamingRoom(
  games = listof(
    EldenRing(title = "艾尔登法环", platforms = listof("PlayStation", "Xbox", "PC"), releaseAt = "2022-02-25"),
    Zelda(title = "塞尔达传说:旷野之息", platform = "Nintendo Switch", releaseAt = 1488470400)
  )
)

多态对象的 JSON 序列化

针对上述 room 对象,将其进行 JSON 序列化之后,理论上应该生成如下的字符串。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
{
    "games":
    [
        {
            "title": "艾尔登法环",
            "platforms": ["PlayStation", "Xbox", "PC"],
            "releaseAt": "2022-02-25"
        },
        {
            "title": "塞尔达传说:旷野之息",
            "platform": "Nintendo Switch",
            "releaseAt": 1488470400
        }
    ]
}

然而 Moshi 与 kotlinx-serialization 中默认的 adapter/serializer 并不清楚代码中的多态关系,直接使用时会出现如下异常。

  • Moshi
1
2
3
4
5
No JsonAdapter for interface com.devwu.dto.Game (with no annotations)
for interface com.devwu.dto.Game
for java.util.List<com.devwu.dto.Game> games
for class com.devwu.dto.GamingRoom
java.lang.IllegalArgumentException: No JsonAdapter for interface com.devwu.dto.Game (with no annotations)
  • kotlinx-serialization
1
2
3
4
Class 'EldenRing' is not registered for polymorphic serialization in the scope of 'Game'.
Mark the base class as 'sealed' or register the serializer explicitly.
kotlinx.serialization.SerializationException: Class 'EldenRing' is not registered for polymorphic serialization in the scope of 'Game'.
Mark the base class as 'sealed' or register the serializer explicitly.

针对上述异常,我们需要在代码中为 Moshi 或 kotlinx-serialization 声明代码的多态关系。

Moshi 中多态对象的序列化

尽管 Moshi 的官方文档中对多态对象没有太多介绍,但其 moshi-adapter 库中提供了一个 PolymorphicJsonAdapterFactory, 可以很方便的为多态对象生成 JsonAdapter 工厂。在使用时,需要在 DSL 中声明如下依赖。

1
implementation "com.squareup.moshi:moshi-adapters:1.13.0"

然后新建一个 moshi 对象

1
2
3
4
5
6
7
8
val moshi = Moshi.Builder()
        // 添加多态 JsonAdapter 工厂
      .add(
        PolymorphicJsonAdapterFactory.of(Game::class.java,"type")// 基础类型:Game::class.java, 标签Key:type
          .withSubtype(Zelda::class.java,"zelda")          // 子类型:Zelda::class.java,标签Value:zelda
          .withSubtype(EldenRing::class.java,"elden-ring") // 子类型:EldenRing::class.java,标签Value: elden-ring
      )
      .build()

此时 moshi 便知晓了 Game, Zelda, EldenRing 之间的关系,Moshi 会在 JSON 的序列化过程中,为子类型添加 type 字段以标识当前JSON 对象的类型 ,我们可以使用以下代码进行验证

序列化
1
2
3
4
5
6
7
8
9
val room = GamingRoom(
  games = listof(
    EldenRing(title = "艾尔登法环", platforms = listof("PlayStation", "Xbox", "PC"), releaseAt = "2022-02-25"),
    Zelda(title = "塞尔达传说:旷野之息", platform = "Nintendo Switch", releaseAt = 1488470400)
  )
)
val adapter = moshi.adapter(GamingRoom::class.java)
val jsonStr = adapter.toJson(room)
println(jsonStr)

image-20220314102852735

可以看到 Moshi 为 room 对象生成了预期的 JSON 字符串,图中红色标记处的 type 字段为额外生成的类型标识符,这个标记符与我们构造 moshi 时添加的 PolymorphicJsonAdapterFactory相关,你可以为其制定任意键和值。但务必注意,每个子类型的值应该是唯一的,不可重复。

1
2
3
4
5
6
7
8
val moshi = Moshi.Builder()
        // 添加多态 JsonAdapter 工厂
      .add(
        PolymorphicJsonAdapterFactory.of(Game::class.java,"CUSTOME_KEY")// 基础类型:Game::class.java, 标签Key:
          .withSubtype(Zelda::class.java,"CUSTOME_VALUE1")          // 子类型:Zelda::class.java,标签Value:zelda
          .withSubtype(EldenRing::class.java,"CUSTOME_VALUE2") // 子类型:EldenRing::class.java,标签Value: elden-ring
      )
      .build()
反序列化

使用上一步生存的 jsonStr 进行反序列化,代码如下。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
val adapter = moshi.adapter(GamingRoom::class.java)
val jsonStr = """{"games":[{"type":"elden-ring","title":"艾尔登法环","platforms":["PlayStation","Xbox","PC"],"releaseAt":"2022-02-25"},{"type":"zelda","title":"塞尔达传说:旷野之息","platform":"Nintendo Switch","releaseAt":1488470400}]}"""
val dto = adapter.fromJson(jsonStr)!!
dto.games.forEach{
	when(it){
    is Zelda -> { assert(it.platform == "Nintendo Switch")}
    is EldenRing -> { assert(it.platforms.contains("Xbox"))}
    else -> {}
  }
}

kotlinx-serialization 中多态对象的序列化

kotlinx-serialization 的官方文档相对更加友好,其对多态问题有单独的文档进行描述,通过查阅该文档,我们可以通过以下代码来声明多态关系。

声明多态对象之间的关系
1
2
3
4
5
6
7
8
val json = Json {
      serializersModule = SerializersModule {
        polymorphic(Game::class) {   // 声明基础类型
          subclass(Zelda::class)     // 声明子类型
          subclass(EldenRing::class) // 声明子类型
        }
      }
    }
序列化
1
2
3
4
5
6
7
8
val room = GamingRoom(
  games = listof(
    EldenRing(title = "艾尔登法环", platforms = listof("PlayStation", "Xbox", "PC"), releaseAt = "2022-02-25"),
    Zelda(title = "塞尔达传说:旷野之息", platform = "Nintendo Switch", releaseAt = 1488470400)
  )
)
val jsonStr = json.encodeToJson(room)
println(jsonStr)

image-20220313174525488

kotlinx-serialization 在进行 JSON 序列化时,额外添加了 type 字段。用来标识当前 json 对象的实际类型。type字段的默认值是该对象的全限定类名。

我们可以通过 @SerialName 注解为每个类指定特定的 type 值, 如下所示。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
+@SerialName("zelda")              // kotlinx-serialization: 设置类型键名
@Serializable											 // kotlinx-serialization: 自动生成 Zelda$$seriliazer.class
@JsonClass(generateAdapter = true) // Moshi: 自动生成 ZeldaJsonAdapter.kt
data class Zelda(
  val title: String,
  val platform: String,
  val releaseAt: Long
) : Game
========
+@SerialName("elden-ring")         // kotlinx-serialization: 设置类型键名
@Serializable											 // kotlinx-serialization: 自动生成 EldenRing$$seriliazer.class
@JsonClass(generateAdapter = true) // Moshi: 自动生成 EldenRingJsonAdapter.kt
data class EldenRing(
  val title: String,
  val platforms: List<String>,
  val releaseAt: String
) : Game

需要注意的是,每个类型的type值应该是唯一的,不可重复。再次进行序列化进行校验,代码如下。

1
2
3
4
5
6
7
8
val room = GamingRoom(
  games = listof(
    EldenRing(title = "艾尔登法环", platforms = listof("PlayStation", "Xbox", "PC"), releaseAt = "2022-02-25"),
    Zelda(title = "塞尔达传说:旷野之息", platform = "Nintendo Switch", releaseAt = 1488470400)
  )
)
val jsonStr = json.encodeToJson(room)
println(jsonStr)

image-20220313175354450

可见 type 字段的值已经从全限定类名变成了自定义的名称。

反序列化

使用上一步生成的 jsonStr 进行反序列化,代码如下。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
val jsonStr = """{"games":[{"type":"elden-ring","title":"艾尔登法环","platforms":["PlayStation","Xbox","PC"],"releaseAt":"2022-02-25"},{"type":"zelda","title":"塞尔达传说:旷野之息","platform":"Nintendo Switch","releaseAt":1488470400}]}"""
val gamingRoom = json.decodeFromString<GamingRoom>(jsonStr).games.forEach {
  when (it) {
    is Zelda -> {
      assert(it.platform == "Nintendo Switch")
    }
    is EldenRing -> {
      assert(it.platforms.contains("Xbox"))
    }
    else -> {}
  }
}
利用 sealed class 自动推导多态关系

如果你的项目中使用了闭合的sealed class ,那么 kotlinx-serialization 可以自动推导出多态类型之间的关系

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Serializable
sealed class Game2 {
  @Serializable
  @SerialName("zelda")
  data class Zelda2(
    val title: String,
    val platform: String,
    val releaseAt: Long
  ) : Game2()

  @Serializable
  @SerialName("elden-ring")
  data class EldenRing2(
    val title: String,
    val platforms: List<String>,
    val releaseAt: String
  ) : Game2()
}
-----
@Serializable
data class GamingRoom2(
  val games: List<Game2>
)

此时便可以将之前显示声明的多态关系代码移除。

1
2
3
4
5
6
7
8
- val json = Json {
-      serializersModule = SerializersModule {
-        polymorphic(Game::class) {
-          subclass(Zelda::class)
-          subclass(EldenRing::class)
-        }
-      }
-   }

然后使用 kotlin-serialization 提供的默认Json 对象进行测试

序列化

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
val game1 = Game2.EldenRing2(
      title = "艾尔登法环",
      platforms = listOf("PlayStation", "Xbox", "PC"),
      releaseAt = "2022-02-25"
    )
    val game2 = Game2.Zelda2(
      title = "塞尔达传说:旷野之息",
      platform = "Nintendo Switch",
      releaseAt = 1488470400
    )
    val gamingRoom = GamingRoom2(listOf(game1, game2))
    val jsonStr = Json.encodeToString(gamingRoom)
    assert(jsonStr == """{"games":[{"type":"elden-ring","title":"艾尔登法环","platforms":["PlayStation","Xbox","PC"],"releaseAt":"2022-02-25"},{"type":"zelda","title":"塞尔达传说:旷野之息","platform":"Nintendo Switch","releaseAt":1488470400}]}""")
    println(jsonStr)

反序列化

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
val jsonStr =
      """{"games":[{"type":"elden-ring","title":"艾尔登法环","platforms":["PlayStation","Xbox","PC"],"releaseAt":"2022-02-25"},{"type":"zelda","title":"塞尔达传说:旷野之息","platform":"Nintendo Switch","releaseAt":1488470400}]}"""
    val gamingRoom: GamingRoom2 = Json.decodeFromString(jsonStr)
    gamingRoom.games.forEach {
      when (it) {
        is Game2.Zelda2 -> {
          assert(it.platform == "Nintendo Switch")
        }
        is Game2.EldenRing2 -> {
          assert(it.platforms.contains("Xbox"))
        }
        else -> {}
      }
    }
    println(gamingRoom)

小结

由于此前的 JSON 库在多态问题上存在一定的短板,以至于在实际的开发中,JSON 的多态通常被有意规避,客户端开发者少有机会接触这样的数据。但随着三方库的不断完善,JSON 在处理多态问题上有了长足进步。希望能够通过这篇文章进行抛砖引玉,扩展大家对 JSON 多态的认识。

本文涉及的代码已上传至 Github,代码主要在 dto 模块的单元测试中,您可以点击这个链接进行查看。

后记

Java 服务端常用的 Jackson 在处理多态对象方面也很成熟,具体可以参考这个链接。如果你的业务中需要用到多态对象进行序列化,不妨把这个技巧推荐给你的后端小伙伴~