说起RPC,博主使用CPP手搓了一个RPC项目,RPC简单来说,就是远程过程调用:我们一般在本地传入数据进行执行函数,然后返回一个结果;当我们使用RPC之后,我们可以将函数的执行过程放到另外一个服务器上,而不是在本地创建一个函数栈帧,将函数执行所需要的一些东西压入到栈中。下面,我们来学习RPC!!!
一、远程过程调用带来的问题
在远程调用时,我们需要执行的函数体是在远程的机器上,也就是说,add是在另一个进程中执行的。这就带来了几个问题:
1.1 Caller ID 映射
我们怎么告诉远程机器我们要调用add,而不是sub或者 Foo呢??在本地调用中,直接通过函数指针来指定的,我们调用add,编译器就自动帮我们调用它相应的函数指针。但是在远程调用中,指针是不行的,因为两个进程的地址空间是完全不一样的。所以,在RPC中,所有的函数都必须有自己的一个ID。
这个ID在所有进程中都是唯一确定的。客户端在做远程调用时,必须附上这个ID。然后我们还需要在客户端和服务端中分别维护一个 【函数 <----> Call ID】的对应表。两者的表不一定需要完全相同,但是相同的函数对应的 Call ID 必须相同。当客户端需要进行远程调用时,他就查一下这个表,找出相应的 Call ID ,然后将他传入到服务端,服务端也通过查表来确定客户端需要调用的函数,然后执行相应函数的代码。
1.2 序列化和反序列化
客户端怎么把参数值传给远程的函数呢??在本地调用中,我们只需要把参数压入到栈中,然后让函数自己去栈萝莉读就行。但是在远程过程调用时,客户端根服务端是不同的进程,不能通过内存来传递参数。甚至有时候客户端和服务端使用的都不是同一种语言(比如服务端用C++,客户端使用 Java 或者 python)。这时候就需要客户端把参数先转成一个字节流,传给服务端后,再把字节流转成自己能读取的格式。这个过程叫做序列化和反序列化。同理,从服务端返回的值夜需要通过序列化和反序列化的过程。
1.3 网络传输
远程调用往往用在网络中,客户端和服务端是通过网络连接的。所有的数据都需要通过网络传输,因此就需要有一个网络传输层。网络传输层需要把 Call ID 和序列化后的参数字节流传给服务端,然后再把序列化后的调用结果传回给客户端。只要能完成这两者的,都可以用作为传输层使用。因此,他所使用的协议其实是不限的,只要可以完成传输就行了。尽管大部分RPC框架都使用TCP协议,但是其实UDP也是可以的,而gRPC干脆就使用HTTP2。
二、上述三个机制的解决方法
当我们解决了上述的三个问题,就可以实现RPC了,具体过程如下:
2.1 client 端可以解决的问题
- 将这个调用映射为 Call ID ,这里假设使用最简单的字符串当 Call ID 的方法
- 将 Call ID ,a 和 b序列化。可以直接将他们的值以二进制形式打包
- 将2中得到的数据包发送给 ServerAddr,这就需要使用网络传输层
- 等待服务器调用成功,那么就将结果反序列化,并赋值给total
2.2 server 端可以解决的问题
- 在本地维护一个 Call ID 到函数指针的映射 call_id_map,我们可以使用dict来完成
- 等待请求,包括多线程的并发处理能力
- 得到一个请求后,将其数据包反序列化,得到相应的函数指针
- 将 a 和 rb 反序列化后,在本地调用 add 函数,得到结果
- 将结果序列化后通过网络返回给 Client
在上面的整个流程中,其中:
- Call ID 映射可以直接使用函数字符串,也可以使用整数ID。映射表一般就是一个哈希表
- 序列化和反序列化可以自己写,也可以使用 protobuf 或者 FlatBuffers 之类的
- 网络传输库可以自己写 socket ,或者使用 asio,ZeroMQ、Netty 之类的
实际上真正的开发过程中,除了上面的基本功能之外,还需要更多的细节:网络错误,流量控制,超时和重传。
三、最后,如何将远程的这些过程写出本地函数调用的感觉??
下面,我们可以使用 go 语言来进行实现这个问题,因为在 go 语言中提供的包还是很全的。
我们分为服务端和客户端来进行编写代码:我们先来看一看服务端的代码:
package mainimport ("encoding/json""fmt""net/http""strconv"
)func main() {// http// 返回的格式化为:json{ "data"=3 }// 1. callID 的问题 r.URL.Path 2. 数据的传输协议 url 的参数传递协议 3. 网络传输协议 httphttp.HandleFunc("/add", func(w http.ResponseWriter, r *http.Request) {_ = r.ParseForm() // 解析参数fmt.Println("Path: ", r.URL.Path)a, _ := strconv.Atoi(r.Form["a"][0])b, _ := strconv.Atoi(r.Form["b"][0])w.Header().Set("Content-Type", "application/json; charset=utf-8")jData, _ := json.Marshal(map[string]interface{}{"data": a + b,})_, _ = w.Write(jData)})_ = http.ListenAndServe(":8080", nil)
}
客户端中的代码如下:
package mainimport ("encoding/json""fmt""github.com/kirinlabs/HttpRequest"
)type ResponseData struct {Data int `json:"data"`
}func add(a, b int) int {// 传输协议req := HttpRequest.NewRequest() // 创建一个requestres, _ := req.Get(fmt.Sprintf("http://127.0.0.1:8080/%s?a=%d&b=%d", "add", a, b))body, _ := res.Body()rspData := ResponseData{}_ = json.Unmarshal(body, &rspData)return rspData.Data
}func main() {// 实现 rpcadd(1, 2)
}
四、RPC开发的要素分析
4.1 RPC开发的四大要素
RPC技术在架构设计上有四个部分组成,分别是:客户端,客户端存根,服务端。服务端存根。下面,我们来一一介绍:
- 客户端:服务调用发起方,也称为服务消费者
- 客户端存根:该程序运行在客户端所在的计算机机器上,主要用来存储要调用的服务器的地址,另外,该程序还负责将客户端请求远端服务器程序的数据信息打包成数据包,通过网络发送给服务端Stub程序,其次,还要接受服务器Stub程序发送的调用结果数据包,并解析返回给客户端。
- 服务端:远端的计算机机器上运行的程序,其中有客户端要调用的方法
- 服务端存根:接收客户Stub程序通过网络发送的请求消息数据包,并调用服务端中真正的程序功能方法,完成功能调用,其次,将服务端执行调用的结果进行数据处理打包发送给客户端Stub程序。
了解完了RPC技术的组成结构,我们来看一下具体是如何实现客户端到服务端的调用的。实际上,如果我们想要在网络中的任意两台计算机上实现进程调用进程,需要解决很多问题,比如:
- 两台物理机器在网络中要建立稳定可靠的通信连接
- 两台服务器的通信协议的定义问题,即两台服务器上的程序如何识别对方的请求和返回结果。也就是说两台计算机必须能够识别对方发来的信息,并且能够识别出其中的请求含义和返回含义,然后才能进行处理,这其实就是通信协议要完成的工作。
在上图中,通过10步完成了RPC,下面我们来具体描述一下RPC每一步的调用过程:
- 客户端想要发起一个远程过程调用,首先要通过调用本地客户端Stub程序的方式调用想要使用的功能方法名;
- 客户端Stub程序接收到了客户端的功能调用请求,将客户端请求调用的方法名,携带的参数等信息做序列化,操作,并打包成数据包;
- 客户端Stub查找到远程服务器程序的IP地址,调用Socket通信协议,通过网络发送给服务端
- 服务端Stub程序接收到客户端发送的数据包信息,并通过约定好的协议将数据进行反序列化,得到请求的方法名和请求参数等信息
- 服务端Stub程序准备相关数据,调用本地的Server对应的功能方法进行,并存入相应的参数,进行业务处理
- 服务端程序根据已有的业务逻辑执行调用过程,等到业务执行结束之后,将执行结果返回给服务端Stub程序
- 服务端Stub程序将程序调用结果按照约定的协议进行序列化,并通过网络发送回给客户端Stub程序
- 客户端Stub接收到服务端Server发送返回的数据,对数据进行反序列化的操作,并将调用返回的数据传递给客户端请求发送者
- 客户端请求发起者得到调用结果,整个RPC调用过程结束
五、RPC需要使用到的术语
通过上文一系列的文字描述和讲解,我们已经了解了RPC的由来和整个RPC调用过程。我们可以看到RPC是一系列操作的集合,其中涉及了很多对于数据的操作,以及网络通信。因此,我们对RPC中涉及到的技术做一个总结和分析:
- 动态代理技术:上文中我们提到的Client Stub 和Server Stub 程序,在具体的编码和开发实践的过程中,都是使用动态代理技术自动生成的一段程序
- 序列化和反序列化:在RPC调用的过程中,我们可以看到数据需要在一台机器上传输到另一台机器上。在互联网中,所有的数据都是以字节的形式进行传输的。而我们在编程的过程中,往往都需要使用数据对象,因此想要在网络上将对象和相关变量进行传输,就需要对数据对象做序列化和反序列化的操作。