[Day 6] 使用 kotlinx.serialization 转换 JSON

在 Java 的世界中,有很多种 json library 任君挑选,其中最多人使用的应该是 JacksonGson。我过往都是使用 Jackson,因为这是 Spring Boot 及 Play Framework 的预设偏好。因为我几乎没使用过其它的 library,所以也无法做详尽的功能比较及推荐,不过我个人认为除非你需要处理数量或内容庞大的 json 资料,那麽可以参考 Github 上的 benchmark 结果做为挑选依据,要不然其实就用预设的就好,基本上你需要或想得到的功能都已经有实作了。

kotlinx.serialization

至於 Ktor ContentNegotiation Plugin 当然也有支援 Jackson 及 Gson,不过预设支援的还有自家 JetBrains 开发的 kotlinx.serialization,kotlinx.serialization 的特点在於

  • 支援跨平台 (JVM, JS, Native...)
  • 支援多种格式 (JSON, Protobuf, CBOR, Hocon...)
  • Compiler plugin 自动产生处理 serialize / deserialize 的程序码,而不是只能在执行期使用 reflection 的方式处理而已。

虽然我目前的需求不需要跨平台、又只需要转换 json 资料,但基於做 side project 就是要学习新技术的精神,而且未来跨平台开发会是一个趋势,同时也想知道 Complier Plugin 的方式与 Reflection 的效能比较,所以决定使用 kotlinx.serialization !

使用 kotlinx.serialization 在开发上最大的差别就是它会要求或限制你的程序写法,不像 Reflection 方式那样无脑使用。例如只有 Backing fields 才会被序列化,所以如果你使用 delegated property 或是 getter function 那就不行。还有类别一定要加上 @Serializable annotation 或是已内建支援的型态才能转换,所以如果要转换的物件类别,存在任一个变数的型态不能转换的话,那就无法「编译」程序码了。例如针对 java.util.UUID 要另外写 CustomSerializer 实作 serialize / deserialize 函式,然後在变数宣告加上 @Serializable(with = UUIDSerializer::class) 才能编译,否则 Compiler Plugin 根本不知道怎麽产生程序码。如果是 Jackson 的话,几乎任何物件都能够在执行期透过反射转换,除非产生的 json 不是你想要的,例如 UUID 会被序列化为 mostSigBits, leastSigBits 2个属性值的 JsonObject,而非一个字串,此时我们要自己写 CustomSerializer 再注册到 Jackson。

上面的例子只是众多限制之一,尤其处理继承多型的类别更是要小心,建议如果你是初次使用 kotlinx.serialization,一定要先把官方说明文件大略看过一遍,至少有个印象,否则你会遇到一堆 Compiler Plugin 无法编译的错误,或是发现怎麽有些属性没被 serialize,然後就想要放弃了…XD。另外还要关注一下 kotlinx.serialization 新版本,因为随着版本的进化,过去的限制可能会被解除或有条件松绑,此时升级一下也是不错的。

以上对 kotlinx.serialization 有所认识之後,接下来说明 Ktor 使用 kotlinx.serialization 处理 request json deserialization 及 response json serialization 的实作技巧。

request json deserialization

我在 ApplicationCall 类别加上 receiveAndValidateBody extension function,透过 kotlinx.serialization 的 serializer() 取得 reified type parameter T 类别的 serializer,藉此达到类似 reflection 的效果,这样就可以处理每一种 T 型态的类别。但要小心 T 类别必须是 Serializable,否则会丢出 SerializationException,而且目前使用 serializer() 必须标注 InternalSerializationApi

@OptIn(InternalSerializationApi::class)
suspend inline fun <reified T : Form<*>> ApplicationCall.receiveAndValidateBody(location: Location? = null): T {
    val form = try {
        json.decodeFromString(T::class.serializer(), receiveUTF8Text())
    } catch (e: Throwable) {
        throw RequestException(InfraResponseCode.BAD_REQUEST_BODY, "can't deserialize from json: ${e.message}", e)
    }
    //... 以下省略
}

response json serialization

我在 ApplicationCall 类别加上 respond(responseDTO: ResponseDTO) extension function。ResponseDTO 设计上考虑多种回应格式,最简单的格式 DataResponseDTO 就直接继承 ResponseDTO,然後加上 @SerialName("data") 作为 classDiscriminator property 的值。

为了简化建立 DataResponseDTO 物件的写法,在这里使用 invoke operator overloading 达到直接呼叫 constructor 一样的写法 call.respond(DataResponseDTO(loginResponse)) 这样子就不必另外再定义 factory method,而且还可以拿到 refied type parameter T,然後再取得 T 的 serializer 了

suspend fun ApplicationCall.respond(responseDTO: ResponseDTO) {
    respond(responseDTO.code.httpStatusCode, responseDTO)
}

@Serializable
sealed class ResponseDTO {

    abstract val code: ResponseCode
    abstract val message: String?
    abstract val data: JsonElement?
}

@Serializable
@SerialName("data")
class DataResponseDTO(
    override val code: ResponseCode = InfraResponseCode.OK,
    override val message: String? = null,
    override val data: JsonElement
) : ResponseDTO() {

    companion object {

        inline operator fun <reified T : Any> invoke(data: T, message: String? = null): DataResponseDTO {
            return DataResponseDTO(data = json.encodeToJsonElement(T::class.serializer(), data), message = message)
        }

        inline operator fun <reified T : Any> invoke(data: List<T>, message: String? = null): DataResponseDTO {
            return DataResponseDTO(data = JsonArray(data.map { json.encodeToJsonElement(T::class.serializer(), it) }), message = message)
        }
    }
}

更多 kotlinx.serialization 的实作程序码可参考Github repo,包含了许多常见资料型态的 CustomSerializer,例如 BigDecimal 及 java.time.* 的 ZonedDateTime…等。还有2个 json object 的 deepMerge 操作。


<<:  [Day8] Android - Kotlin笔记:JetPack - Core KTX

>>:  登录档是什麽~资工的讲古时间

Day02 - 纯 Html - 简单型别 + 字串

简单型别 + 字串 简单型别定义 Case01 - 简单型别 Controller 预期 post ...

【Day13】 AutoVC 实作 Pytorch 篇 - 2

衔接昨日 Part 5 - AutoVC 这部分我们暂时先参考官网 model_vc.py 即可 P...

Day8# Array & Slice(下)

昨天没有写完的 Array & Slice(上) ,今天要来把补完进度。 那我们就开始吧 ─...

远端系列-5:如何拉回远端数据库的档案?

角色情境 小明同时学会输入指令操作着终端机、 以及透过滑鼠操作着图像化介面的 Sourcetree ...

D-08-排程设定 ? hangfire

如何处理定期的需求 相信很多人会遇到需要定期做某些事情的状况,例如每分钟去计算一次资料,或者一分钟跟...