跳至主要內容

Moshi的JsonQualifier实战

guodongAndroid大约 7 分钟

背景

笔者在开发过程中使用 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 内置支持 StringNumber 类型的互相转换。

实战

为解决上述问题,笔者查看了 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,
)

其他的不需要改动。

JSON中字段为 null

比如在上面的例子中,服务端响应的 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#fromJsonmoshi 源码 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")
}