RPC, Remote Procedure Call,故名思议就是远程过程调用,一般都有跨语言支持。大规模分布式应用中普遍使用RPC来做内部服务、模块之间的数据通信,还有助于解耦服务、系统的垂直拆分,使得系统可扩展性更强,并能够让Java程序员用与开发本地程序一样的语法与方式去开发分布式应用程序。
RPC分为客户端(服务调用方)和服务端(服务提供方),都运行在自己的JVM中。客户端只需要引入要使用的接口,接口的实现和运行都在服务端。RPC主要依赖的技术包括序列化、反序列化和数据传输协议。是一种定义与实现相分离的设计:
目前Java使用比较多的RPC方案主要有RMI、Hessian、Dubbo以及Thrift。
这里需要提出的一点就是,这里的RPC主要指的内部服务之间的调用,因此虽然RESTful也可以用于内部服务间的调用(跨语言、跨网段、跨防火墙),但其主要用途还是为外部系统提供服务,因此本文没有将其包含在内。
RMI
RMI,remote method invoke, 远程方法调用。是JAVA自带的远程方法调用工具,其基于TCP连接,可以使用任意端口,不易跨网段调用,不能穿越防火墙。但它是JAVA语言最开始时的设计,后来很多框架的原理都基于RMI。其调用逻辑如下图所示:
- 服务注册:服务端注册服务绑定到注册中心registry。
- 服务查找:客户端根据服务名从注册中心查询要使用的接口获取引用。
- 服务调用:Stub序列化调用参数并将其发送给Skeleton,后者调用服务方法,并将结果序列化返回给Stub。
其序列化和反序列化使用的都是JDK自带的序列化机制。
这里服务注册管理中心是在服务端的。其实这个可以完全独立出来作为一个单独的服务,其他的RPC框架很多都是选择zookeepr充当此角色。
可以使用Spring那一节讲的RmiServiceExporter和RmiProxyFactoryBean来使用RMI。
Hessian
Hessian是一个基于HTTP协议的RPC方案,其序列化机制是自己实现的,负载均衡和容错需要依赖于Web容器/服务。其体系结构和RMI类似,不过并没有注册中心Registry这一角色,而是通过使用地址来显式调用。其中需要使用HessianProxyFactory根据配置的地址create一个代理对象。使用此代理对象去调用服务。
和RMI一样,可以使用Spring那一节讲的HessianServiceExporter和HessianProxyFactoryBean来使用。
Thrift
Thrift是Facebook开源的RPC框架,现已进入Apache开源项目。其采用接口描述语言(IDL)定义 RPC 接口和数据类型,通过编译器生成不同语言的代码(支持 C++,Java,Python,Ruby等),数据传输采用二进制格式,是自己实现的序列化机制。没有注册中心的概念。
Thrift的使用需要先编写接口的IDL,然后使用它自带的工具生成代码。
1 2 3 4 5 6 7 8 9 10 |
|
以上即可在gen-java目录下生成TestService的Java代码TestService.java, 其中的核心是接口TestService.Iface,实现此类即可提供服务。需要注意的是Thrift有一个问题就是在接口比较多的时候,生成的Java代码文件太大。
服务提供方:
1 2 3 4 5 6 7 8 9 10 11 |
|
服务消费方:
1 2 3 4 5 6 7 |
|
这里需要说明的一点就是,Thrift提供了多种服务器模型、数据传输协议以及传输层供选择:
服务提供者的服务模型除了上面用的TSimpleServer简单单线程服务模型,还有几个常用的模型:
- TThreadPoolServer:线程池服务模型,使用标准的阻塞式IO,预先创建一组线程处理请求。
- TNonblockingServe:非阻塞式IO。
- THsHaServer: 半同步半异步的服务端模型。
数据传输协议除了上面例子使用的BinaryProtocol二进制格式,还有下面几种:
- TCompactProtocol : 压缩格式。
- TJSONProtocol : JSON格式。
- TSimpleJSONProtocol : 提供JSON只写协议, 生成的文件很容易通过脚本语言解析。
传输层除了上面例子的TServerSocket和TSocket,还有
- TFramedTransport:以frame为单位进行传输,非阻塞式服务中使用。
- TFileTransport:以文件形式进行传输。
- THttpClient: 以HTTP协议的形式进行传输。
Dubbo
Dubbo是阿里开源的服务治理框架。与前面讲的几个RPC协议相比,Dubbo不仅仅是一个RPC框架,还包含了服务治理方面的很多功能:
- 服务注册
- 服务自动发现
- 负载均衡
- 集群容错
这里仅仅针对Dubbo的RPC协议来讲,其传输是基于TCP协议的,使用了高性能的NIO框架Netty,序列化可以有多种选择,默认使用Hessian的序列化实现。Dubbo默认使用Zookeeper作为服务注册、管理中心。
一个基于Spring XML配置的使用例子如下:
服务提供者XML配置
<!-- 消费方应用名,用于计算依赖关系,不是匹配条件,不要与提供方一样 --> <dubbo:application name="test_server"/> <!-- 使用zk注册中心暴露服务地址 --> <dubbo:registry address="zookeeper://zk1.dmp.com:2181?backup=zk2.dmp.com:2181,zk3.dmp.com:2181" file="${catalina.base}/logs/eservice/dubbo.cache"/> <dubbo:service path="emailService" interface="me.rowkey.pje.rpc.test.service.IEmailService" ref="emailApiService" />
服务消费者XML配置
<!-- 提供方应用信息,用于计算依赖关系 --> <dubbo:application name="test_consumer"/> <!-- 使用zk注册中心 --> <dubbo:registry address="zookeeper://zk1.dmp.com:2181?backup=zk2.dmp.com:2181,zk3.dmp.com:2181" /> <dubbo:reference id="emailService" interface="me.rowkey.pje.rpc.test.service.IEmailService"/>
在相关bean中注入emailService即可使用。
序列化
序列化是RPC的一个很关键的地方,序列化、反序列的速度、尺寸大小都关系着RPC的性能。包括上面提到的几个序列化协议,现在使用较为普遍的Java序列化协议有以下几种:
Java Serialiazer
JDK自带的序列化机制, 使用起来比较方便。但是其是对象结构到内容的完全描述,包含所有的信息,因此速度较慢,占用空间也比较大,且只支持Java语言。一般不推荐使用。
需要注意的是字段serialVersionUID的作用是为了在序列化时保持版本的兼容性,即在版本升级时反序列化仍保持对象的唯一性。否则如果你在序列化后更改/删除了类的字段,那么再反序列化时就会抛出异常;而如果设置了此字段的值,那么会将不一样的field以type的预设值填充。
//序列化 ByteArrayOutputStream bout = new ByteArrayOutputStream(); ObjectOutputStream out = new ObjectOutputStream(bout); out.writeObject(obj); byte[] bytes = bout.toByteArray(); //反序列化 ObjectInputStream bin = new ObjectInputStream(new ByteArrayInputStream(bytes)); bin.readObject();
Hessian
底层是基于List和Hashmap实现的,着重于数据,附带简单的类型信息的方法,支持多种语言,兼容性比较好, 与JDK序列化相比高效且空间较小;但其在序列化的类有父类的时候,如果有字段相同,父类的值会覆盖子类的值,因此使用Hessian时一定要注意子类和父类不能有同名字段。
需要注意的一点,Hessian的实现里有v1和v2两种版本的协议支持,并不兼容,推荐使用Hessian2相关的类。
与后来出现的其他二进制序列化工具相比,其速度和空间都不是优势。
//序列化 ByteArrayOutputStream os = new ByteArrayOutputStream(); Hessian2Output out = new Hessian2Output(os); out.startMessage(); TestUser user = new TestUser(); out.writeObject(user); out.completeMessage(); out.flush(); byte[] bytes = os.toByteArray(); out.close(); os.close(); //反序列化 ByteArrayInputStream ins = new ByteArrayInputStream(bytes); Hessian2Input input = new Hessian2Input(ins); input.startMessage(); TestUser newUser = (TestUser)input.readObject(); input.completeMessage(); input.close(); ins.close();
MsgPack
MsgPack是一个非常高效的对象序列化库,支持多种语言,有点像JSON,但是非常快,且占用空间也较小,号称比Protobuf还要快4倍。
使用MsgPack需要在序列化的类上加@Message注解;为了保证序列化向后兼容,新增加的属性需要加在类的最后面,且要加@Optional注解,否则反序列化会报错。
此外,MsgPack提供了动态类型的功能,通过接口Value来实现动态类型,首先将字节数组序列化为Value类型的对象,然后用converter转化为本身的类型。
MsgPack不足的一点就是其序列化和反序列都非常消耗资源。
//TestUser.java @Message public class TestUser{ private String name; private String mobile; ... } TestUser user = new TestUser(); MessagePack messagePack = new MessagePack(); //序列化 byte[] bs = messagePack.write(user); //反序列化 user = messagePack.read(bs, TestUser.class);
Kryo
Kryo是一个快速高效的Java对象图形序列化框架,使用简单、速度快、序列化后体积小。实现代码非常简单,远远小于MsgPack。但其文档较少,跨语言支持也较差,适用于Java语言。目前Kryo的版本到了4.x, 对于之前2.X之前版本的很多问题都做了修复。
Kryo kryo = new Kryo(); // 序列化 ByteArrayOutputStream os = new ByteArrayOutputStream(); Output output = new Output(os); TestUser user = new TestUser(); kryo.writeObject(output, user); output.close(); byte[] bytes = os.toByteArray(); // 反序列化 Input input = new Input(new ByteArrayInputStream(bytes)); TestUser newUser = kryo.readObject(input, TestUser.class); input.close();
Thrift
上面讲的Thrift RPC框架其内部的序列化机制可以单独使用,主要是对TBinaryProtocol的使用。和接口的生成方式类似,需要先定义IDL,再使用Thrift生成。其序列化性能比较高,空间占用也比较少。但其设计目标并非是单独做为序列化框架使用的,一般都是整体作为RPC框架使用的。
定义IDL:
//TestUser.thrift namespace java me.rowkey.pje.datatrans.rpc.thrift struct TestUser { 1: required string name 2: required string mobile } thrift --gen java TestUser.thrift
使用生成的TestUser类做序列化和反序列化:
TestUser user = new TestUser(); //由thrift代码生成引擎生成 //序列化 ByteArrayOutputStream bos = new ByteArrayOutputStream(); user.write(new TBinaryProtocol(new TIOStreamTransport(bos))); byte[] result = bos.toByteArray(); bos.close(); //反序列化 ByteArrayInputStream bis = new ByteArrayInputStream(result); TestUser user = new TestUser(); user.read(new TBinaryProtocol(new TIOStreamTransport(bis))); bis.close();
需要注意的是由于Thrift序列化时,丢弃了部分信息,使用ID+Type来做标识,因此对新增的字段属性, 采用ID递增的方式标识并以Optional修饰来添加才能做到向后兼容。
Protobuf
Protobuf是Google开源的序列化框架,是Google公司内部的混合语言数据标准,用于RPC系统和持续数据存储系统,非常轻便高效,具有很好的可扩展性、也具有良好的向后兼容和向前兼容性。与上述的几种序列化框架对比,序列化数据紧凑、速度快、空间占用少、资源消耗较低、使用简单,但其缺点在于需要静态编译生成代码、可读性差、缺乏自描述、向后兼容有一定的约束限制。
这里需要注意目前ProtoBuf的版本到了3.x,比2.x支持更多语言但更简洁。去掉了一些复杂的语法和特性,更强调约定而弱化语法。因此,如果是首次使用就直接使用3.x版本。这里也针对Protobuf 3来讲。
首先需要编写.proto文件,并使用Protobuf代码生成引擎生成Java代码。
//TestUser.proto syntax = "proto3"; option java_package = "me.rowkey.pje.datatrans.rpc.proto"; option java_outer_classname = "TestUserProto"; message TestUser { string name=1; string mobile=2; } protoc --java_out=./ TestUser.proto
即生成TestUserProto.java,使用此类,即可完成序列化和反序列化:
//序列化 TestUserProto.TestUser testUser = TestUserProto.TestUser.newBuilder() .setMobile("xxx") .setName("xxx") .build(); byte[] bytes = testUser.toByteArray(); //反序列化 testUser = TestUserProto.TestUser.parseFrom(bytes);
综上,对以上几个序列化框架做对比如下:
| 优点 | 缺点 —-|—–|—— Java | JDK自带实现,包含对象的所有信息| 速度较慢,占用空间也比较大,只支持Java语言 Hessian | 支持语言比较多,兼容性较好 | 较慢 MsgPack | 使用简单,速度快,体积小| 兼容性较差,耗资源 Kryo | 速度快,序列化后体积小 | 跨语言支持较差,文档较少 Thrift | 高效 | 需要静态编译;是Thrift内部序列化机制,很难和其他传输层协议共同使用 Protobuf | 速度快 | 需要静态编译
在兼顾使用简单、速度快、体积小且主要使用在Java开发的场景下,Kryo是比较好的方案;如果特别要求占用空间、性能,那么Protobuf则是更好的选择。此外,JSON其实也是一种序列化方式,如果比较关注阅读性的话,那么JSON是更好的选择。
提示
面对这些RPC框架,选择的时候应该从以下几方面进行考虑:
- 是否允许代码侵入:即是否需要依赖相应的代码生成器生成代码,比如Thrift需要,而Dubbo、Hessian就不需要。
- 是否需要长连接、二进制序列化获取高性能:如果需要性能比较高,那么果断选取基于TCP的Thrift、Dubbo。
- 是否需要跨网段、跨防火墙:这种情况一般就需要选择基于Http协议的,Hessian和Thrift的HTTP Transport。
- 是否需要跨语言调用:Thrift、Hessian对于语言的支持是比较丰富的,而Dubbo目前只支持Java语言。
此外,除了上述框架之外,Google推出的基于HTTP 2.0的gRPC框架也开始得到了应用,其序列化协议基于Protobuf, 网络框架使用了Netty4。但其需要生成代码,可扩展性也比较差。
本文节选自《Java工程师修炼之道》一书。