Moshi 支持 enum 空安全

227 阅读2分钟

背景

Moshi 支持 enum 枚举,但是解析时如果没有匹配到定义的枚举值便会报错,其实有些时候枚举的值随着业务也会有所变化,比如新增了一个枚举类型,我们当然不希望旧版本解析崩溃不可用。

问题

Mosh 是支持 enum 类型的。如下 json:

{
	"type": "Image"
	...
}
class UserKeynote(
    val type: ResourceType,
    ...
)

enum class ResourceType {
    Image,
    Text
}

解析时 Moshi 默认使用 enum 的 name 做匹配,当然也可以通过 @Json 自定义名字。

业务中,ResourceType 是后续可扩展的,比如后续新增一种类型 Audio,旧版本将解析失败。因此我们把 type 声明为可空的:

class UserKeynote(
    val type: ResourceType?,
    ...
)

然后业务上可以考虑怎么处理空类型,忽略或提示用户升级等。

不过,这样的修改 Moshi 并不能在遇到未知的 Audio 时,将 type 字段置为 null。看一下EnumJsonAdapter fromJson 源码:

@Override
public T fromJson(JsonReader reader) throws IOException {
  int index = reader.selectString(options);
  if (index != -1) return constants[index];

  // We can consume the string safely, we are terminating anyway.
  String path = reader.getPath();
  String name = reader.nextString();
  throw new JsonDataException(
      "Expected one of "
          + Arrays.asList(nameStrings)
          + " but was "
          + name
          + " at path "
          + path);
}

constants 中是所有枚举值,如果找不到对应枚举便会抛异常。

自定义 SafeEnumStringJsonAdapter

我们希望在解析 enum 时,如果不存在对应枚举则赋值 null。模仿 EnumJsonAdapter 的实现,定义 SafeEnumStringJsonAdapter 如下:

/**
 * 空安全的 Enum 解析器。
 * Moshi 自带的 [EnumJsonAdapter] 在遇到不认得的枚举值时会抛出异常,但在业务迭代时枚举并不是保持不变的。
 * 如果遇到这种情况,我们希望它能向前兼容不发生异常并返回 null 值,当然这也需要 Enum 的定义是 Nullable 的。
 */
internal class SafeEnumStringJsonAdapter<T : Enum<T>>(private val enumType: Class<T>) : JsonAdapter<T>() {
    private val nameStrings: Array<String?>
    private val constants: Array<T>?
    private var options: JsonReader.Options? = null

    @Throws(IOException::class)
    override fun fromJson(reader: JsonReader): T? {
        val index = reader.selectString(options)
        return if (index != -1) {
            constants!![index]
        } else {
            // We can consume the string safely
            reader.nextString()
            null
        }
    }

    @Throws(IOException::class)
    override fun toJson(writer: JsonWriter, value: T?) {
        writer.value(nameStrings[value!!.ordinal])
    }

    override fun toString(): String {
        return "JsonAdapter(" + enumType.name + ")"
    }

    init {
        try {
            constants = enumType.enumConstants
            nameStrings = arrayOfNulls(constants!!.size)
            for (i in constants.indices) {
                val constant = constants[i]
                val annotation = enumType.getField(constant.name).getAnnotation(Json::class.java)
                val name = annotation?.name ?: constant.name
                nameStrings[i] = name
            }
            options = JsonReader.Options.of(*nameStrings)
        } catch (var6: NoSuchFieldException) {
            throw AssertionError("Missing field in " + enumType.name, var6)
        }
    }
}

逻辑不难理解, fromJson 中不认识的枚举就返回 null。然后定义 SafeEnumJsonAdapterFactory 决定使用时机:

object SafeEnumJsonAdapterFactory : JsonAdapter.Factory {
    override fun create(type: Type, annotations: MutableSet<out Annotation>, moshi: Moshi): JsonAdapter<*>? {
        val rawType = Types.getRawType(type)
        if (rawType.isEnum) {
            return SafeEnumStringJsonAdapter(rawType as Class<out Enum<*>>).nullSafe()
        }
        return null
    }
}

创建 Moshi 实例时使用 SafeEnumJsonAdapterFactory

Moshi.Builder().add(SafeEnumJsonAdapterFactory)

如此,添加了 SafeEnumJsonAdapterFactory 的 Moshi 实例在解析 enum 时,遇到未知枚举值时将赋值为 null,同时 enum 的声明也必须是可空的。当然了,如果确认业务中的枚举不会变化,也可以定义不可空的,这点和之前一样。

话说,为什么 SafeEnumStringJsonAdapter 名字中有个 String 呢?因为 Moshi 只支持解析 json 中值为 String 的枚举,如果是 Int 或 Long 则无效,所以我还自定义了个 SafeEnumNumberJsonAdapter ,感兴趣的话见我的另一篇文章:Moshi 支持 Int/Long 类型 enum