使用 KCP 打造更安全的 Gson 与更快的 Moshi

3,925 阅读12分钟

前言

众所周知,使用 Gson、Jackson 等框架反序列化 JSON 到 Kotlin 类时存在空安全问题和构造器默认值失效的问题,同时常用的 Gson, Moshi 等框架往往在功能上比较强大,全面,而在性能上却没有很明显的优势。本文将介绍如何使用 Kotlin 编译器插件打造更安全的 Gson 与更快的 Moshi。

空安全与默认值问题

如上图所示,我们在使用 Gson 将 Json 数据反序列化为 User 对象时,会遇到空值安全问题和默认构造参数失效的问题。下面让我们来探讨一下为何会出现这样的情况。

  • 当调用Gson.fromJson方法来反序列化对象时,首先会创建Adapter
  • 在创建Adapter的过程时需要获取类的构建函数,如果存在无参构建函数会直接返回,否则会通过 UnSafe 的方式创建
  • 当使用 UnSafe 方式创建时,就不会调用我们定义的主构造函数了,默认值自然也就失效了
  • Adapter创建后,接下来就是在Adapter中通过jsonReader读取输入
  • 在读取输入的过程中通过field.set(target, fieldValue)反射赋值,自然也就无法保证空安全了

性能问题

反序列化是 Android 开发中必备且非常高频的需求,因此一个高性能的反序列化框架就非常重要了,那么常用的 Gson 与 Moshi 等框架性能到底怎么样呢?

下面列出使用 Jetpack Microbenchmark 库测试常用反序列化框架的结果,具体测试过程可见:常用 JSON 库性能对比

多次运行测试结果

从柱状图可以很明显的看出各个框架的速度对比

  • Kotlin Serialization 看起来是最适合 Kotlin 的反序列化框架,在各个框架中表现最好
  • JSONReader 与 JSONObject 在小数据上表现也不错,在大数据上 JSONReader 与其它框架相差不大,而 JSONObject 由于要将 InputStream 转化成 String,表现较差
  • Moshi 与 Gson 在反序列化速度上差距不大,基本上是一个量级,但相比 JSONReader 等框架则明显较慢

一次运行测试结果

可以看出,一次运行测试结果与多次运行测试结果明显不同

  • JSONReader 与 JSONObject 相比其它框架优势明显,在冷启动场景使用这些系统原生 API 应该会有一定优势
  • 在冷启动场景,Moshi 与 Kotlin Serialization 速度差不多,相比 Gson 则略慢

根据上述结果,可以看出 JSONReader 这些原生 API 在冷启动场景有极大优势,在经过充分优化后相比 Gson, Moshi 等库仍然有一定优势。因此,如果要实现更高性能的反序列化,使用 JSONReader 代替 Gson 与 Moshi,应该是个不错的选择。

但是 JSONReader 这些原生 API 使用起来较为麻烦,需要写很多模板代码,我们该如何优化呢?答案就是 Kudos。

Kudos 是什么

Kudos 是 Kotlin utilities for deserializing objects 的缩写。它可以解决使用 Gson、Jackson 等框架反序列化 JSON 到 Kotlin 类时所存在的空安全问题和构造器默认值失效的问题,同时可以简化高性能的反序列化框架 JsonReader 的使用方式。

Kudos 已经在 Github 上开源,开源地址可见:github.com/kanyun-inc/…

Kudos 使用

引入 Kudos 主要分为以下几步

1. 添加插件到 classpath

// 方式 1
// 传统方式,在根目录的 build.gradle.kts 中添加以下代码
buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath("com.kanyun.kudos:kudos-gradle-plugin:$latest_version")
    }
}

// 方式 2
// 引用插件新方式,在 settings.gradle.kts 中添加以下代码
pluginManagement {
    repositories {
        mavenCentral()
    }
    plugins {
        id("com.kanyun.kudos") version "$latest_version" apply false
    }
}

在项目中启用插件

plugins {
    // 启用 Kudos 插件. 
    // 为被 @Kudos 注解标注的类生成优化版本的无参构造器
    id("com.kanyun.kudos")
}

kudos {
    // 启用 Kudos.Gson. 为被 @Kudos 标注的类同时生成 @JsonAdapter 注解,并添加 kudos-gson 依赖.
    gson = true
    // 启用 Kudos.Jackson. 添加 kudos-jackson 依赖.
    jackson = true
    // 启用 Kudos.AndroidJsonReader. 添加 kudos-android-json-reader 依赖.
    androidJsonReader = true
}

我们可以在模块级别配置 Kudos 的功能开关

  • 当设置gson = true时就会启用 Kudos.Gson,实现更安全的 Gson
  • 当设置jackson = true时就会启用 Kudos.jackson,实现更安全的 Jackson
  • 当设置androidJsonReader = true时就会启用 Kudos.AndroidJsonReader,简化 JsonReader 的使用

为特定类启动 Kudos 的支持

@Kudos
data class User(
    val id: Long, 
    val name: String,
    val age: Int = -1,
    val tel: String = ""
)

@Kudos(KUDOS_GSON)
data class User(
    val id: Long, 
    val name: String,
    val age: Int = -1,
    val tel: String = ""
)
  • 对于需要添加 Kudos 解析支持的类型,直接添加@Kudos注解即可
  • @Kudos注解的类的功能默认与模块配置一致,也可以添加参数例如@Kudos(KUDOS_GSON)实现针对该类只开启特定功能

原理解析:前置知识

编译器插件是什么?

Kotlin 的编译过程,简单来说就是将 Kotlin 源代码编译成目标产物的过程,具体步骤如下图所示:

Kotlin 编译器插件,通过利用编译过程中提供的各种 Hook 时机,让我们可以在编译过程中插入自己的逻辑,以达到修改编译产物的目的

Kotlin 编译器插件可以分为 Gradle 插件,编译器插件,IDE 插件三部分,如下图所示

K2 编译器与 K1 编译器

编译器前端

Kotlin 编译器可以分为编译器前端与编译器后端两部分,目前 Kotlin 编译器前端有两个版本,一个是老版本的 PSI 版本,一个是即将在 Kotlin 2.0 版本稳定的 FIR 版本。

PSI 即 Program Structure Interface,PSI 原本是 Intellij 平台对各类编程语言语法树的统一抽象,为了快速实现需求,Kotlin 团队在开发初期复用了 Intellij 平台已经有的技术积累。而从 Kotlin 1.6 版本开始,为了实现更好的编译器性能,Kotlin团队开始开发一款全新的前端编译器,即 K2 编译器的 FIR 前端。

因此当我们开发编译器插件时,在涉及编译器前端的修改时需要同时适配 K1 与 K2 两个版本。

编译器后端

同时 Kotlin 作为一个多平台语言,可以将源代码编译成多个平台的目标代码,比如 Kotlin/Jvm 可以生成 Java 字节码,Kotlin/Js 可以生成 Javascript,Kotlin/Native 可以生成 .so 文件。

不同的编译器后端之间必然有不少逻辑可以共享,因此为了在不同的后端之间共享逻辑,降低支持新的语言特性的成本,同时方便后续扩展支持新的平台,Kotlin 编译器后端引入了 IR 这一中间层。

不过由于 Kotlin/Jvm 已经在 Kotlin 1.5 版本支持了 IR,Kotlin/Js 在 Kotlin 1.6 版本支持了 IR,而 Kotlin/Native 在一开始就支持 IR,因此我们在开发编译器插件时,涉及修改 IR 时不需要做什么额外的适配。

编译器扩展点

前面提到 Kotlin 编译器插件,通过利用编译过程中提供的各种扩展点,让我们可以在编译过程中插入自己的逻辑,以达到修改编译产物的目的,下面就介绍一下 Kudos 中使用到的扩展点以及对应的 API

扩展的类名编译阶段功能说明用例
SyntheticResolveExtension前端解析生成的类,函数等,可用于给类添加接口或者方法Parcelize
FirExtensionRegistrar前端FIR 扩展,可用于提供代码声明信息与代码检查Parcelize
StorageComponentContainerContributor前端可用于编译期代码检查Compose
IrGenerationExtension多平台 IR 后端生成与修改 IRParcelize、Atomicfu、NoArg

总得来说,在涉及到修改类或者函数的声明,例如函数的签名信息,给类添加接口等内容时,需要使用到编译器前端的扩展点;而当涉及到方法体的内部实现,类的私有属性等内容时,直接生成或修改 IR 即可。

Kudos 原理解析

项目结构

我们知道,Kudos 是一个 Kotlin 编译器插件,当然也可以分为 Gradle 插件,编译器插件,IDE 插件三部分,为了支持 Jackson,Kudos 也提供了 Maven 集成能力。

同时 Kudos 添加了对 Gson, Jackson, JsonReader 等框架的增强,针对这些框架的定制化代码封装在特定的 runtime 模块中,只有开启了对应的功能开关才会引入。

Kudos 如何保证空安全与默认值?

前面介绍了 Kotlin 编译器插件相关的前置知识,那么 Kudos 到底是如何保证空安全与默认值的呢?

@Kudos
data class User(
    val id: Long, 
    val name: String,
    val age: Int = -1,
    val tel: String = ""
)

上面这段代码,在编译过后大致相当于

@Kudos
// 如果启用了 com.kanyun.kudos.gson 插件,则生成 @JsonAdapter 注解
@JsonAdapter(value = KudosReflectiveTypeAdapterFactory::class)
data class User(
    val id: Long, 
    val name: String,
    val age: Int = -1,
    val tel: String = ""
) : KudosValidator {
    constructor() { // 生成的默认无参构造器
        super() // 调用父类默认无参构造器
        init<User>() // 调用 User 类内部的 init 块(包括定义在内部的属性初始化)
        this.age = -1 // 使用主构造器的参数默认值初始化属性
        this.tel = "" // 使用主构造器的参数默认值初始化属性
    }
    
    // 生成的用于校验字段空安全的函数
    override fun validate(status: Map<String, Boolean>) {
        validateField("id", status)
        validateField("name", status)
    }
}
  • Kudos 插件会生成默认无参构造器,在其中使用主构造器的参数默认值初始化属性,因此可以避免默认值失效。
  • 当启用了 Kudos.gson 时,会给类添加@JsonAdapter注解,在自定义的KudosReflectiveTypeAdapterFactory中自定义反序列化逻辑
  • Kudos 插件同样会给类添加KudosValidator接口与validate方法,在validate方法体中会给没有默认值的非空属性添加validateField调用
  • KudosReflectiveTypeAdapterFactory中在反序列化完成后会调用KudosValidatorvalidate方法,以验证声明非空的属性是否不为空,否则会抛出异常

Kudos 如何简化 JsonReader 的使用

JsonReader 使用起来麻烦的原因就在于要写很多模板代码,我们通过编译器插件来生成这些模板代码就可以解决 JsonReader 使用麻烦的问题。同时当我们开启 Kudos.AndroidJsonReader, 之前的保证默认值生效与空安全的代码也同样能生效。编译过后的代码如下所示:

@Kudos
data class User(
    val id: Long, 
    val name: String,
    val age: Int = -1,
    val tel: String = ""
) : KudosJsonAdapter {
    private var kudosFieldStatusMap: Map<String, Boolean> = hashMapOf()

    override fun fromJson(jsonReader: JsonReader): User {
        jsonReader.beginObject()
        while (jsonReader.hasNext()) {
            val tmp0 = jsonReader.nextName()
            if (jsonReader.peek() == JsonToken.NULL) {
                jsonReader.skipValue()
                continue
            }
            when {
                tmp0 == "id" -> {
                    <this>.id = jsonReader.nextLong()
                    <this>.kudosFieldStatusMap.put("id", <this>.id != null)
                }
                tmp0 == "name" -> {
                    <this>.name = jsonReader.nextString()
                    <this>.kudosFieldStatusMap.put("name", <this>.name != null)
                }
                // ...
                else -> {
                    jsonReader.skipValue()
                }
            }
        }
        jsonReader.endObject()
        validate(<this>.kudosFieldStatusMap)
        return this
    }
}

kudos-compiler 的实现

前面我们介绍了 kudos-compiler 模块要实现的代码,那么作为提供核心能力的模块,kudos-compiler 具体又是如何实现的呢?

  • KudosCommandLineProcessor 作为编译件插件的入口,注册 pluginId,同时接收命令行或者 gradle 插件传过来的参数
  • KudosCompilerPluginRegistrar 的作用则是用于注册我们所需的各种扩展点
  • KudosSyntheticResolveExtension 的作用是在符号引用解析时提供声明信息在符号引用解析时提供声明信息,比如给类添加KudosValidator接口与validate方法
  • KudosFirExtensionRegistrar 的作用是在 K2 解析时提供声明信息与代码检查,与 KudosSyntheticResolveExtension 类似,区别在于两者适用的编译器前端版本不同
  • KudosComponentContainerContributor 的作用是提供代码编译期检查
  • KudosIrGenerationExtension 的作用是在编译产物中提供实现,比如validatefromJson的方法体

性能数据

基于 Kudos 的工作机制不难想到,Kudos 的运行耗时会略微多于对应的 JSON 序列化框架。

使用 Jetpack Microbenchmark 库对 Kudos 与其对应的 JSON 序列化框架进行性能对比可以发现,Kudos.Gson 的耗时为 Gson 的 1.1-1.2 倍, Kudos.Jackson, KudosAndroidJsonReader 的情况类似,在可接受的范围之内。

多次运行测试结果

small jsonmedium jsonlarge json
Gson412,375 ns1,374,838 ns3,641,904 ns
Kudos-Gson517,123 ns1,686,568 ns4,311,910 ns
Jackson1,035,010 ns1,750,709 ns3,450,974 ns
Kudos-Jackson1,261,026 ns2,030,874 ns3,939,600 ns
JsonReader190,302 ns1,176,479 ns3,464,174 ns
Kudos-JsonReader215,974 ns1,359,587 ns4,019,024 ns

一次运行测试结果

small jsonmedium jsonlarge json
Gson3,974,219 ns4,666,927 ns8,271,355 ns
Kudos-Gson4,531,718 ns6,244,479 ns11,160,782 ns
Jackson12,821,094 ns13,930,625 ns15,989,791 ns
Kudos-Jackson13,233,750 ns15,674,010 ns18,641,302 ns
JsonReader662,032 ns2,056,666 ns4,624,687 ns
Kudos-JsonReader734,907 ns2,362,010 ns6,212,917 ns

如何学习 Kotlin 编译器插件

Kotlin 编译器插件目前还没有稳定,没有稳定的 API 与文档,那么我们该如何学习 Kotlin 编译器插件呢?

  • 通过 Kotlin 源码学习:由于没有文档,Kotlin 源码实际上就是最新的文档,如果想要学习 KCP,可以先从 Kotlin 官方开发的插件,例如 Parcelize, NoArg 等开始
  • 通过 AI 学习:当我们在开发编译器插件时,往往是知道想要生成的代码的样式,却由于没有 API 不知道如何实现,这种场景正是 AI 的用武之地了,我们可以提供给 AI 想要生成的代码示例,返回编译器插件的实现。
  • 学习《深入实践 Kotlin 元编程》:这本书是目前少有的针对 Kotlin 编译器插件做了系统介绍的学习资料,除此之外,还介绍了反射,代码生成,程序静态分析,符号处理器等 Kotlin 元编程技术,想要深入学习 Kotlin 元编程的同学都可以了解下。

总结

本文主要介绍了如何使用 Kudos ,以及 Kudos 到底是如何实现的,如果有任何问题,欢迎提出 Issue,如果对你有所帮助,欢迎点赞收藏 Star ~

开源地址

github.com/kanyun-inc/…