RPC with Kotlinx Serialization

因为将项目从Retrofit切换到Ktor和gson切换到kotlinx serialize。而kotlinx serialize和gson的实现差异导致在rpc接口中对于rpc 请求参数类型不一致的情况下,序列化出现了异常报错,比如类似一下的请求Body

{
    "id": 0,
    "jsonrpc": "2.0",
    "method": "eth_call",
    "params": [
        {
            "data": "0x5ec88c7900000000000000000000000081080a7e991bcdddba8c2302a70f45d6bd369ab5",
            "from": "0x81080a7e991bcDdDBA8C2302A70f45d6Bd369Ab5",
            "to": "0xb3831584acb95ED9cCb0C11f677B5AD01DeaeEc0"
        },
        "latest"
    ]
}

List<Any> + @Contextual

本来以为在Kotlinx Serialize上会像Gson一样轻松解决,一开始便对于原来的数据结构定义成

@Serializable
data class TestBaseRpc(
    val jsonrpc: String,
    val method: String,
    val params: List<@Contextual Any>,
    val id: Int
)

并在Json 配置的时候将 Contextual配置上

Json {
    useArrayPolymorphism = true
    prettyPrint = true
    allowStructuredMapKeys = true
    serializersModule = SerializersModule {
        contextual(Any::class) {
            TestCaller.serializer()
            String.serializer()
        }
    }
}

将单元测试代码跑起来后发现,contextual 只使用了最后一个serializer来解析Any类型,所以这个方向无法实现想要的结果
报错如下:

***.TestCaller cannot be cast to class java.lang.String

Note: 如果将两个serializer调换顺序就会发现报错也会两个类型互换

通过 Sealed Interface + Polymorphic

polymorphic多态的意思,顾名思义,可以根据实际情况进行选择对应的Serializer,所以需要有一个基类(可以interface,sealed…)

  1. 首先定义一个interface
    internal interface Parameter
  2. 定义subclass
    internal sealed interface Parameter {
        @Serializable
        data class CallParameter(
            val data: String,
            val from: String,
            val to: String
        ) : Parameter
    
        @Serializable(with = IntListParameterSerializer::class)
        data class IntListParameter(
            val items: List<Int>
        ) : Parameter
    
        @Serializable(with = StringParameterSerializer::class)
        data class StringParameter(
            val content: String
        ) : Parameter
        ...
    }
  3. 配置Json模块,绑定subclass的Serializer
    Json {
        prettyPrint = true
        isLenient = true
        ignoreUnknownKeys = true
        allowStructuredMapKeys = true
        serializersModule = SerializersModule {
            polymorphic(List::class) {
                ListSerializer(ParameterSerialize)
            }
            polymorphic(Parameter::class) {
                subclass(Parameter.CallParameter::class, Parameter.CallParameter.serializer())
                subclass(Parameter.StringParameter::class, Parameter.StringParameter.serializer())
                subclass(Parameter.IntListParameter::class, Parameter.IntListParameter.serializer())
            }
        }
    }
  4. 针对不同结构定义不同的Serializer
    internal object IntListParameterSerializer : KSerializer<Parameter.IntListParameter> {
        override fun deserialize(decoder: Decoder): Parameter.IntListParameter {
            val items = decoder.decodeSerializableValue(ListSerializer(Int.serializer()))
            return Parameter.IntListParameter(
                items = items
            )
        }
    
        override val descriptor: SerialDescriptor
            get() = buildClassSerialDescriptor("IntListParameter") {
                this.element<List<Int>>("items")
            }
    
        override fun serialize(encoder: Encoder, value: Parameter.IntListParameter) {
            encoder.encodeSerializableValue(ListSerializer(Int.serializer()), value.items)
        }
    }
    
    internal object RpcRequestBodySerializer : KSerializer<RpcRequestBody> {
        override fun deserialize(decoder: Decoder): RpcRequestBody {
            return decoder.decodeStructure(descriptor) {
                var rpc: String? = null
                var method: String? = null
                var params: List<Parameter> = emptyList()
                var id: Int? = null
                loop@ while (true) {
                    when (decodeElementIndex(descriptor)) {
                        CompositeDecoder.DECODE_DONE -> break@loop
    
                        0 -> rpc = decodeStringElement(descriptor, 0)
                        1 -> method = decodeStringElement(descriptor, 1)
                        2 -> params = decodeSerializableElement(descriptor, 2, ListSerializer(ParameterSerialize))
                        3 -> id = decodeIntElement(descriptor, 3)
                    }
                }
                RpcRequestBody(
                    jsonrpc = rpc ?: "",
                    method = method ?: "",
                    params = params,
                    id = id ?: 1
                )
            }
        }
    
        override val descriptor: SerialDescriptor
            get() = buildClassSerialDescriptor("RpcRequestBody") {
                this.element<String>("jsonrpc", isOptional = false)
                this.element<String>("method", isOptional = false)
                this.element<List<Parameter>>("params", isOptional = false)
                this.element<Int>("id", isOptional = false)
            }
    
        override fun serialize(encoder: Encoder, value: RpcRequestBody) {
            encoder.encodeStructure(descriptor) {
                encodeStringElement(descriptor, 0, value.jsonrpc)
                encodeStringElement(descriptor, 1, value.method)
                encodeSerializableElement(descriptor, 2, ListSerializer(ParameterSerialize), value.params)
                encodeIntElement(descriptor, 3, value.id)
            }
        }
    }
    ...
  5. 如果需要deserialize,便需要加上如下选择器
    internal object ParameterSerialize : JsonContentPolymorphicSerializer<Parameter>(Parameter::class) {
        override fun selectDeserializer(element: JsonElement): DeserializationStrategy<out Parameter> {
            // override the condition for your point
            return when(element) {
                is JsonPrimitive -> Parameter.StringParameter.serializer()
                is JsonArray -> Parameter.IntListParameter.serializer()
                is JsonObject -> Parameter.CallParameter.serializer()
            }
        }
    }
    运行单元测试,验证通过:
    Unit Test Result!

最后附上仓库链接:
Kotlinx-Serialization-Example

总结

只是为了在项目中减少gson的使用,所以花了点时间验证下具体情况,但是:
虽然目前看起来是解决了问题,不过相对来说比较麻烦,并且如果类型越来越多也会增加不必要的工作量,所以笔者觉得如果你的项目接口也有类似的情况,gson或许是个不错的选择