Moshi的JsonQualifier实战
背景
笔者在开发过程中使用 retrofit
+ okhttp
+ moshi
这套方案与服务器进行通信交互,使用 JSON 作为通信的数据结构。
本文主要记录笔者在使用 moshi
+ kotlin-codegen
解析 JSON 时的案例和遇到的问题。
笔者使用的版本:
moshi:1.15.2
案例
通常由服务端同学定义 HTTP 接口的响应数据结构,比如有下面一段 JSON 数据:
{
"name": "guodongAndroid",
"enable": 1
}
enable
字段表示是否启用,但是服务端响应的是 Number
类型的一个值,而在开发时,我们可以定义如下的实体类:
@JsonClass(generateAdapter = true)
data class Domain(
val name: String,
val enable: Boolean,
)
使用下面的方式进行解析:
private val moshi = Moshi.Builder().build()
val json = """
{
"name": "guodongAndroid",
"enable": 1
}
""".trimIndent()
val domain = jsonAdapter.fromJson(json)
println(domain)
不好意思,上面的解析代码会报错:
Exception in thread "main" com.squareup.moshi.JsonDataException: Expected a boolean but was NUMBER at path $.enable
意思是不能将 NUMBER
类型转换为 boolean
类型,即 moshi
内置不支持这种转换。
moshi
内置支持String
和Number
类型的互相转换。
实战
为解决上述问题,笔者查看了 moshi
的使用文档,发现了 JsonQualifier
注解,简单了解它的使用方式后就开始了愉快的编码过程。
自定义注解
首先需要自定义一个注解:
@Retention(AnnotationRetention.RUNTIME)
@JsonQualifier
annotation class IntBoolean
这个注解必现标记 JsonQualifier
注解,还必现是运行时注解。
应用自定义注解
然后修改上面的实体类,为 enable
字段标记 IntBoolean
注解:
@JsonClass(generateAdapter = true)
data class Domain(
val name: String,
- val enable: Boolean,
+ @field:IntBoolean val enable: Boolean,
)
自定义JsonAdapter
其次需要为 IntBoolean
注解自定义一个 JsonAdapter
类:
class IntBooleanAdapter {
@FromJson
@IntBoolean
fun fromJson(value: Int): Boolean {
return value > 0
}
@ToJson
fun toJson(@IntBoolean value: Boolean): Int {
return if (value) 1 else 0
}
}
在此类中:
fromJson
函数标记了FromJson
注解,告知moshi
此函数用于解析 JSON 数据,同时它标记了IntBoolean
注解,表示此函数仅处理同样标记了IntBoolean
注解的实体类字段toJson
函数标记了ToJson
注解,告知moshi
此函数用于生成 JSON 数据,同时函数内的参数标记了IntBoolean
注解,表示此函数仅处理同样标记了IntBoolean
注解的实体类字段
应用自定义JsonAdapter
最后在构造 moshi
时传入自定义的 JsonAdapter
即可:
- private val moshi = Moshi.Builder().build()
+ private val moshi = Moshi.Builder().add(IntBooleanAdapter()).build()
val json = """
{
"name": "guodongAndroid",
"enable": 1
}
""".trimIndent()
val domain = jsonAdapter.fromJson(json)
println(domain)
至此,我们已经了解了 JsonQualifier
的大致用法。但是,事情往往没有这么简单~~。
问题
目前笔者遇到的问题都是和服务端响应的数据有关。由此可见客户端任何时候都不应该相信服务端响应的数据,任何时候都要考虑服务端数据异常的情况,反之亦然。
JSON中缺少某个字段
比如在上面的例子中,服务端响应的 JSON 数据中缺少 enable
字段:
{
"name": "guodongAndroid"
}
此时仅需修改实体类,为对应的字段添加默认参数即可:
@JsonClass(generateAdapter = true)
data class Domain(
val name: String,
- @field:IntBoolean val enable: Boolean,
+ @field:IntBoolean val enable: Boolean = false,
)
其他的不需要改动。
null
JSON中字段为 比如在上面的例子中,服务端响应的 JSON 数据中 enable
字段的值为 null
:
{
"name": "guodongAndroid",
"enable": null
}
此时解析时会发生如下异常:
Exception in thread "main" com.squareup.moshi.JsonDataException: Non-null value 'enable' was null at $.enable
笔者知道这是服务端响应的问题,理应由服务端进行修改,但是我们不能保证服务端同学不会犯错(客户端怎么把控服务端的代码质量呢?),或者调用的 HTTP 接口由第三方提供时,客户端更加无能为力。
首先笔者的第一想法是将 Domain#enable
字段改为 可空类型,但是很遗憾,此时 moshi
会报错:
Caused by: java.lang.IllegalArgumentException: No JsonAdapter for class java.lang.Boolean annotated [@com.squareup.moshi.recipes.IntBoolean()]
for class java.lang.Boolean enable
for class com.squareup.moshi.recipes.Domain
报错信息指出对标记了 IntBoolean
的注解的 Domain#enable
字段找不到相应的 JsonAdapter
。
这是必然的,因为笔者自定义的 IntBooleanAdapter
不支持可空类型。
接下来笔者的想法是:既然不支持,当然是 ”打到“ 它支持啦:
class IntBooleanAdapter {
@FromJson
@IntBoolean
fun fromJson(value: Int? /* 改为可空类型 */): Boolean? /* 改为可空类型 */ {
// 增加可空判断
if (value == null) {
return false
}
return value > 0
}
@ToJson
fun toJson(@IntBoolean value: Boolean? /* 改为可空类型 */): Int? /* 改为可空类型 */ {
// 增加可空判断
if (value == null) {
return 0
}
return if (value) 1 else 0
}
}
哈哈,修改后 moshi
解析不报错了,但是解析出来的实体对象好像有点问题:
Domain(name=guodongAndroid, enable=null)
笔者期望此时 enable
字段的值应该为 false
,但是解析出来的是 null
。断点调试一波发现压根没走笔者自定义的 JsonAdapter
,那我不白忙活半天嘛。
没办法啦,只能去 moshi
的源码里找找”机缘“了。
笔者查看的 moshi 源码 git commit:6b7780e5a11ada27a4dd1c6623eebd7b98cca710
通过断点调试找到调用 IntBooleanAdapter#fromJson
的 moshi
源码 AdapterMethodsFactory$AdapterMethod#invokeMethod
,随着调用栈一路反向排查调试,终于发现一丝曙光,在源码中有个函数 fromAdapter
,其将自定义的 JsonAdapter
解析为 AdapterMethod
,其中有一行代码(#262行):
val nullable = parameterAnnotations[0].hasNullable
判断 fromJson
函数的第一个参数是否是可空类型,其中 hasNullable
是扩展函数:
/** Returns true if `annotations` has any annotation whose simple name is Nullable. */
internal val Array<Annotation>.hasNullable: Boolean
get() {
for (annotation in this) {
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN")
if ((annotation as java.lang.annotation.Annotation).annotationType().simpleName == "Nullable") {
return true
}
}
return false
}
现在只需再定义一个名为 Nullable
的运行时注解,将其标记在 IntBooleanAdapter#fromJson
函数的第一个参数上:
@Retention(AnnotationRetention.RUNTIME)
annotation class Nullable
class IntBooleanAdapter {
@FromJson
@IntBoolean
fun fromJson(@Nullable /* 标记Nullable注解 */ value: Int? /* 改为可空类型 */): Boolean {
// 增加可空判断
if (value == null) {
return false
}
return value > 0
}
@ToJson
fun toJson(@IntBoolean value: Boolean): Int {
return if (value) 1 else 0
}
}
最后,moshi
可以按笔者的预期正确的解析 JSON 数据了。
自定义JsonAdapter.Factory
通过上面的查看源码,笔者发现之前自定义的 IntBooleanAdapter
其实 moshi
在内部包装了一层 JsonAdapter.Factory
,最后在通过反射的方式调用自定义的解析函数,这样可能会有一定的性能开销,不过我们可以自定义 JsonAdapter.Factory
来实现上述的需求同时避免反射调用:
class IntBooleanAdapterFactory : JsonAdapter.Factory {
override fun create(
type: Type,
annotations: Set<Annotation>,
moshi: Moshi
): JsonAdapter<*>? {
if (type != Boolean::class.java) {
return null
}
val delegateAnnotations = Types.nextAnnotations(annotations, IntBoolean::class.java) ?: return null
val delegateAdapter = moshi.nextAdapter<Boolean>(this, type, delegateAnnotations)
return object : JsonAdapter<Boolean>() {
override fun fromJson(reader: JsonReader): Boolean {
return when (reader.peek()) {
JsonReader.Token.NULL -> {
reader.nextNull<Any>()
false
}
JsonReader.Token.NUMBER -> {
reader.nextInt() == 1
}
else -> delegateAdapter.fromJson(reader) ?: false
}
}
override fun toJson(writer: JsonWriter, value: Boolean?) {
val newValue = when (value) {
null -> 0
true -> 1
false -> 0
}
writer.value(newValue)
}
override fun toString(): String {
return "IntBooleanAdapter(Int -> Boolean)"
}
}
}
}
最后将其添加到 moshi
并移除 IntBooleanAdapter
:
- private val moshi = Moshi.Builder().build()
- private val moshi = Moshi.Builder().add(IntBooleanAdapter()).build()
+ private val moshi = Moshi.Builder().add(IntBooleanAdapterFactory()).build()
val json = """
{
"name": "guodongAndroid",
"enable": null
}
""".trimIndent()
val domain = jsonAdapter.fromJson(json)
println(domain)
附调试代码
package com.squareup.moshi.recipes
import com.squareup.moshi.*
import java.lang.reflect.Type
@JsonClass(generateAdapter = true)
data class Domain(
val name: String,
@field:IntBoolean val enable: Boolean = false,
)
@Retention(AnnotationRetention.RUNTIME)
@JsonQualifier
annotation class IntBoolean
// region 自定义Nullable注解
@Retention(AnnotationRetention.RUNTIME)
annotation class Nullable
// endregion
// region 自定义Adapter
class IntBooleanAdapter {
@FromJson
@IntBoolean
fun fromJson(@Nullable value: Int?): Boolean {
if (value == null) {
return false
}
return value > 0
}
@ToJson
fun toJson(@IntBoolean value: Boolean): Int {
return if (value) 1 else 0
}
}
// endregion
// region 自定义JsonAdapter.Factory
class IntBooleanAdapterFactory : JsonAdapter.Factory {
override fun create(
type: Type,
annotations: Set<Annotation>,
moshi: Moshi
): JsonAdapter<*>? {
if (type != Boolean::class.java) {
return null
}
val delegateAnnotations = Types.nextAnnotations(annotations, IntBoolean::class.java) ?: return null
val delegateAdapter = moshi.nextAdapter<Boolean>(this, type, delegateAnnotations)
return object : JsonAdapter<Boolean>() {
override fun fromJson(reader: JsonReader): Boolean {
return when (reader.peek()) {
JsonReader.Token.NULL -> {
reader.nextNull<Any>()
false
}
JsonReader.Token.NUMBER -> {
reader.nextInt() == 1
}
else -> delegateAdapter.fromJson(reader) ?: false
}
}
override fun toJson(writer: JsonWriter, value: Boolean?) {
val newValue = when (value) {
null -> 0
true -> 1
false -> 0
}
writer.value(newValue)
}
override fun toString(): String {
return "IntBooleanAdapter(Int -> Boolean)"
}
}
}
}
// endregion
private val moshi = Moshi.Builder()
// .add(IntBooleanAdapter())
.add(IntBooleanAdapterFactory())
.build()
private val jsonAdapter = moshi.adapter(Domain::class.java)
fun main() {
domain2Json()
json2Domain1()
json2Domain2()
json2Domain3()
json2DomainException()
}
private fun domain2Json() {
val domain = Domain("guodongAndroid", true)
val json = jsonAdapter.toJson(domain)
println("domain2Json: $json")
}
private fun json2Domain1() {
val json = """
{
"name": "guodongAndroid",
"enable": 1
}
""".trimIndent()
val domain = jsonAdapter.fromJson(json)
println("json2Domain1: $domain")
}
private fun json2Domain2() {
val json = """
{
"name": "guodongAndroid",
"enable": 0
}
""".trimIndent()
val domain = jsonAdapter.fromJson(json)
println("json2Domain2: $domain")
}
private fun json2Domain3() {
val json = """
{
"name": "guodongAndroid"
}
""".trimIndent()
val domain = jsonAdapter.fromJson(json)
println("json2Domain3: $domain")
}
private fun json2DomainException() {
val json = """
{
"name": "guodongAndroid",
"enable": null
}
""".trimIndent()
val domain = jsonAdapter.fromJson(json)
println("json2DomainException: $domain")
}