服务化基石之远程通信系列六:远程调用

远程调用(RPC) 的全称是 Remote Procedure Call 。它是一种网络间通信方式,允许程序调用共享网络中的另一台服务器中应用的方法或函数,而向应用开发者屏蔽远程调用的相关技术细节。RPC应该尽量做到简单、高效和透明化。客户端应用可以像调用本地对象的方法一样直接调用另一台服务器上服务端应用的对象方法。RPC是分布式服务和应用的基石。

核心概念

比较有影响力的关于RPC论文是一篇完成于1984年的《Implementing Remote Procedure Calls》,论文详见:http://birrell.org/andrew/papers/ImplementingRPC.pdf,文中将RPC的过程抽象为5个概念模型,它们分别是:

1. User,即应用的客户端,是RPC的发起者。它的职责是通过本地调用User-stub发送调用,并负责接收User-stub的返回值。本地调用还是远程调用对于User本身是完全透明的。

2. User-stub,即客户端的存根对象。存根对象的作用是通过使用本地模拟对象的方式,来屏蔽需要通过远程调用才可以获取的对象。User-stub 负责三件事情。第一件事情是将需要远程调用的接口、方法以及参数通过事先约定好的协议进行序列化;第二件事情是通过本地的 RPCRuntime 对象将序列化的数据传输到服务端的RPCRuntime对象;第三件事是将服务端的返回值反序列化为User可以直接使用的对象。

3. RPCRuntime,即远程调用的运行时对象,它同时存在于在客户端和服务端,负责网络间信息的发送与接收。

4. Server-stub,即服务端的存根对象,它负责将由服务端的RPCRuntime对象接收到的数据进行反序列化并调用服务端的本地方法,和将服务端本地方法的返回值序列化后交于服务端的RPCRuntime。

5. Server,即应用的服务端。用于真正的处理相关业务逻辑。

客户端和服务端需要互相知悉相同的业务方法接口。服务端需要将远程接口导出给客户端;同样,客户端需要将该接口导入。这样,客户端才能像调用本地方法一样去调用相同接口的远程方法。

一个RPC的全流程如下图所示:

一个完整的RPC调用,核心点是通信、序列化和透明化调用。通信和序列化正是前两节讲述的内容,开发者可以根据自己的需求定制化的实现各种组合,例如使用Netty + Kryo实现一个RPC框架。

Java远程通信RMI

远程方法调用(RMI)的全称是Remote Method Invocation,它是Java最初用于实现透明远程调用的重要组成部分。它能够让客户端JVM运行的应用像调用本地方法一样调用服务端JVM中的方法。在远程调用时,客户端仅具有服务器端提供的接口即可,它通过客户端的存根(Stub)对象作为远程接口进行远程方法的调用。

核心机制

RMI的运行机制与上文论文中介绍的流程极为相似,只是在其基础上增加了RMI Registry这一概念,下图是RMI的流程图。

应用的服务端需要在启动时向RMI Registry注册服务,应用的客户端通过RMI Registry查找并获取服务对象的Stub。服务对象Stub仅是指向远程对象的引用,并非服务对象本身。在客户端获取到服务对象Stub之后,即可通过其与服务端提供的Skeleton进行交互。

应用开发者仅需要关注开发服务对象以及客户端应用本身,Stub和Skeleton都可由RMI提供的rmic命令自动生成。应用开发本身需要实现RMI定义的Remote接口以及抛出RemoteException异常,因此对应用程序有一定的侵入性。

Java 8发布后,对原有的RMI进行了重新设计。Skeleton的职责已经完全由RMI服务端所实现,因此它已无需存在。Stub也无需再通过rmic命令静态生成,而是通过代动态理将其实现,因此使用起来更加简单,rmic命令也将结束它的历史使命。

开发流程

使用RMI开发应用程序主要经过开发服务接口、实现服务业务逻辑、发布服务和客户端使用服务这4个步骤。

开发服务接口很简单,只需要在接口继承Remote接口以及在需要远程调用的方法签名中抛出RemoteException即可,核心代码示例:

实现服务业务逻辑并无特殊要求,发布方法需要配合RMI提供的Registry一起使用,核心代码示例:

最后,再看一下客户端使用RMI的核心代码示例:

局限性

RMI虽然方便易用,并且在Java的EJB时代大放异彩,但在如今的微服务大行其道的如今,已经很难再获得容身之地。它最主要的问题是性能低、灵活性差以及缺乏对异构语言的支持:

1. 性能低。RMI使用Java远程方法协议(JRMP),该协议实际使用阻塞IO进行远程通信,并且采用Java原生的序列化方案。其性能难与非阻塞IO以及protobuf、kryo等高性能序列化方案相比。由于阻塞IO与Java原生序列化方案均已不适合于当今高性能与高并发的应用场景,因此基于这两种技术组合的RMI也不可能适用。

2. 灵活性差。采用RMI实现远程调用虽然便捷,但它客户端与服务端直接建立连接的方式,没有提供扩展点用于实现分布式系统中多服务副本治理,负载均衡、限流熔断等服务治理相关措施无从施展,不能适应分布式系统对高可用的要求。

3. 无法支持异构语言。RMI要求客户端与服务端两端必须都是Java语言,因此也限制了异构语言共同开发系统的可能。

综上所述,RMI已不适合于现代应用系统的技术选型。

异构语言RPC框架 gRPC

概述

在跨语言的RPC解决方案中,RESTful API是热门的选择。RESTful API大多采用JSON或XML的格式传输信息,虽然绝大多数的编程语言都支持JSON和XML的解析,但需要应用开发者自行选择编码方式和服务器架构。文本格式序列化对性能消耗较高,而搭建一个高性能且容错性强的通信架构也并非易事,因此,RESTful API未必是互联网高并发场景下和合理选择。

gRPC是Google开源的一款语言和平台均中立的高性能的RPC框架,它使用HTTP/2协议进行网络通信,并使用ProtoBuf作为其序列化工具。gRPC支持多种异构语言,提供Java语言、Go语言和C语言这3个版本。其中C语言版本又支持C、C++、C#、Node.js、PHP、Python、Ruby和Objective-C 等多种语言。由于其支持Objective-C和Java这两个在iOS和Android移动客户端的主要开发语言,从而为移动端到服务器端通讯提供了一站式解决方案。

gRPC是面向服务端和移动端设计的RPC框架,它基于 HTTP/2 协议,带来双向流、请求压缩、单连接多路复用等功能,使得其在移动设备上表现更好,能够进一步节省移动端的耗电量和网络流量。

在 gRPC 里,客户端应用可以像调用本地对象一样调用处于另一台不同的机器上服务端应用的方法,使开发者能够更容易地创建分布式应用和服务。相信对于读过前文对于dubbo介绍的内容来说,透明化远程调用这一概念应该并不陌生了。与dubbo框架类似,gRPC 也是基于类似的理念:

1. 定义服务接口,指定其能够被远程调用的方法以及它的参数和返回值。

2. 在服务端实现该接口,并运行一个服务器来监听并处理客户端调用。

3. 在客户端持有一个与服务端一样的方法存根,使客户端与服务端的调用像本地方法调用一样。

下图是gRPC官方主页的架构图,可以明确的看出它的特点是使用跨语言的ProtoBuf协议进行通信传输,且完全透明化远程调用的细节。

使用 gRPC可以在一个 .proto 文件中定义服务的契约,并使用任何支持它的语言去生成客户端和服务器的RPC代码。gRPC解决了不同语言及环境间通信的复杂性和性能问题。

服务类型

gRPC默认使用Protobuf作为其消息序列化协议。使用gPRC也需要预先编写.proto文件来定义服务接口,然后通过该协议文件将其生成为各种开发语言所对应的代码。gRPC推荐使用Protobuf的3.x版本,也就是我们前文介绍过的版本。

gRPC支持四种服务类型,分别是简单PRC、服务端流式RPC、客户端流式RPC和双向流式RPC。要理解这四种服务类型,就需要对前文介绍的HTTP/2协议进行充分的了解。

1. 简单RPC是最简单的RPC调用,即一次请求对应一次应答。客户端发送一次请求给服务端,并从服务端获取一次应答,和一次普通的函数调用一样。

接口定义示例:

2. 服务端流式RPC可以一次请求对应多个响应结果。客户端发送一次请求给服务端,可获取一个数据流用来读取一系列消息,客户端从返回的数据流里一直读取到没有更多消息为止。在定义接口时,只需将返回值增加stream关键字即可。

接口定义示例:

3. 客户端流式RPC可以多次请求对应一个应答结果。客户端提供数据流写入并批量发送消息给服务端,当客户端完成消息写入后,再等待服务端读取消息并应答。在定义接口时,只需将方法参数增加stream关键字即可。

接口定义示例:

4. 双向流式RPC是服务端流式RPC和客户端流式RPC的结合,可以多次请求对应多个应答结果。服务端和客户端都可以分别通过读写数据流批量发送消息,两个数据流相互独立,客户端和服务端都能按照它的期望的顺序完成读写。举例说明,服务端可以在发送应答消息之前等待接收所有的客户端消息;也可以先接收一条消息之后,再应答一条消息,每个数据流里消息的顺序会被保持。在定义接口时,需要将方法参数和返回值都增加stream关键字。

接口定义示例:

通过以上的四种服务类型可以看出,基于HTTP/2协议的gRPC,在交互模型采用请求/响应模式的同时,充分的利用了HTTP/2协议的流式处理特性。

在Java中使用gRPC

与protobuf一脉相承,无论是Java中还是其他编程语言,使用gRPC都需要有根据它的.proto生成相关语言的代码。在Java语言中使用gRPC还算简便,只需在pom.xml中引用gRPC提供的代码生成插件即可。在执行mvn install时,protobuf-maven-plugin会默认从src\main\proto和src\test\proto查找所有的.proto文件并生成代码,因此需要放在pom.xml的build部分。

配置示例如下:

在基础环境搭建完毕之后,即可开始定义服务契约。定义一个简单RPC类型服务的.proto核心代码示例:

我们逐条说明一下代码中值得注意的地方。

1. 与定义protobuf协议相同,指明使用protobuf 3的协议。

2. 将服务接口和消息类型生成多个Java类。如果设置为false,则多个服务接口和消息类型将会采用内部类的方式生成到同一个Java的类中,可读性略差。

3. 指定生成代码的包名称。

4. 定义RPC服务接口,名称为Greeting。

5. 提供一个SayHello的方法。参数的消息类型是HelloRequest,返回的消息类型是HelloResponse。该方法为简单RPC的服务类型,在服务端和客户端都未使用流。

6. 定义HelloRequest参数类型。只有一个name的字符串类型的属性。

7. 定义HelloResponse参数类型。只有一个message的字符串类型的属性。

执行mvn install即可生成编译完成的Java类。根据服务定义文件会生成6个Java类。与protobuf生成的类相比,多了一个GreetingGrpc,它对应的是RPC相关的操作。

接下来就可以根据gRPC生成的代码编写通信的服务端和客户端。

首先是构建服务对象,将获取到的客户端消息结合应用的业务需求实现。

代码核心示例如下:

服务实现类需要继承gRPC生成的服务接口实现类,用于在声明的接口中实现业务逻辑。在实现服务定义文件中定义的sayHello方法时,其方法签名与.proto中声明的有所不同,它们的方法名称是一致的。由于示例使用的是简单RPC,因此入参HelloRequest是一样的,返回值则以第二个参数存在,但是使用的是StreamObserver类型,泛型是方法声明中定义的HelloResponse类型。StreamObserver用于响应传输流中的事件。处理完业务逻辑后,将响应信息送入数据流。由于服务定义的是简单RPC类型,因此onNext方法最多只能调用一次。最后,结束向数据流的消息推送。

实现服务端之后,只需启动服务即可,核心代码示例:

服务端启动服务,需要绑定服务的端口号并且添加服务接口。然后将主线程挂起等待直到服务端进程结束即可。最后可以添加JVM关闭响应事件,让其优雅关闭服务器。

gRPC服务端的启动代码与Netty较为相似。其实gRPC的Java实现版本确实是使用了Netty作为其底层通信框架。

客户端消费服务相对简单,以下是核心代码示例:

在连接至指定的服务器IP地址和端口之后,即可通过管道创建供gRPC使用的远程通信stub。gRPC生成类提供了builder方式构建对象,通过gRPC调用服务端的sayHello方法,与调用本地方法并无区别。

小结

远程调用框架的选择余地非常大,在性能和跨语言这些关注点之外,还有将服务治理与远程调用集中于一体的框架,如dubbo。除此之外,开发者也可以根据自己的需要定制化实现不同的通信协议与序列化协议的搭配。面对种类如此之多的远程调用方案,又该如何选择合适的框架呢?

对于只需要使用Java单一语言开发的应用来说,由于RMI已经不再适用,因此一体化的远程调用框架基本只有dubbo和spring等框架封装的RESTful API这两种选择。Dubbo的远程通信性能极高,并支持多种通信和序列化协议,还包含了服务治理相关能力,下文中会单独列出一个章节介绍。基于Spring的HTTP与RESTful API在性能方面较为逊色,但出色的调试和适配能力是其优势。对于很多需求来说,dubbo有些过于重,而RESTful API又性能不足,因此采用Netty与kryo的通信和序列化组合自行实现通信框架也是不错的选择。

对于跨语言的场景,RESTful API依然是可行选择之一。在与WebService的对抗中,更加轻量级的RESTful API在当今的技术选型中更占据优势。除此之外,gRPC在性能方面更加出色,也在技术选型时较为常见。但它需要代码生成,而且双工的流式通道使用方式也较为复杂,因此应用的成本较高。与gRPC类似的还有apache thrift。其他的选择还包括历史悠久的hessain框架,虽然也是用二进制的序列化协议,但是需要依托于HTTP协议和web服务器,因此性能较差,也不具备RESTful API的明文优势,已逐渐退出历史舞台。

通过下表的各类一体化远程调用框架的直观对比来结束本节的话题。

以上内容节选自

《 Java云原生新一代分布式中间件架构》

内容简介

【互联网架构不断演化,经历了从集中式架构到分布式架构,再到云原生架构的过程。云原生因能解决传统应用升级缓慢、架构臃肿、不能快速迭代等问题而成为未来云端应用的目标。本书首先介绍了架构演化及云原生的概念,让读者对基础概念有一个准确的了解。接着阐述容器调度、服务化、分布式等体系的原理,讲解分布式中间件设计方法。最后辅以实战,以中心化和平台化角度切入,深度揭秘两大开源项目Elastic-Job和Sharding-JDBC的实现】

尽请期待

《Java云原生 新一代分布式中间件架构》

2018年与您见面

书名尚未完全确定,欢迎您宝贵建议。

感谢大家关注“点亮架构”,欢迎对公众号文章的内容批评指正,如果有其他想要了解的技术问题,也可以留言提出。

‘点亮架构’的火炬,燃烧云原生‘

  • 发表于:
  • 原文链接http://kuaibao.qq.com/s/20180211G0DSD300?refer=cp_1026
  • 腾讯「云+社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。

扫码关注云+社区

领取腾讯云代金券