1、RPC 简介
RPC(Remote Procedure Call Protocol)——远程过程调用协议,它是一种通过网络从远程计算机程序上请求服务
,而不需要了解底层网络技术的协议。比如说两台服务器A和B,一个应用部署在A服务器上,想要调用B服务器上应用提供的函数/方法,由于不在一个内存空间,不能直接调用,就需要通过网络来表达调用的语义和传达调用的数据,而这种方式就是 RPC。
RPC 的主要功能目标是让构建分布式计算(应用)更容易,在提供强大的远程调用能力时不损失本地调用的语义简洁性。为实现该目标,RPC 框架需提供一种透明调用机制让使用者不必显式的区分本地调用和远程调用。
2、为什么要用 RPC
其实这是应用开发到一定的阶段的强烈需求驱动的。
(1)如果我们开发简单的单一应用,逻辑简单、用户不多、流量不大,那我们用不着;
(2)当我们的系统访问量增大、业务增多时,我们会发现一台单机运行此系统已经无法承受。此时,我们可以将业务拆分成几个互不关联的应用,分别部署在各自机器上,以划清逻辑并减小压力。此时,我们也可以不需要RPC,因为应用之间是互不关联的。
(3)当我们的业务越来越多、应用也越来越多时,自然的,我们会发现有些功能已经不能简单划分开来或者划分不出来。此时,可以将公共业务逻辑抽离出来,将之组成独立的服务Service应用 。而原有的、新增的应用都可以与那些独立的Service应用 交互,以此来完成完整的业务功能。所以此时,我们急需一种高效的应用程序之间的通讯手段来完成这种需求,所以你看,RPC大显身手的时候来了!其实这描述的场景也就是服务化、微服务和分布式系统架构的基础场景。即RPC框架就是实现以上结构的有力方式。
●
分布式部署及微服务
当我们的系统访问量增大、业务增多时,我们会发现一台单机运行此系统已经无法承受。此时,我们可以将业务拆分成几个互不关联的应用,分别部署在各自机器上,以划清逻辑并减小压力。
●不同技术选型
公司业务规模扩大,有可能引入不同的语言,比如A团队要开发CPU密集型的采用c++语言,B团队要开发IO密集型的采用了PHP,不同语言之间如何通讯。
● 系统高可用性差
因为所有的功能开发最后都部署到同一个框架里,运行在同一个进程之中,一旦某一功能涉及的代码或者资源有问题,那就会影响整个框架中部署的功能。
3、RPC 的调用分类
同步调用:客户方等待调用执行完成并返回结果。
异步调用:客户方调用后不用等待执行结果返回,但依然可以通过回调通知等方式获取返回结果。
异步和同步的区分在于是否等待服务端执行完成并返回结果。
4、RPC 的原理
实现 RPC 的程序包括 5 个部分: User、User-stub、RPCRuntime、Server-stub 和 Server。
这里 user 就是 client 端,当 user 想发起一个远程调用时,它实际是通过本地调用user-stub。user-stub 负责将调用的接口、方法和参数通过约定的协议规范进行编码并通过本地的 RPCRuntime 实例传输到远端的实例。远端 RPCRuntime 实例收到请求后交给 server-stub 进行解码后发起本地端调用,调用结果再返回给 user 端。
粗粒度的 RPC 实现概念结构,这里我们进一步细化它应该由哪些组件构成,如下图所示。
(1)RPC 服务方通过 RpcServer 去导出(export)远程接口方法,而客户方通过 RpcClient 去引入(import)远程接口方法。
(2)客户方像调用本地方法一样去调用远程接口方法,RPC 框架提供接口的代理实现,实际的调用将委托给代理RpcProxy 。代理封装调用信息并将调用转交给RpcInvoker 去实际执行。在客户端的RpcInvoker 通过连接器RpcConnector 去维持与服务端的通道RpcChannel,并使用RpcProtocol 执行协议编码(encode)并将编码后的请求消息通过通道发送给服务方。
(3)RPC 服务端接收器 RpcAcceptor 接收客户端的调用请求,同样使用RpcProtocol 执行协议解码(decode)。解码后的调用信息传递给 RpcProcessor 去控制处理调用过程,最后再委托调用给 RpcInvoker 去实际执行并返回调用结果。
如下是各个部分的详细职责:
● RpcServer :负责导出(export)远程接口
● RpcClient :负责导入(import)远程接口的代理实现
● RpcProxy :远程接口的代理实现
● RpcInvoker :客户方实现负责编码调用信息和发送调用请求到服务方并等待调用结果返回;服务方实现负责调用服务端接口的具体实现并返回调用结果
● RpcProtocol :负责协议编/解码
● RpcConnector:负责维持客户方和服务方的连接通道和发送数据到服务方
● RpcAcceptor :负责接收客户方请求并返回请求结果
● RpcProcessor :负责在服务方控制调用过程,包括管理调用线程池、超时时间等
● RpcChannel :数据传输通道
5、客户端调用远端服务的过程
RPC 采用客户机/服务器模式。请求程序就是一个客户机,而服务提供程序就是一个服务器。首先,客户机调用进程发送一个有进程参数的调用信息到服务进程,然后等待应答信息。在服务器端,进程保持睡眠状态直到调用信息到达为止。当一个调用信息到达,服务器获得进程参数,计算结果,发送答复信息,然后等待下一个调用信息,最后,客户端调用进程接收答复信息,获得进程结果,然后调用执行继续进行。
(1)客户端client发起服务调用请求。
(2)client stub 可以理解成一个代理,会将调用方法、参数按照一定格式进行封装成能够进行网络传输的消息。
(3)client stub 通过服务提供的地址,发起网络请求,消息通过网络传输到服务端。
(4)server stub 接受来自socket的消息 。
(5)server stub 将消息进行解包、告诉服务端调用的哪个服务,参数是什么。
(6)本地服务执行并将结果返回给 server stub。
(7)sever stub 把结果进行打包交给 socket
(8)socket 通过网络传输结果消息
(9)client slub 从socket 拿到结果消息。
(10)client stub 解包结果消息并将其返回给 client。
一个RPC框架就是把步骤 2 到 9 都封装起来。
6、RPC 核心之功能实现
RPC 的核心功能主要由 5 个模块组成,如果想要自己实现一个 RPC,最简单的方式要实现四个技术点,分别是:服务寻址、远程代理对象、数据流的序列化和反序列化和网络通信。
(1)服务寻址
服务寻址可以使用 Call ID 映射。在本地调用中,函数体是直接通过函数指针来指定的,但是在远程调用中,函数指针是不行的,因为两个进程的地址空间是完全不一样的。
所以在 RPC 中,所有的函数都必须有自己的一个 ID。这个 ID 在所有进程中都是唯一确定的。客户端在做远程过程调用时,必须附上这个 ID
。
然后我们还需要在客户端和服务端分别维护一个函数和Call ID的对应表。当客户端需要进行远程调用时,它就查一下这个表,找出相应的 Call ID,然后把它传给服务端,服务端也通过查表,来确定客户端需要调用的函数,然后执行相应函数的代码
。
实现方式:服务注册中心。要调用服务,首先你需要一个服务注册中心去查询对方服务都有哪些实例。
实现案例:RMI(Remote Method Invocation,远程方法调用)也就是 RPC 本身的实现方式。
● Registry(服务发现):借助 JNDI 发布并调用了 RMI 服务。实际上,JNDI 就是一个注册表,服务端将服务对象放入到注册表中,客户端从注册表中获取服务对象。
● RMI 服务在服务端实现之后需要注册到 RMI Server 上,然后客户端从指定的 RMI 地址上 Lookup 服务,调用该服务对应的方法即可完成远程方法调用。
● Registry 是个很重要的功能,当服务端开发完服务之后,要对外暴露,如果没有服务注册,则客户端是无从调用的,即使服务端的服务就在那里。
(2)远程代理对象
服务调用者用的服务实际是远程服务的本地代理。说白了就是通过动态代理来实现。
java 里至少提供了两种技术来提供动态代码生成,一种是 jdk 动态代理,另外一种是字节码生成。动态代理相比字节码生成使用起来更方便,但动态代理方式在性能上是要逊色于直接的字节码生成的,而字节码生成在代码可读性上要差很多。两者权衡起来,个人认为牺牲一些性能来获得代码可读性和可维护性显得更重要。
(3)序列化和反序列化
客户端怎么把参数值传给远程的函数呢?在本地调用中,我们只需要把参数压到栈里,然后让函数自己去栈里读就行。
但是在远程过程调用时,客户端跟服务端是不同的进程,不能通过内存来传递参数。 这时候就需要
客户端把参数先转成一个字节流,传给服务端后,再把字节流转成自己能读取的格式
。
只有二进制数据才能在网络中传输
,序列化和反序列化的定义是: 将对象转换成二进制流的过程叫做序列化 将二进制流转换成对象的过程叫做反序列化。
这个过程叫序列化和反序列化。同理,从服务端返回的值也需要序列化反序列化的过程。
(4)网络通信
网络传输:远程调用往往用在网络上,客户端和服务端是通过网络连接的。
所有的数据都需要通过网络传输,因此就需要有一个网络传输层。网络传输层需要把 Call ID 和序列化后的参数字节流传给服务端,然后再把序列化后的调用结果传回客户端
。只要能完成这两者的,都可以作为传输层使用。因此,
RPC框架与具体的协议无关。RPC 可基于 HTTP 或 TCP 协议
。尽管大部分 RPC 框架都使用 TCP 协议,但其实 UDP 也可以,而 gRPC 干脆就用了 HTTP2。
IO方式:为了支持高并发,传统的阻塞式 IO 显然不太合适,因此我们需要异步的 IO,即 NIO。Java 提供了 NIO 的解决方案,Java 7 也提供了更优秀的 NIO.2 支持。
技术实现
要实现一个 RPC 框架,只需要把以下三点实现了就基本完成了:
(1)
Call ID 映射
:可以直接使用函数字符串,也可以使用整数 ID。映射表一般就是一个哈希表。
(2)序列化反序列化
:可以自己写,也可以使用 Protobuf、Kryo、Hessian、Jackson 或者 FlatBuffers 之类的。
(3)网络通信
:可以自己写 Socket,或者用 Asio,ZeroMQ,Netty 之类。
7、RPC 核心之网络传输协议
要实现一个 RPC,需要选择网络传输的方式。
在 RPC 中可选的网络传输方式有多种,可以选择 TCP 协议、UDP 协议、HTTP 协议。
每一种协议对整体的性能和效率都有不同的影响,如何选择一个正确的网络传输协议呢?首先要搞明白各种传输协议在 RPC 中的工作方式。
(1)基于 TCP 协议的 RPC 调用
由服务的调用方与服务的提供方建立 Socket 连接,并由服务的调用方通过 Socket 将需要调用的接口名称、方法名称和参数序列化后传递给服务的提供方,服务的提供方反序列化后再利用反射调用相关的方法,然后将结果返回给服务的调用方,整个基于 TCP 协议的 RPC 调用大致如此。
但是在实例应用中则会进行一系列的封装,如 RMI 便是在 TCP 协议上传递可序列化的 Java 对象。
(2)基于 HTTP 协议的 RPC 调用
该方法更像是访问网页一样,只是它的返回结果更加单一简单。
其大致流程为:由服务的调用者向服务的提供者发送请求,这种请求的方式可能是 GET、POST、PUT、DELETE 等中的一种,服务的提供者可能会根据不同的请求方式做出不同的处理,或者某个方法只允许某种请求方式。而调用的具体方法则是根据 URL 进行方法调用,而方法所需要的参数可能是对服务调用方传输过去的 XML 数据或者 JSON 数据解析后的结果,然后返回 JOSN 或者 XML 的数据结果。
由于目前有很多开源的 Web 服务器,如 Tomcat,所以其实现起来更加容易,就像做 Web 项目一样。
两种方式对比
基于 TCP 的协议实现的 RPC 调用,由于 TCP 协议处于协议栈的下层,能够更加灵活地对协议字段进行定制,减少网络开销,提高性能,实现更大的吞吐量和并发数。
但是需要更多关注底层复杂的细节,实现的代价更高。同时对不同平台,如安卓,iOS 等,需要重新开发出不同的工具包来进行请求发送和相应解析,工作量大,难以快速响应和满足用户需求。
基于 HTTP 协议实现的 RPC 则可以使用 JSON 和 XML 格式的请求或响应数据。
而 JSON 和 XML 作为通用的格式标准(使用 HTTP 协议也需要序列化和反序列化,不过这不是该协议下关心的内容,成熟的 Web 程序已经做好了序列化内容),开源的解析工具已经相当成熟,在其上进行二次开发会非常便捷和简单。
但是由于 HTTP 协议是上层协议,发送包含同等内容的信息,使用 HTTP 协议传输所占用的字节数会比使用 TCP 协议传输所占用的字节数更高。
因此在同等网络下,通过 HTTP 协议传输相同内容,效率会比基于 TCP 协议的数据效率要低,信息传输所占用的时间也会更长,当然压缩数据,能够缩小这一差距。