概述
在单体应用时,一次服务调用发生在同一台机器上的同一个进程内部,也就是说调用发生在本机内部,因此也被叫作本地方法调用。在进行服务化拆分之后,服务提供者和服务消费者运行在两台不同物理机上的不同进程内,它们之间的调用相比于本地方法调用,可称之为远程方法调用,简称RPC(Remote Procedure Call)。
将服务消费者叫作客户端,服务提供者叫作服务端,两者通常位于网络上两个不同的地址,要完成一次RPC调用,就必须先建立网络连接。建立连接后,双方还必须按照某种约定的协议进行网络通信,这个协议就是通信协议。双方能够正常通信后,服务端接收到请求时,需要以某种方式进行处理,处理成功后,把请求结果返回给客户端。为了减少传输的数据大小,还要对数据进行压缩,也就是对数据进行序列化。
上面就是RPC调用的过程,由此可见,想要完成调用,你需要解决四个问题:
- 客户端和服务端如何建立网络连接?
- 服务端如何处理请求?
- 数据传输采用什么协议?
-
通信协议
HTTP
HTTP通信是基于应用层HTTP协议的,而HTTP协议又是基于传输层TCP协议的。一次HTTP通信过程就是发起一次HTTP调用,而一次HTTP调用就会建立一个TCP连接,经历一次下图所示的“三次握手”的过程来建立连接。
完成请求后,再经历一次“四次挥手”的过程来断开连接。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通信过程。
当客户端和服务端建立网络连接后,就可以发起请求了。但网络不一定总是可靠的,经常会遇到网络闪断、连接超时、服务端宕机等各种异常,通常的处理手段有两种。
- 链路存活检测:客户端需要定时地发送心跳检测消息(一般是通过ping请求)给服务端,如果服务端连续n次心跳检测或者超过规定的时间都没有回复消息,则认为此时链路已经失效,这个时候客户端就需要重新与服务端建立连接。
- 断连重试:通常有多种情况会导致连接断开,比如客户端主动关闭、服务端宕机或者网络故障等。这个时候客户端就需要与服务端重新建立连接,但一般不能立刻完成重连,而是要等待固定的间隔后再发起重连,避免服务端的连接回收不及时,而客户端瞬间重连的请求太多而把服务端的连接数占满。
HTTP协议
各大网站的服务器和浏览器之间的数据传输大都采用了HTTP协议。HTTP协议包括两个部分:消息头和消息体。其中消息头存放的是协议的公共字段以及用户扩展字段,消息体存放的是传输数据的具体内容。
下图展示了一段采用HTTP协议传输的数据响应报文,其中消息头中存放的是协议的公共字段,Server代表是服务端服务器类型、Content-Length代表返回数据的长度、Content-Type代表返回数据的类型;消息体中存放的是具体的返回结果,这里就是一段HTML网页代码。
序列化&反序列化
网络传输的耗时一方面取决于网络带宽的大小,另一方面取决于数据传输量。常用的序列化方式分为两类:文本类如XML/JSON等,二进制类如PB/Thrift等,而具体采用哪种序列化方式,主要取决于三个方面的因素。
- 支持数据结构类型的丰富度。数据结构种类支持的越多越好,这样的话对于使用者来说在编程时更加友好,有些序列化框架如Hessian 2.0还支持复杂的数据结构比如Map、List等。
- 跨语言支持。序列化方式是否支持跨语言也是一个很重要的因素,否则使用的场景就比较局限,比如Java序列化只支持Java语言,就不能用于跨语言的服务调用了。
- 性能。主要看两点,一个是序列化后的压缩比,一个是序列化的速度。以常用的PB序列化和JSON序列化协议为例来对比分析,PB序列化的压缩比和速度都要比JSON序列化高很多,所以对性能和存储空间要求比较高的系统选用PB序列化更合适;而JSON序列化虽然性能要差一些,但可读性更好,更适合对外部提供服务。
RPC框架
- rpcx: 基于Go的服务治理的rpc框架、客户端支持跨语言
- grpc: Google 出品的跨语言rpc框架,很弱的(实验性的)负载均衡, 测试使用的是grpc-go
- go std rpc: Go标准库的rpc, 不支持跨语言(jsonrpc支持json rpc 1.0)
- thrift: 跨语言的rpc框架,facebook贡献
- dubbo: 国内较早开源的服务治理的Java rpc框架,虽然在阿里巴巴内部竞争中落败于HSF,沉寂了几年,但是在国内得到了广泛的应用,目前dubbo项目又获得了支持,并且dubbo 3.0也开始开发
- motan: 微博内部使用的rpc框架,底层支持java,生态圈往service mesh发展以支持多语言
- hprose: 国内开发人员开发的一个跨语言的rpc框架,非服务治理但是性能高效
twirp: twitch.tv刚刚开源的一个restful风格的rpc框架
Dubbo
Dubbo是国内开源最早的RPC框架,目前只支持Java语言,它的架构可以用下面这张图展示。
(图片来源:https://dubbo.incubator.apache.org/docs/zh-cn/dev/sources/images/dubbo-relation.jpg)
从图中你能看到,Dubbo的架构主要包含四个角色,其中Consumer是服务消费者,Provider是服务提供者,Registry是注册中心,Monitor是监控系统。
具体的交互流程是Consumer一端通过注册中心获取到Provider节点后,通过Dubbo的客户端SDK与Provider建立连接,并发起调用。Provider一端通过Dubbo的服务端SDK接收到Consumer的请求,处理后再把结果返回给Consumer。
可以看出服务消费者和服务提供者都需要引入Dubbo的SDK才来完成RPC调用,因为Dubbo本身是采用Java语言实现的,所以要求服务消费者和服务提供者也都必须采用Java语言实现才可以应用。
我们再来看下Dubbo的调用框架是如何实现的。通信框架方面,Dubbo默认采用了Netty作为通信框架。
- 通信协议方面,Dubbo除了支持私有的Dubbo协议外,还支持RMI协议、Hession协议、HTTP协议、Thrift协议等。
- 序列化格式方面,Dubbo支持多种序列化格式,比如Dubbo、Hession、JSON、Kryo、FST等。
gRPC
先来看下gRPC,它的原理是通过IDL(Interface Definition Language)文件定义服务接口的参数和返回值类型,然后通过代码生成程序生成服务端和客户端的具体实现代码,这样在gRPC里,客户端应用可以像调用本地对象一样调用另一台服务器上对应的方法。
(图片来源:https://grpc.io/img/landing-2.svg)
它的主要特性包括三个方面。
- 通信协议采用了HTTP/2,因为HTTP/2提供了连接复用、双向流、服务器推送、请求优先级、首部压缩等机制,所以在通信过程中可以节省带宽、降低TCP连接次数、节省CPU,尤其对于移动端应用来说,可以帮助延长电池寿命。
- IDL使用了ProtoBuf,ProtoBuf是由Google开发的一种数据序列化协议,它的压缩和传输效率极高,语法也简单,所以被广泛应用在数据存储和通信协议上。
多语言支持,能够基于多种语言自动生成对应语言的客户端和服务端的代码。
Thrift
Thrift是一种轻量级的跨语言RPC通信方案,支持多达25种编程语言。为了支持多种语言,跟gRPC一样,Thrift也有一套自己的接口定义语言IDL,可以通过代码生成器,生成各种编程语言的Client端和Server端的SDK代码,这样就保证了不同语言之间可以相互通信。它的架构图可以用下图来描述。
(图片来源:https://github.com/apache/thrift/raw/master/doc/images/thrift-layers.png)
从这张图上可以看出Thrift RPC框架的特性。支持多种序列化格式:如Binary、Compact、JSON、Multiplexed等。
- 支持多种通信方式:如Socket、Framed、File、Memory、zlib等。
- 服务端支持多种处理方式:如Simple 、Thread Pool、Non-Blocking等。