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。其调用逻辑如下图所示:

  1. 服务注册:服务端注册服务绑定到注册中心registry。
  2. 服务查找:客户端根据服务名从注册中心查询要使用的接口获取引用。
  3. 服务调用: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
namespace java me.rowkey.pje.datatrans.rpc.thrift

typedef i32 int
service TestService
{
    int add(1:int n1, 2:int n2),
}

//代码生成
thrift --gen java TestService.thrift

以上即可在gen-java目录下生成TestService的Java代码TestService.java, 其中的核心是接口TestService.Iface,实现此类即可提供服务。需要注意的是Thrift有一个问题就是在接口比较多的时候,生成的Java代码文件太大。

服务提供方:

1
2
3
4
5
6
7
8
9
10
11
TProcessor tprocessor = 
  new TestService.Processor<TestService.Iface>(new TestServiceImpl());

TServerSocket serverTransport = new TServerSocket(8088);
TServer.Args tArgs = new TServer.Args(serverTransport);
tArgs.processor(tprocessor);
tArgs.protocolFactory(new TBinaryProtocol.Factory());

// 简单的单线程服务模型
TServer server = new TSimpleServer(tArgs);
server.serve();

服务消费方:

1
2
3
4
5
6
7
TTransport transport = new TSocket("localhost", 8088, TIMEOUT);
TestService.Client testService = 
  new TestService.Client(new TBinaryProtocol(transport));
transport.open();

int result = testService.add(1,2);
...

这里需要说明的一点就是,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序列化协议有以下几种:

  1. 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();
    
  2. 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();
    
  3. 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);
    
  4. 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();
    
  5. 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修饰来添加才能做到向后兼容。

  6. 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工程师修炼之道》一书。