Kotlin 也有json解析问题?

2,140 阅读6分钟

这是我参与11月更文挑战的第9天,活动详情查看:2021最后一次更文挑战

前言

我们开发用到的最多的json解析工具就是Gson了,毫无疑问它一直兢兢业业的,没有出现过什么问题,而且还非常好用,但是,在kotlin中使用,Gson没有顶住,出现了点小问题,下面总结下问题。

问题一:class字段默认值失效

所有字段都有默认值的情况

@JsonClass(generateAdapter = true)data class DefaultAll(    val name: String = "me",    val age: Int = 17)
fun testDefaultAll() {    val json = """{}"""    val p1 = gson.fromJson(json, DefaultAll::class.java)    println("gson parse json: $p1")    val p2 = moshi.adapter(DefaultAll::class.java).fromJson(json)    println("moshi parse json: $p2")}// 结果// gson parse json: DefaultAll(name=me, age=17)// moshi parse json: DefaultAll(name=me, age=17)

可以看到这种情况下gson和moshi都没有问题

部分字段有默认值

@JsonClass(generateAdapter = true)data class DefaultPart(    val name: String = "me",    val gender: String = "male",    val age: Int)
fun testDefaultPart() {    // 这里必须要有age字段,moshi为了保持空安全不允许age为null    val json = """{"age": 17}"""    val p1 = gson.fromJson(json, DefaultPart::class.java)    println("gson parse json: $p1")    val p2 = moshi.adapter(DefaultPart::class.java).fromJson(json)    println("moshi parse json: $p2")}
// 结果// gson parse json: DefaultPart(name=null, gender=null, age=17)// moshi parse json: DefaultPart(name=me, gender=male, age=17)

这种情况下gson忽略了name字段和gender字段默认值,给非空类型设置了一个null值,这个就不符合预期了。而moshi则没有影响。

问题分析: gson丢失默认值原因

Gson反序列化对象时

  • 先尝试获取无参构造函数
  • 失败则尝试List、Map等情况的构造函数
  • 最后使用Unsafe.newInstance兜底(此兜底不会调用构造函数,导致所有对象初始化代码不会调用)

显然出现这种情况是因为Gson获取类的无参构造函数失败了,所以最后走到了unsafe方案。让我们来看看Kotlin代码对应的java代码,一探究竟。AS tools -> kotlin -> show kotlin bytecode可以查看kotlin编译后的字节码,decompile后可以查看对应的java代码。

所有字段都有默认值

public final class DefaultAll {   
@NotNull  
private final String name;   
private final int age;
   @NotNull   
   public final String getName() {      
       return this.name;   
   }
   public final int getAge() {      
       return this.age;   
   }
   public DefaultAll(@NotNull String name, int age) {      
       Intrinsics.checkNotNullParameter(name, "name");      
       super();     
       this.name = name;      
       this.age = age;   
       }
   // $FF: synthetic method   
   public DefaultAll(String var1, int var2, int var3, DefaultConstructorMarker var4) {      
       if ((var3 & 1) != 0) {         
           var1 = "me";      
   }
      if ((var3 & 2) != 0) {         
          var2 = 17;     
      }
          this(var1, var2);  
   }      
      public DefaultAll() {      
          this((String)null, 0, 3, (DefaultConstructorMarker)null);   
      }
   }

可以看到这种情况下该类会生成空参构造函数,但是空参构造函数中并没有赋值,而是调用了synthetic method这个额外生成的辅助构造函数对字段赋默认值。synthetic method倒数第二个参数是一个int类型,用于标记哪些字段使用默认值赋值,按字段声明顺序它们对应的flag值为2^n也就是1 2 4 8....

因为存在空参构造函数而且会赋值默认值,所以这种情况下gson使用正常。

部分字段有默认值

public final class DefaultPart {   
@NotNull   private final String name;   
@NotNull   private final String gender;   
private final int age;
   @NotNull   
   public final String getName() {      
       return this.name;   
   }
   @NotNull   
   public final String getGender() {      
       return this.gender;   
   }
   public final int getAge() {      
       return this.age;   
   }
   public DefaultPart(@NotNull String name, @NotNull String gender, int age) { 
       Intrinsics.checkNotNullParameter(name, "name");      
       Intrinsics.checkNotNullParameter(gender, "gender");      
       super();      
       this.name = name;      
       this.gender = gender;      
       this.age = age;  
   }
   // $FF: synthetic method   
   public DefaultPart(String var1, String var2, int var3, int var4, DefaultConstructorMarker 
       var5) {      
   // 最低不为0表示第一个默认值字段在json中无值,需要默认值      
       if ((var4 & 1) != 0) {         
           var1 = "me";      
       }
      if ((var4 & 2) != 0) {         
          var2 = "male";      
      }
      this(var1, var2, var3);   
      }
   }

这种情况下该类并没有生成空参构造函数,所以gson实例化时使用了Unsafe,自然默认值不生效。实际上只有所有字段都有默认值时才会生成空参构造函数。

解决方案:

分析了这么多,避免默认值无效的方法已经显而易见了

  1. 定义类时所有字段都给一个默认值,这样gson就可以正常工作
  2. 使用Moshi库

Moshi库属于square公司,最初由Jake Wharton主导,他是kotlin的拥趸,不难推测moshi对Kotlin做了兼容,实际上也是这样。

Moshi序列化/反序列化时根据每个类反射创建对应的JsonAdapter,用它来进行具体操作,同时支持使用annotationProcessor编译时预先生成各个类的JsonAdapter,空间换时间提升性能。 从它的源码可以看出来,它做了2件事:

  1. 用一个int记录(字段超过32个使用多个int)默认值字段在将要解析的json中是否存在,从最低位到最高位依次记录第一个到最后一个默认值字段在json中是否有key,0表示存在,1表示不存在
  2. 判断是否所有默认字段在json中都有值,若为true则不用管默认值,直接使用json字段生成实例,若为false则反射调用(synthetic构造器只能够反射调用)synthetic构造器实例化对象,synthetic构造器会根据标志位为默认值字段赋值

一言蔽之,Moshi通过遵循Kotlin的机制做到了兼容。

问题二: Json中value为null的情况

正常情况下后端返回的Json数据中只应该存在Object类型字段为null的情况,但是现实很骨感,不乏String类型/list类型丢过来也是null的情况。

  • 在Java中,null value会覆盖掉默认值,使用时get方法中判空就可以了。
  • 但是在Kotlin中,如果该字段声明为非空类型,使用gson序列化后非空类型字段会被赋予null值,虽然由于空安全检查是在编译器进行不会报异常,但是这明显非常不符合预期。
  • 而Moshi中对这个情况做了处理,非空字段对应的json value为null时抛JsonDataException,对应的key都不存在时也做同样处理

这些处理逻辑看起来都很合情合理,但是实际开发中不可预期的null value情况又确实存在,我们也不太可能将所有字段都声明为可空类型,那么将Json中null value自定义解析成预设值或许是一个比较好的方法。

Gson自定义解析替换null value

Gson自定义解析使用TypeAdapterFactory或者单TypeAdapter,下面示例将声明为String和List的字段通过自定义解析器替换Json中null value为空字符串和空list

class GsonDefaultAdapterFactory: TypeAdapterFactory {
    override fun <T : Any> create(gson: Gson, type: TypeToken<T>): TypeAdapter<T>? {
        if (type.type == String::class.java) {
            return createStringAdapter()
        }
        if (type.rawType == List::class.java || type.rawType == Collection::class.java) {
            return createCollectionAdapter(type, gson)
        }
        return null
    }

    /**
     * null替换成空List
     */
    private fun <T : Any> createCollectionAdapter(
        type: TypeToken<T>,
        gson: Gson
    ): TypeAdapter<T>? {
        val rawType = type.rawType
        if (!Collection::class.java.isAssignableFrom(rawType)) {
            return null
        }

        val elementType: Type = `$Gson$Types`.getCollectionElementType(type.type, rawType)
        val elementTypeAdapter: TypeAdapter<Any> =
            gson.getAdapter(TypeToken.get(elementType)) as TypeAdapter<Any>

        return object : TypeAdapter<Collection<Any>>() {
            override fun write(writer: JsonWriter, value: Collection<Any>?) {
                writer.beginArray()
                value?.forEach {
                    elementTypeAdapter.write(writer, it)
                }
                writer.endArray()
            }

            override fun read(reader: JsonReader): Collection<Any> {
                val list = mutableListOf<Any>()
                // null替换为空list
                if (reader.peek() == JsonToken.NULL) {
                    reader.nextNull()
                    return list
                }
                reader.beginArray()
                while (reader.hasNext()) {
                    val element = elementTypeAdapter.read(reader)
                    list.add(element)
                }
                reader.endArray()
                return list
            }

        } as TypeAdapter<T>
    }

    /**
     * null 替换成空字符串
     */
    private fun <T : Any> createStringAdapter(): TypeAdapter<T> {
        return object : TypeAdapter<String>() {
            override fun write(writer: JsonWriter, value: String?) {
                if (value == null) {
                    writer.value("")
                } else {
                    writer.value(value)
                }
            }

            override fun read(reader: JsonReader): String {
                // null替换为""
                if (reader.peek() == JsonToken.NULL) {
                    reader.nextNull()
                    return ""
                }
                return reader.nextString()
            }

        } as TypeAdapter<T>
    }
}

测试代码:

val gson: Gson = GsonBuilder()
    .registerTypeAdapterFactory(GsonDefaultAdapterFactory())
    .create()
    
data class Person(
    val name: String,
    val friends: List<Person>
)

fun testGsonNullValue() {
    // 这里必须要有age字段,moshi为了保持空安全不允许age为null
    val json = """{"name":null, "friends":null}"""
    val p1 = gson.fromJson(json, Person::class.java)
    println("gson parse json: $p1")
}

运行结果gson parse json: Person(name=, friends=[]),符合预期

同样的 Moshi 库也可以完全解决Gson这种null值带来的问题

解决方案:

  1. 使用Gson给所有定义字段赋值默认值+自定义解析将不可预期的null值过滤
  2. 使用Moshi,自定义解析过滤不可预期的null值

总结:

遇到kotlin中json解析的问题,可以参考以上解决方案,希望可以帮到大家。