Gson与Kotlin data class的NPE问题

1,708 阅读3分钟

一、问题

今年项目在线上爆过几次Gsonkotlin data classNullPointerException,之前没仔细研究,仅仅先对出问题的参数进行了可为的处理,来修复此问题。最近正好有点时间,而且发现此类问题在公司项目中出现的次数不少,所以对此问题的原因进行一下研究,并整理一下处理方案。

1、参数没有默认值

先来看看没有构造函数默认值的例子

data class Bean(val id:Int,val name:String)

val json = "{\n  \"id\": 100\n}"
val beanGson = GsonBuilder().create().fromJson(json,Bean::class.java)
Log.i("gson_bean_0","id:${beanGson.id};name:${beanGson.name}")

Bean需要的参数有id、name2个,而此时Json中仅有id一个参数,大家猜猜打印会得到什么结果呢?

I/gson_bean_0: id:100;name:null

这就有点奇怪了,name不是设置成了不可null的String类型吗?怎么打印出了null?我们先来看下Bean反编译的结果

public final class Bean {
   private final int id;
   @NotNull
   private final String name;

   public final int getId() {
      return this.id;
   }

   @NotNull
   public final String getName() {
      return this.name;
   }

   public Bean(int id, @NotNull String name) {
      Intrinsics.checkNotNullParameter(name, "name");
      super();
      this.id = id;
      this.name = name;
   }
   // 省略 tostring、hashcode、equals等方法
}

我们可以看到在Bean的构造函数中,对name进行了kotlin的Null-Safely检查,那么Gson解析的时候为什么没有触发NPE呢?它是用了什么魔法绕过的呢?这里先挂个钩子1⃣️,等到下面原因探究章节再一起解释。

2、所有参数都有默认值

现在我们将idname都添加上默认参数,其余设置不变

data class Bean(val id:Int=1,val name:String="idtk")

val json = "{\n  \"id\": 100\n}"
val beanGson = GsonBuilder().create().fromJson(json,Bean::class.java)
Log.i("gson_bean_1","id:${beanGson.id};name:${beanGson.name}")

// log
I/gson_bean_1: id:100;name:idtk

虽然Json没有返回具体的name值,但是可以看到参数默认值生效了,现在再来看下反编译之后的Bean类与上面没有默认值时,有什么不同

public final class Bean {
   private final int id;
   @NotNull
   private final String name;

   public final int getId() {
      return this.id;
   }

   @NotNull
   public final String getName() {
      return this.name;
   }

   public Bean(int id, @NotNull String name) {
      Intrinsics.checkNotNullParameter(name, "name");
      super();
      this.id = id;
      this.name = name;
   }

   // $FF: synthetic method
   public Bean(int var1, String var2, int var3, DefaultConstructorMarker var4) {
      if ((var3 & 1) != 0) {
         var1 = 1;
      }

      if ((var3 & 2) != 0) {
         var2 = "idtk";
      }

      this(var1, var2);
   }

   public Bean() {
      this(0, (String)null, 3, (DefaultConstructorMarker)null);
   }

	  // 省略 tostring、hashcode、equals等方法
}

这里与无默认值的反编译结果对比,比较明显的就是Bean类多了一个无参构造函数,这里需要关注与喜爱,等后面看到源码时,就会明白它的用处。现在再来做另一个实验,如果我在Json中明确指定name为null会怎样呢?

data class Bean(val id:Int=1,val name:String="idtk")

val json = "{\n  \"id\": 100,\n  \"name\": null\n}"
val beanGson = GsonBuilder().create().fromJson(json,Bean::class.java)
Log.i("gson_bean_2","id:${beanGson.id};name:${beanGson.name}")

大家可以猜猜会发生什么情况:

1、抛出NullPointerException异常

2、打印出name为idtk

3、打印出name为null

I/gson_bean_2: id:100;name:null

答应可能超出了部分同学的意料,居然打印出了name为null,这里kotlin的Null-Safely检查又没有生效,是什么地方绕过了呢?这里我们挂下第二个钩子2⃣️,等到下面原因探究章节将会得到解释。

3、参数部分有默认值

现在我们将部分参数设置默认值,看下情况

data class Bean(val id:Int=1,val name:String)

val json = "{\n  \"id\": 100\n}"
val beanGson = GsonBuilder().create().fromJson(json,Bean::class.java)
Log.i("gson_bean_3","id:${beanGson.id};name:${beanGson.name}")

val json = "{\n  \"id\": 100,\n  \"name\": null\n}"
val beanGson = GsonBuilder().create().fromJson(json,Bean::class.java)
Log.i("gson_bean_4","id:${beanGson.id};name:${beanGson.name}")

// log

I/gson_bean_3: id:100;name:null
I/gson_bean_4: id:100;name:null

此种情况与第一种没有默认值的情况类似,在此就不做过多说明了,接下来一起进入Gson的源码,探究一下产生这些解析结果的原因吧。

二、原因探究

GsonfromJson处理方式,一般是根据数据的类型,选择相对应的TypeAdapter对数据进行解析,上面的示例为Bean对象,最终将走到ReflectiveTypeAdapterFactory.create方法中,返回TypeAdapter,其中调用了constructorConstructor.get(type)方法,这里主要看一下它

public <T> ObjectConstructor<T> get(TypeToken<T> typeToken) {
    final Type type = typeToken.getType();
    final Class<? super T> rawType = typeToken.getRawType();

    // 省略部分代码。。。

    ObjectConstructor<T> defaultConstructor = newDefaultConstructor(rawType);
    if (defaultConstructor != null) {
      return defaultConstructor;
    }

    ObjectConstructor<T> defaultImplementation = newDefaultImplementationConstructor(type, rawType);
    if (defaultImplementation != null) {
      return defaultImplementation;
    }

    // finally try unsafe
    return newUnsafeAllocator(type, rawType);
  }

这里有3个方法来创建对象

1、newDefaultConstructor方法,通过无参构造函数,尝试创建对象,创建成功则返回对象,否则返回null,进入下一步尝试。

Object[] args = null;
return (T) constructor.newInstance(args);

2、newDefaultImplementationConstructor方法,通过反射集合框架类型来创建对象,上面的示例显然不是这种情况。

3、兜底的newUnsafeAllocator方法,通过sun.misc.UnsafeallocateInstance方法构建对象,Unsafe类使Java拥有了直接操作内存中数据的能力。如果想要进一步了解Unsafe,可以参考美团的文章Java魔法类:Unsafe应用解析

Class<?> unsafeClass = Class.forName("sun.misc.Unsafe");
Field f = unsafeClass.getDeclaredField("theUnsafe");
f.setAccessible(true);
final Object unsafe = f.get(null);
final Method allocateInstance = unsafeClass.getMethod("allocateInstance", Class.class);
return new UnsafeAllocator() {
  @Override
  @SuppressWarnings("unchecked")
  public <T> T newInstance(Class<T> c) throws Exception {
    return (T) allocateInstance.invoke(unsafe, c);
  }
};

我想看了上面三种构造对象的方法,相信读者对第一章节的两个钩子心里已经有了答案。

第一个钩子1⃣️

在这种情况下,data对象并没有无参构造函数,所以在构造对象时,只能使用Unsafe的兜底方案,此时直接操作内存获取的对象,自然绕过了2个参数构造函数的Null-Safely检查,所以并没有抛出NPE,第一章的参数无默认值与部分参数有默认值,都可以归入这种情况。

第二个钩子2⃣️

在这种情况下,data对象将会直接适配无参构造函数的方式构建对象,而Gson设置对应属性时,又是使用了反射,自然在整个过程中也不会触发kotlin的Null-Safely检查,所以并不会抛出NPE

三、解决方法

解决上述的Json解析问题,我整理了下面两个方案,可供大家选择。

1、选用moshi

moshisquare 提供的一个开源库,提供了对 Kotlin data class的支持。简单使用如下:

val moshi = Moshi.Builder()
	// 添加kotlin解析的适配器
	.add(KotlinJsonAdapterFactory())
  .build()

val adapter = moshi.adapter(Bean::class.java)
val bean = adapter.fromJson(json)?:return
Log.i("gson_bean_5","${bean.id}:${bean.name}")

moshi对于Json中明确返回null的参数将会进行校验,如果此参数不可为null,则会抛出JsonDataException。对于Json中缺少某个字段,而此字段又没有设置默认值的情况下,则也会抛出JsonDataException

moshi的GitHub地址

2、自定义GsonTypeAdapterFactory

Gson框架可以通过添加TypeAdapterFactory的方式干预Json数据的解析过程,我们可以编写一个自定义的TypeAdapterFactory来完成我们对Kotlin data class的支持,我们需要达到的目的如下:

  • 对于类型不可以为null且设置了默认值的参数,如果Json中缺失此字段或者明确此字段为null,则使用默认值代替
  • 对于类型不可为null且未设置默认值的参数,如果Json中缺失此字段或者明确此字段null,则抛出异常
  • 对于类型可以为null的参数,不论其是否设置了默认值,返回的Json中缺失此了字段,或者明确此字段为null,都可以正常解析

对以上这些要求,首先需要获取对象的默认值,然后根据1⃣️参数是否为null、2⃣️参数是否可null、3⃣️数据是否有无参构造函数,进行处理步骤如下:

  1. 判断是否为kotlin对象,如果不是则跳过,是则继续

    private val KOTLIN_METADATA = Metadata::class.java
    
    // 如果类不是kotlin,就不要使用自定义类型适配器
    if (!rawType.isAnnotationPresent(KOTLIN_METADATA)) return null
    
  2. 通过无参构造函数,创建出对象,缓存对象的默认值

    val rawTypeKotlin = rawType.kotlin
    // 无参数构造函数
    val constructor = rawTypeKotlin.primaryConstructor ?: return null
    constructor.isAccessible = true
    // params与value映射
    val paramsValueByName = hashMapOf<String, Any>()
    // 判断是否有空参构造
    val hasNoArgs = rawTypeKotlin.constructors.singleOrNull {
        it.parameters.all(KParameter::isOptional)
    }
    if (hasNoArgs != null) {
        // 无参数构造实例
        val noArgsConstructor = (rawTypeKotlin as KClass<*>).createInstance()
        rawType.declaredFields.forEach {
            it.isAccessible = true
            val value = it.get(noArgsConstructor) ?: return@forEach
            paramsValueByName[it.name] = value
        }
    }
    
  3. 在反序列化时,判断data class的参数是否可以为null,序列化中读取的值是否为null,参数是否有缓存值

    1. 参数可以为null,序列化读取的值为null,则继续
    2. 参数可以为null,序列化读取的值不为null,则继续
    3. 参数不可为null,序列化读取的值不为null,则继续
    4. 参数不可为null,序列化读取的值为null,参数有缓存的默认值,则将参数设置为默认值
    5. 参数不可为null,序列化读取的值为null,参数没有缓存的默认值,则抛出异常
    val value: T? = delegate.read(input)
    if (value != null) {
        /**
         * 在参数不可以为null时,将null转换为默认值,如果没有默认值,则抛出异常
         */
        rawTypeKotlin.memberProperties.forEachIndexed { index, it ->
            if (!it.returnType.isMarkedNullable && it.get(value) == null) {
                val field = rawType.declaredFields[index]
                field.isAccessible = true
                if (paramsValueByName[it.name] != null) {
                    field.set(value, paramsValueByName[it.name])
                } else {
                    throw JsonParseException(
                        "Value of non-nullable member " +
                                "[${it.name}] cannot be null"
                    )
                }
            }
        }
    }
    return value
    

此方案的缺点

  • 现在来思考下,这个方案是否可以完美无缺呢?不知道是否有人注意到了步骤中的第二步,要实行这个方案,必需有一个无参数构造函数,假设kotlin data class没有做到这点呢?这时再结合一下Gson三个构造函数中的第三个Unsafe方案一起思考。此时因为没有无参数构造函数,数据对象将通过Unsafe进行对象的创建,数据类型获得了虚拟机赋予的默认值。此时在序列化时基本类型读取到的结果并不会为null,而是会是虚拟机赋予的默认值,从而逃避了检查。

四、总结

Gson在解析Kotlin data class时,如果data没有提供默认的无参数构造函数,Gson将通过Unsafe方案创建对象,此时将跳过kotlin的Null-Safely检查,并且此时对象中数据的值,皆为虚拟机赋予的初始值,而不是我们定义的默认值,所以首先需要给对象提供无参数构造函数。但是即使提供了无参数,如果返回的Json中,明确指定某个参数为null,我们依然无能为力,此时可以接入我上面提供KotlinJsonTypeAdapterFactory,它将会检查这个参数是否可以为null,如果不可为null,则使用默认值替换掉null。

此方案并不是完美的,它要求你提供一个有无参数构造函数的Kotlin data class,才可以保证不会触发NullPointerException

KotlinJsonTypeAdapterFactory仓库地址

如果在阅读过程中,有任何疑问与问题,欢迎与我联系。

博客: www.zhichaoma.com

GitHub: github.com/Idtk

邮箱: IdtkMa@gmail.com