该项目自主实现了分布式环境下本地服务在RPC节点上的注册、发布与远程调用功能。主要实现了自定义通信协议、服务注册中心、日志系统及高并发网络模型,具备高性能和良好的扩展性。
RPC介绍:是远程过程调用的缩写,可以通过网络从远程服务器上请求服务。具体功能简要来讲就是用户可以像在本地调用函数一样在客户端从服务器远程调用函数。
以下为需要的前置知识学习,不需要的可以跳过此部分:
ZooKeeper概述
概念:一个分布式的开源的分布式应用程序的协调服务。
功能:1.配置管理2.分布式锁3.集群管理。
- 配置管理:对于多个程序,设置一个配置中心,将配置信息写入配置中心中,当需要相应的配置信息时,直接从配置中心拉取对应的配置信息。
- 分布式锁:
- 单机:当一个用户访问数据时,为其加锁,当该用户访问完后解锁,其他用户才能进入并访问。
- 多用户:设置一个分布式锁的组件,当其中一个事务上锁时,另外一个事务需等待锁释放后才能上锁。
- 集群管理:设置注册1中心,provider将地址提供给注册中心,当consumer需要访问provider时,先从注册中心获取provider的地址,然后访问provider。
(本项目仅用到了zookeeper的api配置,所以只了解到这里)
TCP/IP协议的工作原理:
它是一个协议簇,包含四个协议:
应用协议:主要包括HTTP协议(超文本传输协议),SMTP协议(简单邮件传送协议),FTP(文件传输协议)等
传输层协议:TCP(传输控制协议),UDP(用户数据报协议)
网际互联协议:IP, ARP, RARP, ICMP
路由控制协议
(一)TCP协议的核心特性
- 面向连接:在数据传输前先建立连接,传输完成后释放连接。
- 可靠性:通过序列号、确认应答、超时重传机制确保数据准确到达。
- 字节流传输:数据被视为连续的字节流,没有消息边界的概念。
- 全双工通信:运行通信双方同时进行数据传输。
- 流量控制:通过滑窗机制控制数据发送速率,防止接收双方缓冲区溢出。
- 拥塞控制:动态调整数据发送速率,避免网络拥塞。
(二)三次握手概念
第一步:客户发送SYN包:客户端向服务器发送一个TCP数据包,SYN(同步序列号)标志位被设为1,表示这是一个连接请求包。同时客户端随机选择一个初始序列号(ISN),假设为x,将其放入数据包的序列号字段中,客户端发送SYN包后进入SYN_SENT阶段,等待服务器的响应。
第二步:服务器发送SYN-ACK包:服务器接到客户端的SYN包后,会为该连接分配必要的资源,服务器将SYN与ACK标志位都设置为1,表示同意建立连接并对客户端的请求进行确认。服务器也会随机选择一个初始序列号,假设为y,放入数据包的序列号字段中,同时将确认号字段设置为x+1,表示已收到客户端的SYN包,期待接下来收到客户端序列号为x+1的数据包,服务器发送SYN-ACK包后进入SYN-RCYD状态。
第三步:客户端发送ACK包:客户端接收到服务器发送的SYN-ACK包后,会检查确认号是否为x+1,如果是,则认为服务器已正确接受到自己的SYN包,客户端将ACK标志位设置为1,序列号字段设置为x+1,确认号字段设置为y+1,表示已收到服务器的SYN包,期望接下来收到服务器序列号为y+1的数据包。客户端发送ACK包后进入ESTABLISHED状态,此时客户端可以开始向服务器发送数据。
服务器接收到ACK包后,也进入ESTABLISHED状态,双方连接建立成功。
三次握手的设计原理:1.客户端发送SYN确保客户端有发送数据的能力。第二次握手证明服务器具有接收和发送数据的能力。第三次握手证明客户端可以接收数据。
- TCP协议通过同步初始序列号来跟踪每个字节的传输速度,并且初始序列号是随机生成的,降低被预测攻击的风险。
- 防止历史连接的干扰,服务器在接受到SYN时会发送SYN-ACK包,而当客户端接受到SYN-ACK包时会根据确认号是否正确才会发送最后的ACK包。
(三)TCP优化:
TFO技术:在SYN报文中携带数据,减少一次RTT(往返时间)延迟。
SYN cookie:一种应对SYN flood攻击的技术。SYN flood攻击:攻击者伪造大量的SYN包,耗尽服务器资源,导致正常请求无法处理。SYN cookie技术在SYN-ACK包中嵌入
(四)TCP报文
里面有几个关键字段:
- 序号:标识本报文段在发送方字节流中的位置。
- 确认号:表示接受方期望接收的下一个字节的序号。如果接收到了序号为x的字节,那么它会返回确认号x+1,确认号只有在ACK标志位为1的报文中有效。
- 控制位。包含八个标志位,用于控制 TCP 的各种操作。其中,CWR 标志与后面的 ECE 标志都用于 IP 首部的 ECN 字段,ECE 标志为 1 时,则通知对方已将拥塞窗口缩小;ECE若其值为 1 则会通知对方,从对方到这边的网络有阻塞。在收到数据包的 IP 首部中 ECN 为 1 时将 TCP 首部中的 ECE 设为 1;ACK 标志位表示确认号是否有效;SYN 标志位用于建立连接;FIN 标志位用于关闭连接;RST 标志位用于重置连接;PSH 标志位提示接收方应尽快将数据传递给应用层;URG 标志位表示紧急指针是否有效。
(五)TCP的数据分段与重组
每个网络链路都有一个最大传输单元(NTU),表示该链路能传输的最大数据包大小。TCP在发送数据时,会根据网络的MTU来决定每个段的大小,以避免数据在网络层被分片。TCP通常在三次握手过程中协商最大段大小(MSS)来确定每个段的最大数据量。MSS通常等于MTU减去IP头和TCP头的长度。
(六)可靠传输
- ACK延迟:接收方在接收多个报文后发送一个ACK报文,或者有数据要发送时将ACK与数据一同发送,提高传输效率。
- PSH标志:接收方在收到有PSH标志的报文时会立刻将缓冲区的数据传递给应用层,不用等待缓冲区满。
- 超时重传:设置超时时间,当一段时间内发送方没有接到反馈的ACK包就会重新发送该数据段。TCP超时时间一般由RTT(数据往返时间)确定。
- 最大重传次数:当超过一定重传次数后依然没有收到ACK包,发送方则认为接收端出现了问题,则会关闭连接。
- 快速重传,当接收方收到失序的段时,会发送重复的ACK包,这样发送方就会得知接收方期望序列的数据包没有发送,从而立即重传该段。
- 序列号的作用:1。数据排序。2数据去重。3确认应答。
- 数据去重的判断步骤:检查接收到的数据的序列号,若该数据在接收窗口内并且还没被接收,则将其放入缓冲区。以下情况将被丢弃:(1)已被确认接收的数据(2)如果该序列号大于接收窗口的上界,则将其丢弃。
(七)滑动窗口与流量限制
- 发送方滑窗设置:已发送并确认,已发送未确认,未发送但可发送,未发送不可发送。
- 接收方滑窗:表示可以接收的数据量,由接收方剩余缓冲区空间决定。
- 缩放因子:当窗口大小不足以充分利用网络带宽时,接收方通告一个缩放因子n,那么发送方在接收到这个缩放因子后发送的实际窗口大小则为2^n*窗口大小个字节。
(八)拥塞控制机制(四个阶段)
- 慢启动:连接建立初期,拥塞窗口初始化为一个最大段大小,每收到一个ACK报文拥塞窗口就增加一个MSS的大小;或者每收到一轮的ACK报文,拥塞窗口就翻倍。公式如下:
$ cwnd=cwnd+MSS $
$ Cwnd=cwnd*2 $
- 慢启动阈值:当拥塞窗口增长到慢启动阈值时,结束慢启动阶段,进入拥塞避免阶段。
- 拥塞避免阶段:拥塞窗口进入线性增长状态。
$ cwnd=cwnd+(MSS*MSS)/cwnd $4.拥塞判断:(1)超时事件(将拥塞窗口减半,并且重置为一个MSS)(2)发送方接收到三个重复的ACK报文(执行快速重传和快速回复)
5.快速重传:前面已经说过。
6.快速恢复:将慢启动阈值设置为当前拥塞窗口一半,将拥塞窗口设置为慢启动阈值加上3倍的MSS,随后执行拥塞避免算法。
(九)四次挥手
1,主动关闭方发送FIN包:FIN标志位设为1,序列号字段设为u,进入FIN_WAIT_1状态
2.被动关闭方发送ACK包,ACK标志位设为1,序列号字段设为v,确认号字段设为u+1,等待确认,进入CLOSE_WAIT状态,此时被动关闭方依然可以向主动关闭方发送数据
3.被动关闭方发送FIN包:被动关闭方传输完数据后,向主动关闭方发送一个FIN包,标志位设为1,序列号字段为v,确认号字段设为u+1,随后进入LAST_ACK状态。
4.主动关闭方发送ACK包,标志位为1,序列号字段为u+1,确认号字段为v+1,,进入TIME_WAIT阶段,等待一段时间(2倍的最大段生存期2MSL)
被动关闭方收到ACK包后进入CLOSED状态,主动关闭方等待一段时间后也进入CLOSED状态。
(十)TCP与UDP区别
TCP面向连接, 可靠传输,字节流,UDP无连接,不可靠传输,数据报
TCP效率较UDP低,消耗资源较高。
IO模型:
- 阻塞型IO:在操作系统完成IO操作前,会中断其他事务的操作,操作简单,适用于并发量小的开发。
- 非阻塞型IO:在操作系统完成IO操作前依然可以进行其他事务的操作,但是会使用轮询或事件机制两种机制去确认该IO操作是否完成:1.轮询就是时常询问该IO是否完成,会占用CPU时间2.事件机制就是IO完成后发送中断完成信号。非阻塞IO适用于高并发场景。
- 同步型IO:必须等待IO操作完成后才能执行后续代码。(要点:阻塞型IO一定是同步型IO,但是同步型IO不一定是阻塞型IO,比如使用休眠机制等待IO完成)
- 异步IO:程序发起IO操作后立即返回,内核负责完成所有操作,完成后通知应用程序。适用于极高并发需求的场景。
- 多路复用:允许单个线程通过一个系统调用监视多个文件描述符,当其中任何一个描述符就绪时,系统调用返回。下面讲select,poll以及epoll机制。
(一)多路复用的三种机制:
Select:初始化fd_set集合,调用select并等待,随后select遍历所有fd检查就绪状态,然后处理就绪的fd。
Poll:初始化pollfd数组,调用poll并等待,poll返回后遍历pollfd数组检查revents,返回就绪的fd。
Epoll:创建epoll实例,使用epoll_ctl添加修改删除监视的fd,调用epoll_wait等待事件,处理返回的就绪事件。
以下是三种机制的区别:
- 性能和效率:
- Select:在处理大量文件描述符时性能较差,因为每次调用都需要遍历所有文件描述符,且文件描述符数量受限(通常为1024)。
- Poll:性能较Select有所提升,因为Poll支持更大的文件描述符数量(无上限),但仍然需要遍历所有文件描述符。
- Epoll:性能最优,尤其是处理大量文件描述符时。Epoll采用事件驱动机制,只处理就绪的文件描述符,避免了遍历所有文件描述符的开销。
- 文件描述符数量:
- Select:文件描述符数量有限制(通常为1024)。
- Poll:文件描述符数量无上限。
- Epoll:文件描述符数量无上限,适合处理大量连接。
- 阻塞和唤醒机制:
- Select:阻塞在select调用,直到有文件描述符就绪。
- Poll:阻塞在poll调用,直到有文件描述符就绪。
- Epoll:阻塞在epoll_wait调用,直到有注册的事件发生。Epoll还支持边缘触发(Edge-Triggered)和水平触发(Level-Triggered)模式,进一步优化性能。
- 内存使用:
- Select:需要维护一个fd_set集合,内存使用较高。
- Poll:需要维护一个pollfd数组,内存使用较高。
- Epoll:内存使用较低,因为Epoll只处理就绪的文件描述符。
- 适用场景:
- Select:适用于小规模应用,文件描述符数量较少。
- Poll:适用于中等规模应用,文件描述符数量较多但不极端。
- Epoll:适用于大规模应用,特别是需要处理大量并发连接的场景(如高并发服务器)。
总结:
- Select:简单易用,但性能较差,适用于小型应用。
- Poll:性能较Select有所提升,适用于中型应用。
- Epoll:性能最优,适用于大规模高并发场景,是Linux环境下处理大量文件描述符的最佳选择。
HTTP协议:
(一)请求响应步骤:
- 客户端与服务器HTTP端口建立TCP套接字连接。
- 发送HTTP请求:通过TCP套接字客户端向服务器发送一个文本的请求报文,一个请求报文由请求行,请求头部,空行与请求数据四部分组成。
- 服务器接受请求并返回HTTP响应:服务器解析请求,定位请求资源。服务器将资源复本写到TCP套接字,由客户端读取。一个响应由状态行、响应头部、空行与响应数据四部分组成。
- 释放TCP连接:两个状态:close状态,服务器主动关闭连接,客户端被动关闭连接。Keepalive状态,连接会保持一段时间,在时间内可以继续接受请求。
- 客户端浏览器解析HTML内容:首先解析状态行,查看表明请求是否成功的状态代码。随后解析响应头,响应头告知HTML文档,随后读取响应数据HTML并对其格式化并显示。
(二)无连接
服务器处理完客户请求并收到客户应答后即断开连接。但是HTTP1.1对其进行了优化,在处理完客户请求后,会等待几秒后再关闭连接,这几秒等待的作用是在客户还有后续请求时可以复用该连接,提高效率。
(三)请求方法(区分大小写,这部分需要注意)
- GET:向指定的资源发出“显示”请求。
- HEAD:与GET方法一样,向服务器发出指定资源的请求。不过服务器不传回资源的本文部分。
- POST:向指定资源提交数据,请求服务器进行处理。数据被包含在请求本文中。这个请求可能会创建新的资源或修改现有资源。
- PUT:向指定资源位置上传其最新内容。
- DELETE:请求服务器删除request-url所标识的资源。
- TRACE:回显服务器收到的请求,主要用于测试或诊断。
- OPTIONS:使服务器传回该资源所支持的所有HTTP请求方法,用‘*’代替资源名称向web服务器发送OPTION请求,可以测试该服务器功能是否正常运作。
- CONNECT:HTTP1.1协议中预留给能将连接改为管道方式的代理服务器,通常用于SSL加密服务器的链接。
注意:
- 服务器不支持对应请求时会发送405,不认识对应请求时会发送501。
- GET与HEAD方法是必须实现的,其他方法可选。
- GET提交的数据会放在URL之后,也就是请求行里面,用?分割URL与传输数据,参数之间以&相连。
- GET提交的数据大小有限制,而POST提交的数据没有限制
(四)状态码
所有HTTP响应的第一行都是状态行,依次是当前HTTP版本号,3位数字组成的状态代码,以及描述状态的短语,彼此由空格分隔。
状态代码的第一个数组代表当前响应的类型。
(五)URL
超文本传输协议(HTTP)的统一资源定位符将从因特网获取信息的五个基本元素包括在一个简单的地址中:
传送协议。 层级URL标记符号(为[//],固定不变) 访问资源需要的凭证信息(可省略) 服务器。(通常为域名,有时为IP地址)
端口号。(以数字方式表示,若为HTTP的默认值“:80”可省略) 路径。(以“/”字符区别路径中的每一个目录名称)
查询。(GET模式的窗体参数,以“?”字符为起点,每个参数以“&”隔开,再以“=”分开参数名称与数据,通常以UTF8的URL编码,避开字符冲突的问题)
片段。以“#”字符为起点
以 http://www.luffycity.com:80/news/index.html?id=250&page=1 为例, 其中:
- http,是协议;
- http://www.luffycity.com,是服务器;
- 80,是服务器上的默认网络端口号,默认不显示;
- /news/index.html,是路径(URI:直接定位到对应的资源);
- ?id=250&page=1,是查询。
大多数网页浏览器不要求用户输入网页中“http://”的部分,因为绝大多数网页内容是超文本传输协议文件。同样,“80”是超文本传输协议文件的常用端口号,因此一般也不必写明。一般来说用户只要键入统一资源定位符的一部分就可以了。
由于超文本传输协议允许服务器将浏览器重定向到另一个网页地址,因此许多服务器允许用户省略网页地址中的部分,比如 www。从技术上来说这样省略后的网页地址实际上是一个不同的网页地址,浏览器本身无法决定这个新地址是否通,服务器必须完成重定向的任务。
数据序列化与反序列化
由于项目主要使用的是Proto3,这里针对Proto3进行学习。主要信息来源:Proto官方指南
(一)定义消息类型
如果要用proto3来编写,syntax=”proto3”;必须是文件的第一个非空、非注释行,如果没有指定则默认proto2。
message 消息名{}:花括号内定义字段的名称与类型
例:string name=1;这里1是编号,name是名称,string是类型。
(二)指定字段类型
字段类型除了标量类型还有枚举及其他消息类型等复合类型。
(三)分配字段编号
- 给定编号在该消息的所有字段中必须唯一。
- 字段编号1900到1999保留用于Protocol Buffers实现。
- 不能使用任何以前保留的字段编号,也不能使用分配给扩展的任何字段编号。
- 消息类型一旦投入使用,编号不能更改
- 字段编号不能被重用。
- 常设置的字段编号应设置在1-15之间,1-15占用1个字节,16到2047占用2个字节。
(四)指定字段基数
- Singular:推荐使用optional类型的单一字段,它具有两种可能的状态:1.字段已设置,它将会被序列化到线格式中。2.字段未设置,将返回默认值,不会被序列化到线格式中。
- repeated:该字段类型在格式良好的信息中可以重复零次或多次。重复值的顺序将得到保留。在proto3中,标量数值类型的repeated字段默认使用packed编码。
- Map:这是一种成对的键/值字段类型。
(五)格式良好的消息
单一字段可以在线格式字节中出现多次。解析器接受输入,但只有该字段的最后一个实例可以通过生成的绑定访问。
(六)更多消息类型
单个.proto文件中可以定义多个消息类型,但是当大量具有不同依赖关系的消息定义在同一个文件中时,这也会导致依赖膨胀。
(七)删除字段
当需要在客户端代码中删除所有引用时,必须保留已删除的字段编号,否则开发者会很有可能重用该编号,并且应该保留字段名称,以便JSON编码与TextFormant编码能继续解析。将保留的字段编号与字段名称写入reserved列表中,一个reserved语句中不能混合使用字段名称与字段编号。
(八)文件生成
对于C++,每个.proto文件会生成一个.h文件和.cc文件,文件中描述的每种消息类型都会有一个对应的类。
(九)标量值类型
类型较多,详情请见官方文档。
(十)默认字段值
- 对于字符串,默认值为空字符串
- 对于bytes,默认值为空bytes
- 对于bools,默认值为false
- 对于数值类型默认值为0
- 对于枚举,默认值是定义的第一个枚举值,该值必须为0
- 重复字段默认值为空
- map字段默认值为空。
消息类型到此为止,以下为服务定义部分:
1 | service CalculatorService { |
这是一个服务定义的示例,里面一行的内容:
Add、SubStract:远程方法名
AddRequest、SubtractRequest:输入参数类型,必须是消息类型
AddResponse、SubtractResponse:返回参数类型,必须是消息类型
Proto3的基础使用语法部分到此为止,后续为C++侧需要实现的部分。
Muduo网络库:
这部分作者陈硕出了一本书叫《Linux多线程服务端编程》,建议直接找书吃透,可以淘宝上买也可以在网上找到电子书资源。
代码正式学习(这里主要提出来设计细节):
user.proto:
1 | syntax="proto3";//使用proto3语法 |
设计要点:
- 错误处理标准化:resultcode作为统一错误返回结构,所有响应均包含操作结果与操作状态
- 服务扩展性:新增RPC方法只需在service中添加新rpc定义,新字段向后兼容。
- 跨语言:proto文件可生成Java/Python等客户端。
Krpcheader.proto:
1 | syntax="proto3"; |
Krpcconfig(配置加载模块):
1 | std::unique_ptr<FILE, decltype(&fclose)> pf( |
使用unique_ptr确保文件在任何情况下都会关闭,自定义删除器&fclose保证资源安全
配置解析:
1 | char buf[1024]; // 用于存储从文件中读取的每一行内容 |
根据key值查找对应的value:
1 | // 根据key查找对应的value |
字符串处理:
1 | // 去掉字符串前后的空格 |
使用find_first_not_of与find_last_not_of除去空格,避免不必要的内存拷贝。
可优化点:
- 支持更多数据类型
- 添加热更新支持
- 支持多级配置
Krpclogger(日志系统):
RALL资源管理:
1 | explicit KrpcLogger(const char *argv0) |
构造函数初始化Glog系统,禁用拷贝构造与赋值保证单例性。
日志接口设计:
1 | //提供静态日志方法 |
禁用拷贝构造函数与赋值重载函数:
1 | //禁用拷贝构造函数和重载赋值函数 |
可优化点:
- 增加文件输出
- 支持日志分级控制
- 添加日志轮转
Krpcapplication(框架入口与系统管理):
全局配置对象与互斥锁准备:
1 | Krpcconfig KrpcApplication::m_config; // 全局配置对象 |
创建单例对象:
1 | KrpcApplication &KrpcApplication::GetInstance() { |
加锁保证线程安全,atexit确保资源释放。
命令行参数解析:
1 | // 初始化函数,用于解析命令行参数并加载配置文件 |
安全的资源管理:加载失败后会终止程序,与退出自动释放一同保证了资源不泄漏。
1 | KrpcApplication(){} |
禁用了所有构造方式,保证了严格的单例控制。
可优化点:
- 增强配置验证
- 支持动态重载
KrpcChannel:
该部分负责实现protobuf的rpcchannel接口。
RPC头部组装:
组装过程结合KrpcHeader来理解:设置service_name/method_name,计算参数序列化长度args_size。序列化rpcheader,写入头部长度,拼接头部+参数。
该部分作用:
- 标识这是RPC请求而非普通数据包。
- 指明调用的服务和方法。
- 校验数据,确保参数完整传输。
该部分包含的模块:
- 初始化客户端socket:
1 | if (-1 == m_clientfd) { // 如果客户端socket未初始化 |
- 找到服务器地址:
1 | // 客户端需要查询ZooKeeper,找到提供该服务的服务器地址 |
- 连接服务器:
1 | // 尝试连接服务器 |
- 序列化请求参数:
1 | // 将请求参数序列化为字符串,并计算其长度 |
- 定义请求头部信息:
1 | // 定义RPC请求的头部信息 |
- 序列化头部信息并计算长度:
1 | // 将RPC头部信息序列化为字符串,并计算其长度 |
- 拼接RPC请求报文:
1 | std::string send_rpc_str; |
- 发送请求到服务器:
1 | // 发送RPC请求到服务器 |
这里及后续代码为什么仅明显的写出了错误处理:在执行if判断的同时就会执行发送请求,等待响应,反序列化等操作,如果成功,则不会执行if内的语句,这体现了UNIX网络编程的典型模式“失败即异常,成功即默认”。
IO模型:阻塞
- 接收服务器响应:
1 | // 接收服务器的响应 |
!! 粘包处理:通过长度前缀明确消息边界。
- 反序列化:
1 | // 将接收到的响应数据反序列化为response对象 |
- 创建新的socket连接:
1 | bool KrpcChannel::newConnect(const char *ip, uint16_t port) { |
客户端发现:
节点注册规范:
服务端注册路径:/$ {service_name}/ ${method_name}
节点数据格式:IP:Port
临时节点:利用ZooKeeper的临时节点特性实现服务下线自动清除
- 加分布式锁获取数据
1 | std::string KrpcChannel::QueryServiceHost(ZkClient *zkclient, std::string service_name, std::string method_name, int &idx) { |
- 错误处理:
1 | if (host_data_1 == "") { // 如果未找到服务地址 |
- 延迟连接:
1 | // 构造函数,支持延迟连接 |
查询失败时会进行重试,此处重试次数设为3次。
可优化点:
- 增加连接池管理
- 添加超时控制
Krpcprovider:
1 | void KrpcProvider::NotifyService(google::protobuf::Service *service) { |
NotifyService函数:
功能:将Protobuf生成的服务类注册到RPC框架
调用:在Run()之前调用
服务描述符获取:
1 | // 通过动态多态调用 service->GetDescriptor(), |
通过protobuf反射机制获取服务的元信息。
ServiceDsecipter包含服务名,方法列表,各方法的输入输出类型。
1 | // 获取服务的名字 |
获取服务名及方法数量。
方法遍历注册:
1 | // 遍历服务中的所有方法,并注册到服务信息中 |
服务信息存储:
1 | service_info.service = service; // 保存服务对象 |
建立服务名->方法名->方法实现的二级映射。
设计细节:
- 利用Protobuf原生反射API,避免手动维护服务列表。
- 标准化接口:所有服务统一通过google::protobuf::Service基类操作。
- 在服务启动前完成方法存在性检查。
Run函数:
函数功能:
- 启动RPC服务端网络监听
- 注册服务到ZooKeeper
- 进入事件循环
配置读取:
1 | // 读取配置文件中的RPC服务器IP和端口 |
依赖Krpcapplication部分从全局配置读取IP/端口。
网络服务初始化:
1 | std::shared_ptr<muduo::net::TcpServer> server = std::make_shared<muduo::net::TcpServer>(&event_loop, address, "KrpcProvider"); |
使用shared_ptr管理TCP服务对象生命周期
shared_ptr优势:
- 多线程环境中自动引用计数保证生命周期。
- 异步回调场景中通过shared_from_this延长生命周期。
- 异常安全场景中RALL自动释放。
回调绑定分离连接处理与消息处理。
ZooKeeper服务注册:
1 | // 将当前RPC节点上要发布的服务全部注册到ZooKeeper上,让RPC客户端可以在ZooKeeper上发现服务 |
这里服务名称节点为永久节点,而方法节点为临时节点,随服务进程退出自动删除。
事件循环:
1 | std::cout << "RpcProvider start service at ip:" << ip << " port:" << port << std::endl; |
start()立即返回,为非阻塞启动服务。loop()进入事件循环。
OnMessage()函数:
函数功能:
- 解析RPC请求报文。
- 路由到对应的服务方法。
- 动态调用并返回响应。
数据接收与初步处理:
1 | std::string recv_buf = buffer->retrieveAllAsString();//清空缓冲区并获取数据。 |
协议头解析:
1 | // 使用protobuf的CodedInputStream反序列化RPC请求 |
结构:接收数据 $\rightarrow$ varint32头部长度 $\rightarrow$ 头部数据 $\rightarrow$ 业务参数
安全设置:PushLimit确保不会读取超过header_size的数据,防止恶意构造的超大长度导致OOM。
服务方法路由:
1 | auto it = service_map.find(service_name); |
通过service_name查找服务对象,通过method_name查找方法描述符。
动态调用:
1 | google::protobuf::Service *service = it->second.service; // 获取服务对象 |
GetRequestPrototype动态创建参数对象
CallMethod通过方法描述符触发实际调用
使用NewCallback()确保响应发送时对象存活。
松耦合设计保证业务逻辑与网络层完全隔离。
SendRpcResponse()函数:
1 | // 发送RPC响应给客户端 |
函数功能:将RPC调用的结果序列化并发送回客户端。
调用时机:在服务方法执行完成后,通过Protobuf的Closure机制触发。
参数 | 类型 | 作用 |
---|---|---|
conn |
muduo::net::TcpConnectionPtr |
代表客户端连接的智能指针 |
response |
google::protobuf::Message* |
动态生成的响应消息对象 |
可优化点:
- 使用writev合并头部与参数的发送。
- 双缓冲:预分配内存避免频繁申请释放。
- 批量发送
zookeeperutil:
函数功能:
- 将RPC服务节点信息写入ZooKeeper
- 查询可用服务节点地址
- 维护与ZooKeeper集群的会话
global_watcher函数:
1 | void global_watcher(zhandle_t *zh, int type, int status, const char *path, void *watcherCtx); |
参数 | 类型 | 作用 |
---|---|---|
zh |
zhandle_t* |
ZooKeeper客户端句柄,标识触发事件的连接 |
type |
int |
事件类型(如会话事件、节点变更事件等) |
status |
int |
事件状态(如连接成功、认证失败等) |
path |
const char* |
触发事件的节点路径(对会话事件为NULL) |
watcherCtx |
void* |
用户自定义上下文,初始化时通过zookeeper_init 传入 |
1 | void global_watcher(zhandle_t *zh, int type, int status, const char *path, void *watcherCtx) { |
- 仅关注连接成功事件,忽略其他事件。
- 通过互斥锁保护is_connected标志。
- 使用条件变量实现异步回调转同步等待。
与ZooKeeper客户端的关系
- 每个zhandle_t实例只能有一个全局watcher。
- 生命周期与客户端句柄绑定,在zookeeper_close时失效。
Start函数:
函数功能:主要完成ZooKeeper客户端连接建立和会话管理。
1 | // 从配置文件中读取ZooKeeper服务器的IP和端口 |
这里注释写的比较全面,不过多解释。
关键设计:
- 异步转同步机制。
- 多线程安全实现:
- is_connected变量通过mutex保护。
- cv.wait()保证仅在连接成功后继续执行。
Create函数:
1 | // 创建ZooKeeper节点 |
部分要点:
- zoo_exists是同步阻塞调用
- 服务注册使用ZOO_EPHEMERAL,客户端断开自动清除。
GetData函数:
函数功能:从ZooKeeper节点读取数据,实现服务发现能力。
1 | std::string ZkClient::GetData(const char *path) { |
可优化点:
- 为service_map加锁,保证多线程注册服务不冲突
- 检查service指针的有效性
- 对象池复用
- 零拷贝分析
- 异步日志队列
KrpcControler:
1 | // 构造函数,初始化控制器状态 |
项目设计思路与结构总结:
分层架构:
1 | +-----------------------+ |
核心组件交互:
1 | Client端: |
类名 | 主要职责 |
---|---|
KrpcApplication | 框架入口,配置管理,单例模式保证全局访问 |
KrpcProvider | 服务端核心,注册服务,处理RPC请求路由 |
KrpcChannel | 客户端核心,管理连接,序列化请求,发送RPC调用 |
ZkClient | ZooKeeper客户端封装,处理服务注册与发现 |
KrpcController | RPC调用控制,错误处理 |
Krpcconfig | 配置文件解析,支持key-value格式配置 |
KrpcLogger | 基于Glog的日志系统,提供不同级别日志接口 |
关键流程设计
1. 服务启动流程
- 加载配置(IP/Port/ZK地址等)
- 注册服务到ServiceMap
- 连接ZooKeeper集群
- 将服务方法注册为ZK节点
- 启动Muduo网络服务
2. RPC调用流程
客户端:
- 通过ZK查询服务地址
- 建立TCP连接
- 序列化请求(header+args)
- 发送请求并等待响应
- 反序列化响应
服务端:
- 接收并解析请求头
- 从ServiceMap查找对应服务方法
- 反序列化请求参数
- 通过CallMethod动态调用
- 序列化响应并返回
3. 错误处理流程
- 通过KrpcController记录错误状态
- 错误类型包括:
- 序列化/反序列化失败
- 服务/方法不存在
- 网络通信错误
- ZooKeeper操作失败