ᕕ( ᐛ )ᕗ Jimyag's Blog

RPC 基础介绍

RPC(Remote Procedure Call)远程过程调用协议,一种通过网络从远程计算机上请求服务,而不需要了解底层网络技术的协议。RPC 它假定某些协议的存在,例如 TPC/UDP 等,为通信程序之间携带信息数据。在 OSI 网络七层模型中,RPC 跨越了传输层和应用层,RPC 使得开发,包括网络分布式多程序在内的应用程序更加容易。

本地过程调用

一个简单的本地过程调用的例子

package main

func Add(a,b int)int{
	return a+b
}

func main(){
	sum:=Add(1,2)
	fmt.Println(sum)
}

此时函数的调用过程是:

  1. 将数字 1 和 2 压入 Add 函数的栈
  2. 进入 Add 函数,从栈中取出 1 和 2 分别赋值给 a 和 b
  3. 执行 a + b 将结果压栈
  4. 将栈中的值取出来赋值给 main 中的 sum

远程过程调用的问题

在远程调用时,我们需要执行的函数体是在远程的机器上的,也就是说,Add 是在另一个进程中执行的。这就带来了几个新问题:

Call ID 映射。

我们怎么告诉远程机器我们要调用 Add,而不是 Sub 或者 Foo 呢?

在本地调用中,函数体是直接通过函数指针来指定的,我们调用 Add,编译器就自动帮我们调用它相应的函数指针。但是在远程调用中,函数指针是不行的,因为两个进程的地址空间是完全不一样的。

所以,在 RPC 中,所有的函数都必须有自己的一个 ID。这个 ID 在所有进程中都是唯一确定的。客户端在做远程过程调用时,必须附上这个 ID。然后我们还需要在客户端和服务端分别维护一个 {函数 <–> Call ID} 的对应表。两者的表不一定需要完全相同,但相同的函数对应的 Call ID 必须相同。当客户端需要进行远程调用时,它就查一下这个表,找出相应的 Call ID,然后把它传给服务端,服务端也通过查表,来确定客户端需要调用的函数,然后执行相应函数的代码。

序列化和反序列化

客户端怎么把参数值传给远程的函数呢?

在本地调用中,我们只需要把参数压到栈里,然后让函数自己去栈里读就行。但是在远程过程调用时,客户端跟服务端是不同的进程,不能通过内存来传递参数。甚至有时候客户端和服务端使用的都不是同一种语言(比如服务端用 C++,客户端用 Go 或者 Python)。这时候就需要客户端把参数先转成一个字节流,传给服务端后,再把字节流转成自己能读取的格式。这个过程叫序列化和反序列化。同理,从服务端返回的值也需要序列化反序列化的过程。

**序列化:**把对象转化为可传输的字节序列过程称为序列化。

**反序列化:**把字节序列还原为对象的过程称为反序列化。

网络传输

远程调用往往用在网络上,客户端和服务端是通过网络连接的。所有的数据都需要通过网络传输,因此就需要有一个网络传输层。网络传输层需要把Call ID和序列化后的参数字节流传给服务端,然后再把序列化后的调用结果传回客户端。只要能完成这两者的,都可以作为传输层使用。

因此,它所使用的协议其实是不限的,能完成传输就行。尽管大部分 RPC 框架都使用 TCP 协议,但其实 UDP 也可以,而 gRPC 干脆就用了 HTTP2。Java 的 Netty 也属于这层的东西。

远程过程调用传输的过程

解决了上面三个机制,就能实现 RPC 了,具体过程如下:

传输过程示例

  1. 客户端发起函数调用
  2. 将请求对象序列化为字节流
  3. 通过网络传输
  4. 服务端接收到字节流
  5. 服务端将请求字节流反序列化为对象
  6. 服务端进行函数处理
  7. 服务端将响应进行序列化为字节流
  8. 服务端通过网络传输
  9. 客户端接收到响应字节流
  10. 客户端将响应字节流反序列化为响应对象
  11. 客户端得到响应

实现简单的 RPC

客户端

  1. 将这个调用映射为 Call ID。这里假设用最简单的字符串当 Call ID 的方法

  2. 将 Call ID,a 和 b 序列化。可以直接将它们的值以二进制形式打包

  3. 把 2 中得到的数据包发送给 ServerAddress,这需要使用网络传输层

  4. 等待服务器返回结果

  5. 如果服务器调用成功,那么就将结果反序列化,并赋给 sum

服务端

  1. 在本地维护一个 Call ID 到函数指针的映射 call_id_map,可以用 dict 完成

  2. 等待请求,包括多线程的并发处理能力

  3. 得到一个请求后,将其数据包反序列化,得到 Call ID

  4. 通过在 call_id_map 中查找,得到相应的函数指针

  5. 将 a 和 rb 反序列化后,在本地调用 add 函数,得到结果

  6. 将结果序列化后通过网络返回给 Client

要实现一个 rpc 框架,其实只需要按照以上流程实现就行了**

其中:

使用 http 协议实现一个简单的 rpc

客户端

package main

import(
	"encoding/json"
	"fmt"
	"github.com/kirinlabs/HttpRequest"
	"log"
)

type ResponseDatastruct{
	Dataint`json:"data"`
}

func Add(a,bint)int{
	req:=HttpRequest.NewRequest()
	res,_:=req.Get(fmt.Sprintf("http://127.0.0.1:8000/%s?a=%d&b=%d","add",a,b))
	body,_:=res.Body()
	rspData:=ResponseData{}
	_=json.Unmarshal(body,&rspData)
	return rspData.Data
}

func main(){
	log.Print(Add(10,15))
}

服务端

package main

import(
	"encoding/json"
	"log"
	"net/http"
	"strconv"
)

func main(){
	http.HandleFunc("/add",func(writerhttp.ResponseWriter,request*http.Request){
		_=request.ParseForm()
		log.Printf(request.URL.Path)
		a,_:=strconv.Atoi(request.Form["a"][0])
		b,_:=strconv.Atoi(request.Form["b"][0])
		writer.Header().Set("Content-type","application/json")
		jData,_:=json.Marshal(map[string]int{"data":a+b,})
		_,_=writer.Write(jData)
    })
	_=http.ListenAndServe(":8000",nil)
}

其实 http 请求就是 rpc 的一种实现,也相当于远程过程调用。

rpc、http、restful

不同的应用程序之间的通信方式有很多,比如浏览器和服务器之间广泛使用的基于 HTTP 协议的 Restful API。与 RPC 相比,Restful API 有相对统一的标准,因而更通用,兼容性更好,支持不同的语言。HTTP 协议是基于文本的,一般具备更好的可读性。但是缺点也很明显:

参考

序列化理解起来很简单 - 知乎 (zhihu.com)

什么是 RESTful?RESTfule 风格又是啥? - 知乎 (zhihu.com)

#RPC #教程