一、为什么会出现RPC的概念?
RPC(Remote Progress Call),直译为中文就是远程过程调用。
首先从一个简单的例子说起。假设要做一个电商系统,起步初期,你的系统很简陋,仅仅包含商品浏览、下单购买等基本流程流程。于是,你觉得用一个服务就可以包含所有的功能绰绰有余。
逐渐的,随着业务扩展,越来越多的功能累加上来,这个服务也日渐壮大,代码变得臃肿,难以维护,最后变成一坨"屎山"。
一个优秀的程序员不应该一直在一座“屎山”再加垃圾。这么大的服务,能不能拆开呢?
当然能,聪明的你立马想到,我可以按照功能来拆分服务: 例如拆出一个订单服务:只负责下单、查单等操作;一个用户服务:只负责管理用户信息…
这样,拆分出来几个服务之后,只需要将几个服务结合使用即可。这就是微服务的概念。
可是问题又来了,原来只有一个服务,自然不要考虑服务间通信的问题。但是现在有多个微服务,那么各个服务之间怎么通信呢?或者说:多个进程之间怎么通信呢?
这可难不到你,好歹是看过**《UNP》**的人,立马想出了一百种方法:
- 管道通信
- 消息队列
- 共享内存
- 网络socket
当然,最聪明的选择只有一个,使用网络socket通信。为什么不选其他的呢,因为前几种方式都有个致命缺点,只能本机通信。但是在这个满大街都是分布式部署的年代,你不可能只把服务部署到一台机器上吧。。不会吧。
此外,通信协议也不搞花里胡哨的,不选UDP,老老实实用TCP就行了。
规划完毕,立马开始动手写程序。这写代码多是一件美事啊。
拿起键盘咔咔一顿造,这写个微服务不是有手就行吗,为了追求性能,还用了高性能的Reactor架构。总之,一个个微服务写好了,每个微服务监听在一个端口上,等待其他服务连接。
比如订单服务 OrderServer, 提供了下单(make_order)、**查单(query_order)**两个功能。OrderServer也很聪明,它告诉别人,你只需要发个数据包,数据包里面有方法名字和参数就行了,我就帮你执行对应的方法。假设现在我要在用户服务 UserServer 要去查单,应该怎么做呢:
- UserServer 调用 connect 连接 OrderServer
- 连接成功后 UserServer 制造数据包,数据包里面表示需要调用哪个方法 例如 (方法名:[query_order], 参数[data = 1999-09-09]})。
- UserServer 向 OrderServer发送这个数据包。
- OrderServer 接受到数据包后,解析出来方法名字:query_order,参数是 date=xxxx。
- OrderServer 执行 query_order(date)得到数据,然后制作为数据包。
- OrderServer 发送数据包返回给UserServer,调用结束。
这几个过程抽象出来就是无非就是 连接->编码->发送->解码->业务处理->回包。然而,每次调用都要写这么多代码,会不会有点繁琐?要是能直接像调用本地函数一样调用远程的方法就好了。哎,这不就是RPC的概念吗。我直接在 UserServer里面封装一个 query_order函数,在这个函数里面执行如下逻辑:
void query_order() {
1. 连接 OrderServer
2. 编码数据包,说明要调用 query_order方法,以及参数是什么
3. send
4. 等到回包并解码
5. 得到结果
}
而在远端的OrderServer,当收到数据包并解析后,就转而调用自己的query_order函数,然后会送数据包就行了。
因此,直接在UserServer 直接调用 query_order,不就等同于在调用OrderServer的query_order方法了吗,这个就叫远程过程调用,也就是RPC。
这里UserServer的query_order方法,就像是OrderServer的query_order方法的一个映射一样,又可以称为服务端在客户端的存根(stub)。
二、RPC如何序列化
再来研究下 query_order 函数,之前我们没考虑参数,实际上更完整的函数应该是这样:
class QueryReq {
string date;
};
class QueryRes {
int list_id;
int price;
string time;
};
void query_order(QueryReq* req, QueryRes* res) {
}
假设要查询 2022-01-01的一笔订单,那么要这样做:
QueryReq req;
req.date = "2022-01-01";
QueryRes res;
query_order(&req, &res);
这时候你发现了个问题, req是个对象,而TCP传输是基于字节流传输的,怎么把对象转换为字节流呢?
有办法,这时候 protobuf 就英雄登场了。protobuf是Google设计的一种序列化方案,他可以将 对象转换为字节流,这个过程就成为序列化;反之,也能将字节流再转换位对象,这个过程叫反序列化。
protobuf使用请参考:
https://github.com/protocolbuffers/protobuf
因此我们的逻辑就变得简单了:
- 使用protobuf将 req 序列化为字节流,发送给 OrderServer
- 将OrderServer返回的字节流反序列化成res对象。
要使用ProtoBuf,只需要撰写proto文件:
message QueryReq {
string date;
};
message QueryRes {
int32 list_id;
int32 price;
string time;
}
然后使用 protoc命令,他会自动生成类 QueryReq 以及类 QueryRes。这两个类是继承 google::protobuf::Message的,因此可以直接调用其成员函数进行序列化和反序列化。
注意,由于UserServer 和OrderServer都需要这两个类,因此双方都需要维持同一个 proto文件。
Protobuf只是序列化的方案之一,当然也有其他的方案,如使用json等。不过 protobuf 具有轻量化、快速等优点,通常是第一选择。
三、RPC如何处理数据包?
当然,理想的情况是每次rpc调用我只发送一次,一次就是一个完整的数据包。另一方可以得到这个完整的包并且成功解析。然而事实肯定没这么容易,由于网络阻塞、或者TCP粘包等原因,服务端当时收到的包可能不是一个完整的包,或者说无法区别每个数据包的边界。
因此,不能说直接把QueryReq 对象序列化之后就直接丢给网络传了,而不管其他的。这让服务端怎么解包,万一收到了半个包?那边是多了一个字节,那么反序列化的结果也可能千奇百怪。另外有一点,有没有发现方法名好像也没有传输过去?那服务端怎么知道调哪个方法?
基于此,通常我们需要自定义协议,来完成数据包的制造和拆分。这个过程又可以称为编码和解码。
参考陈硕的文章,这里设计一个简单地传输协议格式如下:
char* start; // 0x02
int32_t pk_len;
int32_t service_full_name_len;
std::string service_full_name;
pb binary data;
int32_t checksum;
char* end; // 0x03
- start 和 end表示一个数据包的开始和结束。
- pk_len为整个数据包的长度。
- service_full_name_len 为方法名的长度
- service_full_name 为方法名,如 “query_name”
- data就是请求对象的序列化后的二进制数据
- checksum是校验和,用于接收端校验是否数据包数据有变化
这里不需要考虑字节对齐,直接按顺序组合以上结构就行了。
综上,发送端在发送数据时需要按照这个格式编码,而服务端接受到数据时需要按这个格式解码。
**注意:**这个协议只是一个很简单的设计,实际业务中通常会附带更多的参数来保证可靠。
到此,完整的rpc逻辑应该就是这样的:
- client进行connect连接
- client序列化req
- client根据约定的协议编码,向server发送编码后的数据
- server接收到数据,解码得到方法名和序列化过后的req二进制流
- server根据方法名找到req的类型,反序列化得到req对象
- server调用本地方法得到res
- server序列化res
- server根据约定的协议编码并发送数据
- client接收到数据并解码
- client反序列化得到res
四、RPC与服务注册和服务发现
注意到,RPC中有关键的第一步就是要调用connect去建立TCP连接。既然要连接,那么要拿到对方的IP:port才行吧。如果对方是单机部署就很容易,直接用这个IP:PORT就行了。
然而,又是可恶的分布式再作怪,对端的服务通常是多机部署的。这时候怎么办,随便找一台机器?那肯定不行,要是这台机器故障了不就完了。再说你只指定一台机器,对方多机部署的意义何在?
那么这个时候有个第三者就出来了。有个叫服务中心的家伙,手下管理着大量服务的IP列表:
{
OrderServer : {
19.3.11.11:5000,
19.4.11.11:5000,
19.5.11.11:5000,
},
...
}
这时候,要想获取某个服务的地址,只需要向服务中心发送请求:
hi,我要OrderServer的IP:PORT
那么服务中心就会返回一个地址给你。这个过程就叫做服务发现。
这样,你就不需要担心不知道对端的地址是什么了,只要能在服务中心查到就行。
当然,还有服务注册,这就很简单了,每个服务启动的时候,需要将他的IP:PORT以及服务名称注册到服务中心。
不过,服务发现不止这么简单。通常,一个完整的服务发现中心还需要支持负载均衡、容灾处理等功能。
负载均衡:简单数就是将请求分散给各个机器上,维持各个机器的请求的均衡态势。确保不会大量请求都涌入到某几个机器上导致机器过载。
容灾处理:就是即使剔除掉故障的机器。例如某次调用19.4.11.11上的OrderServer出现故障,那么服务中心会将这个地址剔除掉,防止下次再访问到这个有故障的地址。