You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

131 lines
13 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 06 | 如何实现RPC远程服务调用
专栏上一期我讲过,要完成一次服务调用,首先要解决的问题是服务消费者如何得到服务提供者的地址,其中注册中心扮演了关键角色,服务提供者把自己的地址登记到注册中心,服务消费者就可以查询注册中心得到服务提供者的地址,可以说注册中心犹如海上的一座灯塔,为服务消费者指引了前行的方向。
有了服务提供者的地址后服务消费者就可以向这个地址发起请求了但这时候也产生了一个新的问题。你知道在单体应用时一次服务调用发生在同一台机器上的同一个进程内部也就是说调用发生在本机内部因此也被叫作本地方法调用。在进行服务化拆分之后服务提供者和服务消费者运行在两台不同物理机上的不同进程内它们之间的调用相比于本地方法调用可称之为远程方法调用简称RPCRemote Procedure Call那么RPC调用是如何实现的呢
在介绍RPC调用的原理之前先来想象一下一次电话通话的过程。首先呼叫者A通过查询号码簿找到被呼叫者B的电话号码然后拨打B的电话。B接到来电提示时如果方便接听的话就会接听如果不方便接听的话A就得一直等待。当等待超过一段时间后电话会因超时被挂断这个时候A需要再次拨打电话一直等到B空闲的时候才能接听。
RPC调用的原理与此类似我习惯把服务消费者叫作**客户端**,服务提供者叫作**服务端**两者通常位于网络上两个不同的地址要完成一次RPC调用就必须先建立网络连接。建立连接后双方还必须按照某种约定的协议进行网络通信这个协议就是通信协议。双方能够正常通信后服务端接收到请求时需要以某种方式进行处理处理成功后把请求结果返回给客户端。为了减少传输的数据大小还要对数据进行压缩也就是对数据进行序列化。
上面就是RPC调用的过程由此可见想要完成调用你需要解决四个问题
* 客户端和服务端如何建立网络连接?
* 服务端如何处理请求?
* 数据传输采用什么协议?
* 数据该如何序列化和反序列化?
## 客户端和服务端如何建立网络连接?
根据我的实践经验客户端和服务端之间基于TCP协议建立网络连接最常用的途径有两种。
**1\. HTTP通信**
HTTP通信是基于应用层HTTP协议的而HTTP协议又是基于传输层TCP协议的。一次HTTP通信过程就是发起一次HTTP调用而一次HTTP调用就会建立一个TCP连接经历一次下图所示的“[三次握手](http://condor.depaul.edu/jkristof/technotes/tcp.html)”的过程来建立连接。
![](https://static001.geekbang.org/resource/image/61/bd/61bfb298c82c441681fb88b7519ecebd.jpg)
完成请求后,再经历一次“四次挥手”的过程来断开连接。
![](https://static001.geekbang.org/resource/image/cb/6f/cb614475054bc5895013748c1139a66f.jpg)
**2\. Socket通信**
Socket通信是基于TCP/IP协议的封装建立一次Socket连接至少需要一对套接字其中一个运行于客户端称为ClientSocket 另一个运行于服务器端称为ServerSocket 。就像下图所描述的Socket通信的过程分为四个步骤服务器监听、客户端请求、连接确认、数据传输。
* 服务器监听ServerSocket通过调用bind()函数绑定某个具体端口然后调用listen()函数实时监控网络状态,等待客户端的连接请求。
* 客户端请求ClientSocket调用connect()函数向ServerSocket绑定的地址和端口发起连接请求。
* 服务端连接确认当ServerSocket监听到或者接收到ClientSocket的连接请求时调用accept()函数响应ClientSocket的请求同客户端建立连接。
* 数据传输当ClientSocket和ServerSocket建立连接后ClientSocket调用send()函数ServerSocket调用receive()函数ServerSocket处理完请求后调用send()函数ClientSocket调用receive()函数,就可以得到得到返回结果。
直接理解可能有点抽象你可以把这个过程套入前面我举的“打电话”的例子可以方便你理解Socket通信过程。
![](https://static001.geekbang.org/resource/image/14/94/14362fab592dee5226bb498e3e46e994.jpg)
当客户端和服务端建立网络连接后,就可以发起请求了。但网络不一定总是可靠的,经常会遇到网络闪断、连接超时、服务端宕机等各种异常,通常的处理手段有两种。
* 链路存活检测客户端需要定时地发送心跳检测消息一般是通过ping请求给服务端如果服务端连续n次心跳检测或者超过规定的时间都没有回复消息则认为此时链路已经失效这个时候客户端就需要重新与服务端建立连接。
* 断连重试:通常有多种情况会导致连接断开,比如客户端主动关闭、服务端宕机或者网络故障等。这个时候客户端就需要与服务端重新建立连接,但一般不能立刻完成重连,而是要等待固定的间隔后再发起重连,避免服务端的连接回收不及时,而客户端瞬间重连的请求太多而把服务端的连接数占满。
## 服务端如何处理请求?
假设这时候客户端和服务端已经建立了网络连接,服务端又该如何处理客户端的请求呢?通常来讲,有三种处理方式。
* 同步阻塞方式BIO客户端每发一次请求服务端就生成一个线程去处理。当客户端同时发起的请求很多时服务端需要创建很多的线程去处理每一个请求如果达到了系统最大的线程数瓶颈新来的请求就没法处理了。
* 同步非阻塞方式 (NIO)客户端每发一次请求服务端并不是每次都创建一个新线程来处理而是通过I/O多路复用技术进行处理。就是把多个I/O的阻塞复用到同一个select的阻塞上从而使系统在单线程的情况下可以同时处理多个客户端请求。这种方式的优势是开销小不用为每个请求创建一个线程可以节省系统开销。
* 异步非阻塞方式AIO客户端只需要发起一个I/O操作然后立即返回等I/O操作真正完成以后客户端会得到I/O操作完成的通知此时客户端只需要对数据进行处理就好了不需要进行实际的I/O读写操作因为真正的I/O读取或者写入操作已经由内核完成了。这种方式的优势是客户端无需等待不存在阻塞等待问题。
从前面的描述,可以看出来不同的处理方式适用于不同的业务场景,根据我的经验:
* BIO适用于连接数比较小的业务场景这样的话不至于系统中没有可用线程去处理请求。这种方式写的程序也比较简单直观易于理解。
* NIO适用于连接数比较多并且请求消耗比较轻的业务场景比如聊天服务器。这种方式相比BIO相对来说编程比较复杂。
* AIO适用于连接数比较多而且请求消耗比较重的业务场景比如涉及I/O操作的相册服务器。这种方式相比另外两种编程难度最大程序也不易于理解。
上面两个问题就是“通信框架”要解决的问题你可以基于现有的Socket通信在服务消费者和服务提供者之间建立网络连接然后在服务提供者一侧基于BIO、NIO和AIO三种方式中的任意一种实现服务端请求处理最后再花费一些精力去解决服务消费者和服务提供者之间的网络可靠性问题。这种方式对于Socket网络编程、多线程编程知识都要求比较高感兴趣的话可以尝试自己实现一个通信框架。**但我建议最为稳妥的方式是使用成熟的开源方案**比如Netty、MINA等它们都是经过业界大规模应用后被充分论证是很可靠的方案。
假设客户端和服务端的连接已经建立了服务端也能正确地处理请求了接下来完成一次正常地RPC调用还需要解决两个问题即数据传输采用什么协议以及数据该如何序列化和反序列化。
## 数据传输采用什么协议?
首先来看第一个问题,数据传输采用什么协议?
最常用的有HTTP协议它是一种开放的协议各大网站的服务器和浏览器之间的数据传输大都采用了这种协议。还有一些定制的私有协议比如阿里巴巴开源的Dubbo协议也可以用于服务端和客户端之间的数据传输。无论是开放的还是私有的协议都必须定义一个“契约”以便服务消费者和服务提供者之间能够达成共识。服务消费者按照契约对传输的数据进行编码然后通过网络传输过去服务提供者从网络上接收到数据后按照契约对传输的数据进行解码然后处理请求再把处理后的结果进行编码通过网络传输返回给服务消费者服务消费者再对返回的结果进行解码最终得到服务提供者处理后的返回值。
通常协议契约包括两个部分:消息头和消息体。其中消息头存放的是协议的公共字段以及用户扩展字段,消息体存放的是传输数据的具体内容。
以HTTP协议为例下图展示了一段采用HTTP协议传输的数据响应报文主要分为消息头和消息体两部分其中消息头中存放的是协议的公共字段比如Server代表是服务端服务器类型、Content-Length代表返回数据的长度、Content-Type代表返回数据的类型消息体中存放的是具体的返回结果这里就是一段HTML网页代码。
![](https://static001.geekbang.org/resource/image/e2/b7/e2b3614e1ea94b08b903d00757a3feb7.png)
## 数据该如何序列化和反序列化?
再看第二个问题,数据该如何序列化和反序列化。
一般数据在网络中进行传输前,都要先在发送方一端对数据进行编码,经过网络传输到达另一端后,再对数据进行解码,这个过程就是序列化和反序列化。
为什么要对数据进行序列化和反序列化呢要知道网络传输的耗时一方面取决于网络带宽的大小另一方面取决于数据传输量。要想加快网络传输要么提高带宽要么减小数据传输量而对数据进行编码的主要目的就是减小数据传输量。比如一部高清电影原始大小为30GB如果经过特殊编码格式处理可以减小到3GB同样是100MB/s的网速下载时间可以从300s减小到30s。
常用的序列化方式分为两类文本类如XML/JSON等二进制类如PB/Thrift等而具体采用哪种序列化方式主要取决于三个方面的因素。
* 支持数据结构类型的丰富度。数据结构种类支持的越多越好这样的话对于使用者来说在编程时更加友好有些序列化框架如Hessian 2.0还支持复杂的数据结构比如Map、List等。
* 跨语言支持。序列化方式是否支持跨语言也是一个很重要的因素否则使用的场景就比较局限比如Java序列化只支持Java语言就不能用于跨语言的服务调用了。
* 性能。主要看两点一个是序列化后的压缩比一个是序列化的速度。以常用的PB序列化和JSON序列化协议为例来对比分析PB序列化的压缩比和速度都要比JSON序列化高很多所以对性能和存储空间要求比较高的系统选用PB序列化更合适而JSON序列化虽然性能要差一些但可读性更好更适合对外部提供服务。
## 总结
今天我给你讲解了服务调用需要解决的几个问题,其中你需要掌握:
* **通信框架**。它主要解决客户端和服务端如何建立连接、管理连接以及服务端如何处理请求的问题。
* **通信协议**。它主要解决客户端和服务端采用哪种数据传输协议的问题。
* **序列化和反序列化**。它主要解决客户端和服务端采用哪种数据编解码的问题。
这三个部分就组成了一个完整的RPC调用框架通信框架提供了基础的通信能力通信协议描述了通信契约而序列化和反序列化则用于数据的编/解码。一个通信框架可以适配多种通信协议也可以采用多种序列化和反序列化的格式比如服务化框架Dubbo不仅支持Dubbo协议还支持RMI协议、HTTP协议等而且还支持多种序列化和反序列化格式比如JSON、Hession 2.0以及Java序列化等。
## 思考题
gRPC是一个优秀的跨语言RPC调用框架按照今天我给你讲的服务调用知识通过阅读[官方文档](https://grpc.io/docs/)你能给出gRPC调用的实现原理吗
欢迎你在留言区写下自己的思考,与我一起讨论。