Gson解析遇到Kotlin Data Class生成默认值的方案探索

最近在梳理网络功能中遇到一个坑,当Gson解析生成一个Data Class时,如果字段缺失,引用类型都会为null。这样当客户端使用的时候,很容易产品空指针异常。非空类型原本的初衷是让程序更健壮,现在反而会使程序产生不可预估的后果。

解决这个问题的方式也可以很简单,将Data Class字段设置为可空类型,在业务层做大量安全判断。但这样的代码显然不够优雅,我们依然希望在网络层就能解决数据问题,让业务层不用频繁的去关心字段是否为空。

出于后端不信任原则,先来看看客户端可能遇到的两个问题

  • json中某个字段缺失
  • json中某个字段value为null

先来看看三个Case,不同的实现方案分别解析同一段Json数据。

第一个Case,实现一个UserBean,不包含任何默认参数。

data class UserBean(
    val name: String,
    val age: Int,
    val notInJson: String,//json中不包含该字段
    val inJsonIsNull: String//json中包含该字段但该字段值为null
)

val gson = Gson()
val json = """ {"name": "lhc","age": 18,"inJsonIsNull": null}"""
val data = gson.fromJson(json, UserBean::class.java)
//打印结果:UserBean(name=lhc, age=18, notInJson=null, inJsonIsNull=null)
checkNotNull(data.notInJson)//运行结果:抛出异常
checkNotNull(data.inJsonIsNull)//运行结果:抛出异常
复制代码

第二个Case,修改一下UserBean,给予异常字段一个默认参数

data class UserBean(
    val name: String,
    val age: Int,
    val notInJson: String = "DEFAULT",//json中不包含该字段
    val inJsonIsNull: String = "DEFAULT"//json中包含该字段但该字段值为null
)

val gson = Gson()
val json = """ {"name": "lhc","age": 18,"inJsonIsNull": null}"""
val data = gson.fromJson(json, UserBean::class.java)
//打印结果:UserBean(name=lhc, age=18, notInJson=null, inJsonIsNull=null)
checkNotNull(data.notInJson)//运行结果:抛出异常
checkNotNull(data.inJsonIsNull)//运行结果:抛出异常
复制代码

第三个Case,给所有字段加上默认值

data class UserBean(
    val name: String = "DEFAULT",
    val age: Int = 10,
    val notInJson: String = "DEFAULT",//json中不包含该字段
    val inJsonIsNull: String = "DEFAULT"//json中包含该字段但该值字段为null
)

val gson = Gson()
val json = """ {"name": "lhc","age": 18,"inJsonIsNull": null}"""
val data = gson.fromJson(json, UserBean::class.java)
//UserBean(name=lhc, age=18, notInJson=DEFAULT, inJsonIsNull=null)
checkNotNull(data.notInJson)//运行结果:正常,notInJson不为nul
checkNotNull(data.inJsonIsNull)//运行结果:抛出异常
复制代码

经过测试我们得到一个结论。当DataClass所有字段(包括基础类型)都存在默认参数,Gson解析后,对于缺失的字段,该字段的值为默认值

那为什么会产生这样的差异?这里的具体原因还跟Kotlin默认参数的实现方案有关,就不展示只说一下结论:当DataClass所有字段(包括基础类型)都存在默认参数,编译器会为这个DataClass生成一个无参构造方法

那么到这里我们又有了两个新的疑问?

  • 为什么无参构造方法能成功的赋予缺失字段默认值
  • 为什么Gson解析并没有触发Kotlin的nullSafe检测

Gson解析流程

Gson解析内部通过反射创建示例,给字段赋值,直接ReflectiveTypeAdapterFactory看起。

@Override public <T> TypeAdapter<T> create(Gson gson, final TypeToken<T> type) {
  Class<? super T> raw = type.getRawType();

  if (!Object.class.isAssignableFrom(raw)) {
    return null; // it's a primitive!
  }

  ObjectConstructor<T> constructor = constructorConstructor.get(type);//(1)获取constructor
  return new Adapter<T>(constructor, getBoundFields(gson, type, raw));
}
复制代码

看一下ConstructorConstructor#get方法,用来返回一个构造器,主要用于实例化对象。主要有三种类型的构造器,无参构造器、容器类构造器、Unsafe构造器。如果没有找到匹配的构造器则会用Unsafe构造器兜底。

public <T> ObjectConstructor<T> get(TypeToken<T> typeToken) {
  ObjectConstructor<T> defaultConstructor = newDefaultConstructor(rawType);//(1)获取无参构造器
  if (defaultConstructor != null) {
    return defaultConstructor;
  }

  ObjectConstructor<T> defaultImplementation = newDefaultImplementationConstructor(type, rawType);//(2)获取容器类的构造方法
  if (defaultImplementation != null) {
    return defaultImplementation;
  }

  // finally try unsafe
  return newUnsafeAllocator(type, rawType);//通过Unsafe来生成对象
}
复制代码

ConstructorConstructor#newDefaultConstructor内部获取了无参的构造方法,如果获取成功则返回,获取失败则返回null

private <T> ObjectConstructor<T> newDefaultConstructor(Class<? super T> rawType) {
    //获取无参构造方法
    final Constructor<? super T> constructor = rawType.getDeclaredConstructor();
    if (!constructor.isAccessible()) {
      accessor.makeAccessible(constructor);
    }
    return new ObjectConstructor<T>() {
      @Override public T construct() {
          Object[] args = null;
          return (T) constructor.newInstance(args);
      }
    };
}
复制代码

当实体类没有无参构造函数,且不是容器类型,最终会命中ConstructorConstructor#newUnsafeAllocator进行兜底。UnsafeAllocator内部通过非常规实例化对象方法allocateInstance创建对象,该方法提供了通过Class对象就能创建实例的功能,不需要调用构造方法、初始化代码、JVM安全检查等,即使构造函数是private也能用此方法进行实例化

  private <T> ObjectConstructor<T> newUnsafeAllocator(
      final Type type, final Class<? super T> rawType) {
    //返回ObjectConstructor,内部通过UnsafeAllocator创建实体类
    return new ObjectConstructor<T>() {
      private final UnsafeAllocator unsafeAllocator = UnsafeAllocator.create();

      @Override public T construct() {
         Object newInstance = unsafeAllocator.newInstance(rawType);
          return (T) newInstance;
        }
      }
    };
  }
复制代码

当实例化完成,gson通过解析好的数据给每个字段赋值。gson会取出json中的每个key,去匹配

@Override public T read(JsonReader in) throws IOException {
		in.beginObject();
		while (in.hasNext()) {
		String name = in.nextName();
		BoundField field = boundFields.get(name);//每次取一个key,去解析好的实体类中查询是否包含该字段
		if (field == null || !field.deserialized) {//如果实体类中不包含该字段则跳过,包含则赋值
			in.skipValue();
		} else {
      field.read(in, instance);
    }
    }
}

@Override void read(JsonReader reader, Object value)
    throws IOException, IllegalAccessException {
  Object fieldValue = typeAdapter.read(reader);//获取字段值
  if (fieldValue != null || !isPrimitive) {//如果字段不为null或者字段不是基础类型,则给字段赋值。因此如果返回的字段值为null,这里的赋值会覆盖原先的默认值
    field.set(value, fieldValue);
  }
}
复制代码

回到我们之前遇到的两个问题

  • 为什么无参构造方法能成功的赋予缺失字段默认值

    当实体类存在无参构造方法,Gson会通过反射调用无参构造方法来实例化对象,此时所有字段会被赋默认值。因此缺失字段获取的值为默认值。

  • 为什么Gson解析并没有触发Kotlin的nullSafe检测

    Gson给实体类中的每个值赋值通过反射实现,绕过了Data Class有参构造方法中的非空检测

如何解决

既然已经找到原因,该如何解决这两个问题。目前感觉想很好的容错依然需要两个方案的互补。

  • 方案1:提供无参构造方法,给每个字段设置默认值

缺点:

1.模板代码太多,容易漏写

2.如果实体类中引用了另外一个实体类,该实体类也需要无参构造方法

目前在探索开发一个Android Studio插件提供给Data Class自动生成无参的构造方法,并且给每个字段设置默认值。这样能极大的减少模板代码编写。

也考虑过使用ASM实现该功能,给每个DataClass插入一个无参构造方法,但感觉这个方案比较繁琐,而且不能定制化默认参数,暂时不考虑

  • 方案2:修改ReflectiveTypeAdapterFactory代码

缺点:

1.因为无法直接修改源码,只能复制一份修改一些必要的地方,通过反射替换。依赖Gson源码实现,升级Gson库需要兼容测试

2.能有效解决String、容器类为空问题,但得枚举需要兜底的类型。而且一旦引用其他实体类,如果依赖反射进行递归初始化,效率会比较低,同时也要求这个实体类有无参的构造方法。出于以上原因,我在进行兜底的时候只处理了字符串和部分容器类

修改BoundField#read方法,原先逻辑为值为null,如果不是基本类型就赋值,改为为null不赋值

@Override
void read(JsonReader reader, Object value)throws IOException, IllegalAccessException {
	Object fieldValue = typeAdapter.read(reader);
	//值不为null,则进行赋值
	if (fieldValue != null) {
		this.hasSetValue = true;
		field.set(value, fieldValue);
	}
}
复制代码

Adapter#read方法返回前,插入兜底代码。目前只处理String、Map、List

//修改BoundField,方便后续做判断
static abstract class BoundField {
    final String name;
    final boolean serialized;
    final boolean deserialized;
    private final boolean isPrimitive;
    public boolean hasSetValue = false;
    final Field field;
}
/**
 * 对于null字段进行兜底
 *
 * @param instance
 */
private void hookNullField(T instance) {
    try {
        for (BoundField boundField : boundFields.values()) {
            //已初始化、基础数据类型、不序列化的字段不进行兜底初始化
            if (boundField == null
                    || !boundField.deserialized
                    || boundField.hasSetValue
                    || boundField.isPrimitive) {
                continue;
            }
            Field field = boundField.field;
            Class<?> type = field.getType();
            if (type == String.class) {
                field.set(instance, "");
            } else if (type == List.class) {
                field.set(instance, new ArrayList<>());
            } else if (type == Map.class) {
                field.set(instance, new HashMap<>());
            }
        }

    } catch (IllegalAccessException e) {
        e.printStackTrace();
    }
}
复制代码

参考文档:

Gson 支持 Kotlin 空安全的一种尝试

Gson 和 Kotlin data class 的避坑指南

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享