dubbo
rpc
接口兼容升级
hessian2
序列化
任何使用微服务架构的团队,作为接口提供方若对扩展性没有考虑周全的话,后续的升级和打补丁绝对是一件让人头疼的事情。要不新增接口与原接口物理隔离,但这会造成接口数量的迅速膨胀和维护困难;要不小心翼翼的修改原接口,尽量考虑兼容到所有依赖系统,而这很容易进入顾此失彼/百密一疏的境地。
本文作者的团队是基于Dubbo微服务架构,所以很多说明和示例都与此有关,我会尽量表述清楚,不妨碍大家理解和阅读。
#兼容性升级常见问题
1. 参数本身或内部成员变量的【类型****修改】
这里的参数包括入参出参, 下文不再特别说明。
既然是兼容升级,修改类定义指的通常是修改为父类。比如MailForm修改为父类BaseForm甚至Object,用来处理类型边界更广的数据。问题随之而来,边界放大了可以收入或者释放更多类型的数据,但是逻辑复杂度也不可避免的升高,稍有不慎万劫不复。此类修改强烈不建议。
2. 参数内部【增删成员变量】
新增应该是最经常发生的。很多时候我们没办法预估到未来的某个需求时,比如要新增一个属性让调用方传递过来,可能是必填也可能是非必填,如果是没有封装为对象只是在入参内一一罗列属性变量,这种低级的设计误区我们就不提了;所以一般是封装为对象作为入参,而这时我们就不得不在对象内新增一个成员变量。
删除一个属性字段的情况很少发生,这里说的场景是:服务方新加了一个属性字段,并提供了一个新的jar包,然后调用方拿着新jar在生产环境发布,而服务方却因为某些原因没有上线,这就出现了好像服务方“删除”了一个字段一样。
3. 修改参数对象的【数据结构】
可能有人会说修改数据结构和兼容性不可能同时出现,这个不能绝对,因为编程中接口定义作为双方约定好的承诺,被单方面破坏的事情偶有发生。比如服务方提供一个接收 Map 类型的方法,如果没约定好具体的实现,调用方使用HashMap、LinkedHashMap、TreeMap的可能性都存在,如果不小心直接使用了TreeMap.firstKey(),只有妥妥的异常。
4. 使用Enum 参数
服务方为了调用方的方便不少人喜欢将参数定义成枚举,殊不知这在RPC接口定义中是一大忌,因为一旦新增枚举值就绝对会导致一片血雨腥风,后文会从序列化角度来说明为何不可。友情提醒,还是老老实实用常量类来实现比较稳妥。
5. 简单总个结
上文主要总结了一些日常会碰到的问题,不少人可能也曾深受其害。接口升级务必充分测试,万不可想当然。举个实际的案例,某个接口的出参是一个Json字符串,有次升级给Json新增了一个Key值,这个Key值很多场景下无关痛痒,只有很特殊的场景才会用这个值。服务方的想法很简单,就算是没有升级的老的调用方解析出这个Key,他的逻辑里也不会对其进行任何操作,理论上没有任何问题。而最后的结果很惊悚,它会导致某些版本的客户端App闪退!
无法免俗的还是不得不提下RPC微服务接口设计和升级的几个要素和建议:
a. 接口或者类最好预定义好版本号,可以基于配置(比如dubbo xml),也可以基于类/方法命名。
b. 不能保证万无一失的升级,就尽量新增接口而不是修改,也就是常说的开闭原则。
c. 接口涉及的对象一定要拿捏好边界。不能暴露非必要的属性字段,造成后期的维护升级难度提高;也不能将属性字段定义的太死,不怎么修改的相对稳定的字段可以用确定的类型,而有很大可能性变动的字段建议用模糊的类型定义,比如Map、List等集合类。
d. 用静态常量代替 Enum 类型。
e. 用相对扁平的数据结构,不要使用嵌套过多的集合类型等。
f. 其他...
【Incompatible Changes & Compatible Changes】
https://docs.oracle.com/javase/8/docs/platform/serialization/spec/version.html#a5172
#serialVersionUID
RPC调用本质无非就是数据的传递,而数据在RPC场景下无非就是序列化的二进制对象,然后成功调用的基本前提就是能够在 Remote 端反序列化恢复成原本的对象。所以本文我们就把接口升级这件事转换到如何保证序列化&反序列化成功率的角度。
先简单把序列化相关基础知识准备一下。谈到序列化大家脑子里先蹦出来的应该是 Serializable 这个Marker标记型接口,和该接口的实现类( IDE通常会提示让你生成 )的一个变量值:
private static final long **serialVersionUID** = 1L;
【4.6 Stream Unique Identifiers】
https://docs.oracle.com/javase/7/docs/platform/serialization/spec/class.html#4100
这个值的算法简单说就是对类名,接口名,方法和属性的名称、修饰符以及描述符的64位哈希值。动态代理类和枚举类的该值都是 0L。特别说明:static/transient类型成员变量、私有方法都不参与Hash值计算。
它也是JDK官方定义的所有序列化的类必须设定的,不过如果代码里没有显性设置,也不用担心,JVM会使用相同的算法帮你生成一个。
一个类能序列化的前提是它内部所引用的所有对象也必须可以被序列化,这点是很容易被忽视的。
有个不怎么常用的 Externalizable,不知道多少人用过,它继承自 Serializable,是个历史遗留接口。有兴趣的可以自己了解下。
对JDK序列化方式本文不会再展开,主流的RPC调用框架一般都不会选择JDK序列化方式,因为它的性能相比hessian2, kryo, FST, protstuff, thrift等等基本没有任何优势。
#Dubbo hessian2 序列化测试
Dubbo框架缺省的dubbo协议默认序列化方式就是Hessian2, 话不多说,直接上针对Hessian2的测试代码,然后出结论。代码设计简述如下:
使用两个完全不同的Project,保证同一个package对象做修改不会互相影响。(一开始考虑过用自定义的Classloader来隔离和模拟调用方和服务方两个同名不同内容的对象,发现复杂度有点高,放弃)
不需要真正的发起RPC调用,双方使用本地的二进制文件简单模拟传输通道。
两个工程:
Project-Provider
Project-Consumer
分别实现四个类:
HessianUtil(Hessian工具类)
SerialBean(序列化的Bean)
RequestEnum(枚举类)
Main(Main测试主入口)
测试案例
代码准备完毕,开始我们的测试案例,下图是笔者的一些用例和测试结果:
图中“绿色对勾”图标表示可以成功序列化和反序列化,“红色叉”图标表示不能完全或者部分行不通(比如存在的枚举值行得通)
大家有兴趣可以自己去验证更多的案例,这里直接总结结论:
所有RPC交换对象必须实现Serializable接口;
serialVersionUID 对hessian2无任何影响;
构造方法不管是否私有,对hessian2无任何影响;
类型定义除了比较容易理解的修改成父类没有影响之外,类似Long to Boolean也可以成功运行相信很多人没想到,虽然行得通,但是代码逻辑已经失控,肯定是不会建议大家这么做;
枚举类中增删新的枚举值,对于双方存在的枚举值不会报错,但是一方不存在的就会反序列化异常;
其他多层嵌套和集合类的测试交给读者自测吧,笔者就不展开了哈哈哈
Dubbo Http协议的坑
架构组老钱前端时间把Mail基础组件从1.0升级成2.0的时候,分享了一个坑。简单来说就是原有MailForm没有显性的设置serialVersionUID, 后来升级新增了字段导致serialVersionUID的计算结果产生变化,一些依赖Mail1.0的调用方会报错,最后不得不显性的设置serialVersionUID并保持不变,这样老的调用方就不会抛异常了。
后来笔者通过上文的各种测试发现这种情况与结论相悖,因为hessian2压根就无视serialVersionUID的存在,到底是怎么发生的呢?苦思无果下,突然想到Mail1.0版本提供的微服务采用的是http协议不是默认的dubbo协议,会不会http协议采用的默认序列化方式不是hessian2呢?看图!
上图是Debug过程中的一张截图,大家可以看89行,对象obj是通过ois.readObject() 反序列化出来的。再看ois这个流对象CodebaseAwareObjectInputStream压根就不是Hessian2体系内的类,而是Spring框架里继承自java.io.ObjectInputStream的一个流对象。到这里就说得通了,因为JDK自带的序列化机制确实会严格比对serianVersionUID是否一致,出现上面的异常也就不足为怪了。
Hessian2 如何处理不存在的类
再送一个知识点,hessian2反序列化的类如果在当前jvm里或者classloader里不存在,会出现什么场景?
上图是SerializerFactory用来根据反序列化的类型找反序列化器的源码,很清晰的看到是MapDeserializer,它会把不存在的对象按照成员变量的名称反序列化成一个HashMap结构。
这个问题是笔者在使用自定义classloader模拟测试的时候发现的,一并分享出来。
END