最近接触到了微前端的项目,其中用到了没接触过的gRPC,这里进行一下总结。
接触gRPC前我们先了解一下RPC:
1.什么是RPC?
RPC( Remote Procedure Call )意为本地过程调用。
我们先来看看本地函数调用
function add(a, b){
return a+b;
}
console.log(add(1, 2));
这其实就是非常普通的本地函数调用,因为在同一个地址空间。但是现在我们希望将系统改成分布式,把能共享的功能全部抽离出来,比如将上面的add函数放到一个单独的服务中, 在远程调用时,我们在服务A上需要执行的函数体是在服务B上的,而 RPC就是要像调用本地的函数一样去调远程函数。
2.远程调用带来的问题:
现在服务A上需要执行的函数体是在服务B上的,那么我们怎么告诉服务B我们要调用add函数而不是其他函数例如mutiple函数呢,所以RPC就是要解决分布式系统中,服务之间调用的问题,那么如何解决这个问题呢?
Call ID映射:
在RPC中,所有的函数都必须有自己的一个ID。这个ID在所有进程中都是唯一确定的。客户端在做远程过程调用时,必须附上这个ID。然后我们还需要在客户端和服务端分别维护一个 的对应表。相同的函数对应的Call ID必须相同。当客户端需要进行远程调用时,它就查一下这个表,找出相应的Call ID,然后把它传给服务端,服务端也通过查表,来确定客户端需要调用的函数,然后执行相应函数的代码。
但是现在又出现了新的问题:客户端如何把参数和ID传给远程的函数呢?在本地我们只需要把参数压栈然后读取就可以了,但是远程调用的时候不能通过内存来传参。也有可能客户端和服务端使用的都是不同的语言
序列化和反序列化:
这时候客户端就需要把数据序列化之后传给服务端,服务端接收到数据之后再反序列化转换成自己能读取的格式。 同理,从服务端返回的值也需要序列化反序列化的过程。
具体过程如下:
// Client端
1. 将这个调用映射为Call ID。
2. 将Call ID,和参数a,b序列化。可以直接将它们的值以二进制形式打包
3. 把2中得到的数据包发送给ServerAddr,这需要使用网络传输层
4. 等待服务器返回结果
5. 如果服务器调用成功,那么就将结果反序列化,然后console.log进行打印
// Server端
1. 在本地维护一个Call ID到函数指针的映射call_id_map
2. 等待请求
3. 得到一个请求后,将其数据包反序列化,得到Call ID
4. 通过在call_id_map中查找,得到相应的函数指针
5. 将a和b反序列化后,在本地调用add函数,得到结果
6. 将结果序列化后通过网络返回给Client
3.什么是gRPC?
gRPC是一款RPC框架,其优点在于:
-
使用Protobuf进行数据编码,提高数据压缩率。
-
使用HTTP2.0弥补了HTTP1.1的不足。
-
同样在调用方和服务方使用协议约定文件,提供参数可选,为版本兼容留下缓冲空间。
简单介绍一下Protobuf,它是Google开发的一种跨语言、跨平台、可扩展的用于序列化数据协议。 举个例子:
// XXXX.proto
service Test {
rpc HowRpcDefine (Request) returns (Response) ; // 定义一个RPC方法
}
message Request {
//类型 | 字段名字| 标号
int64 user_id = 1;
string name = 2;
}
message Response {
repeated int64 ids = 1; // repeated 表示数组
Value info = 2; // 可嵌套对象
map<int, Value> values = 3; // 可输出map映射
}
message Value {
bool is_man = 1;
int age = 2;
}
可以看出有几个明确的特点:
-
有明确的类型,支持的类型有多种。
-
每个field会有名字。
-
每个field有一个数字标号,一般按顺序排列。
-
能表达数组、map映射等类型。
-
通过嵌套message可以表达复杂的对象。
-
方法、参数的定义落到一个.proto 文件中,依赖双方需要同时持有这个文件,并依此进行编解码。
为什么选择protobuf,而不是普及最广的json作为编码方案? 可以做一个直观对比,以上文proto中的Response为例,一次输出json的结果是:
"{\"ids\":[123,456],\"info\":{\"is_man\":true,\"age\":20},\"values\":{\"110\":{\"is_man\":false,\"age\":18}}}"
所有内容被打包成了一个字符串,里面包含字段名、value,当Reponse很大时,体积消耗很大,浪费主要在三个方面:
- 字段名,例如上面的“ids”、“info”等,如果json体大,则重复会更多。
- 数字用字符串表达了,例如123数字变成了“123”,这在编码后体积由一个字节变成三字节。
- 类型字符,如[ 、 ]、{ 、}。
但如果是protobuf呢? 输出是一段人眼无法理解的二进制串,里面:
- 去掉了字段名,转而以字段标号替代,通过标号可以在proto中找到字段名。
- 没有类型字符等。
- 用二进制表达内容,不会将数字转成字符串。
- 字段值按顺序依次排列。
这使得protobuf的编码结果体积,通常是json编码后的十分之一以下。同时由于排列简单,其解析算法的时空复杂度远小于json,对cpu消耗也小很多。这使得protobuf在大数据量、高频率的数据交互场景下,远胜于json,被大规模分布式RPC场景广泛使用。
了解完Protobuf之后gRPC其他不作赘述,我们直接进入最近正在修改的微前端项目中看一下,下面会用到getDevices方法
getDevices方法需要传递的protobuf数据 :
message GetDevices {
//message Request中为发送请求时携带的数据
message Request {
//类型 | 字段名字| 标号
int64 offset = 1;
int64 limit = 2;
filterFilter = 3;
}
//message Response中为服务端返回的数据
message Response {
//类型 | 字段名字| 标号
repeated devices = 101;
}
}
调用getDevices方法
import { asset } from 'xxx';
let params = {
limit,
offset,
filterFilter: {
...filters
}
};
const res = await asset.getDevices(params);
这里我们调用asset.getDevices方法的时候是不是就像本地函数调用一样?这就是gRPC的功劳。
我们再进到引入asset对象的xxx目录中查看:
export const asset = get(window, 'yyy.client.asset');
这里的get就是获取window中的yyy.asset对象,那么window中的yyy.asset对象从何而来呢?我们进入zzz目录中查看:
asset = new Asset(HOSTNAME);
//HOSTNAME是我们asset.getDevices时需要发送请求的网关地址
window.yyy = {
client: asset,
};
至此我们找到了asset从何而来以及为什么能像使用本地函数一样来进行远程调用asset.getDevices方法。
参考资料:
- https://www.zhihu.com/question/25536695/answer/221638079
- https://www.jianshu.com/p/2accc2840a1b