MyPRC 框架设计与实现
框架概述
RPC(Remote Produce Call,远程过程调用)被封装成和本地调用一样的方式,但实际上,RPC需要通过网络通信来调用远端的服务接口。RPC框架在实现分布式微服务的过程中是必备的。下面介绍我所实现的RPC框架的特性:
- I/O模型与并发:
使用epoll网络模型,那么所有的网络I/O操作都是非阻塞的。为了得到高并发的服务处理能力,采用了进程池、Reactor-MS以及协程池。 - 协程本地变量
- 多协议支持
- 超时管理
- 服务路由管理
- 连接池管理
- 高效的客户端
- 分布式调用追踪
- 业务层开发
框架具体实现
MyRPC框架核心类关系简图
- MyRPCService 类是RPC服务启动的入口。
- EventDispatch 类通过 epoll 实现了事件的分发。
- MyHandler 类实现了事务处理的核心逻辑。
- Reactor 类通过 EventDispatch 成员变量实现了 Reactor-MS。
- Timer 类实现了定时器功能。
- CoroutineLocal 模板类封装了协程本地变量。
- EpollCtl 类实现了事件的管理操作。
- TimeStat 类实现了时间的统计。
- DistributedTrace 类实现了分布式调用信息的管理。
服务启动流程
服务启动流程在 MyRPCService 类中完成。
- 首先,使用命令行解析代码完成命令行参数的解析,
- 判断是否在后台运行。然后注册信号处理函数并开启 core dump。
- 接下来,读取服务指定的配置文件并注册业务处理的 handler。
- 如果服务按DeBug模式启动,则直接陷入Reactor事件监听循环;否则创建工作子进程,让工作子进程陷入Reactor事件监听循环。
- 最后,主进程通过给工作子进程发送0号信号来监控工作子进程的状态。当监控到工作子进程异常时,主进程会创建新的工作子进程。当接收到退出相关信号时,无论服务是否按debug模式启动,都会执行对应的退出逻辑。
Service.h
Service.cpp
信号处理相关代码:signalhandler.hpp
事件分发流程
事件的分发由Reactor-MS并发模型实现。每个工作子进程启动两个线程。
MainReactor
为了应对恶意连接(建立连接之后,不发送任何数据),在MainReactor线程的事件分发流程中,并不是一监听到新的客户端连接,就将客户端事件的监听迁移到SubReactor线程中,而是:
- 先创建一个超时的定时器事件,如果在指定的时间内客户端连接上没有触发可读事件(认为这是恶意连接),就关闭这个客户端连接。
SubReactor
- 在SubReactor线程的事件分发流程中,先初始化协程池,在陷入监听客户端连接上读写事件的循环中。
- 每次先处理I/O事件,当事件有关联的协程时,就恢复之前协程的执行,否则创建新的协程并执行。
- 当执行权返回到主协程之后,接着处理协程池的batch任务,恢复batch任务相关协程的执行。
- 最后,处理到期的定时事件,执行设置的超时回调函数。
reactor.cpp
eventdispatch.hpp
coroutinelocal.hpp
timer.hpp
服务器端请求处理流程
事件的处理由 MyHandler 类的 HandlerEntry 函数完成。
- HandlerEntry 首先从字节流中解析出请求的内存对象;
- 然后对请求进行处理,获取应答的内存对象;
- 接下来,对应答的内存对象进行序列化;
- 最后把应答数据返回给客户端;
- 除了主分支,还有Fast-Resp 模式和 oneway 模式。Fast-Resp 模式在执行请求的业务处理逻辑之前,就把默认的应答数据返回给客户端。而oneway模式在处理完请求的业务逻辑之后,不需要写应答数据给客户端。
在MyHandler类中,为了同时支持HTTP和MySvr,使用了MixedCodec对象来完成数据的解析。当请求通过HTTP发送时,先把请求数据转换成MySvr的请求对象,再使用MySvr得业务逻辑处理函数获取应答对象。接下来,把MySvr得应答对象转换成HTTP的应答对象。当请求通过MySvr发送时,和HTTP处理流程类似,只是少了协议对象转换的处理。在执行业务处理逻辑之前,必须对请求对象做参数校验(为了简化处理):
- 必须是服务支持的RPC接口
- HTTP请求格式是受限的,必须时POST请求
- URL只支持/index
- body必须是JSON格式
事件监听状态的迁移
HandlerEntry 函数根据不同的请求模式,在请求的不同阶段做事件监听状态的调整。事件监听的管理封装在 epoolctl.hpp。EpollCtl 类封装了5个函数,用于管理epoll实例上的事件监控,同时也提供了EventReadable 函数,用于完成事件标志易读化的转换。EventData 结构体是用于事件触发的回调数据,EventType 则是事件的逻辑分类。在不同的请求模式下,客户端连接上事件监听状态的迁移流程如下:
协程版读写函数的封装
在 HandlerEntry 函数中,并没有显式的协程切换调用。协程的切换被封装在CoRead函数、CoWrite函数和CoConnect函数中。当非阻塞的I/O暂不可用时,就主动让出执行权,切换到主协程中执行。考虑到可测试性,使用System类对read、write、connect函数进行了封装,并支持通过定义SocketIoMock的子类来实现对函数的装饰(mock),以方便进行单元测试。
客户端请求调用流程
服务发现
在客户端发起请求之前,需要先查询被调服务部署的实例信息(IP + Port),然后根据负载均衡策略选择调用的具体服务实例,最后将请求发送给对应的服务实例。通常,服务发现和服务注册一起使用。在这个实现中,选择简化服务发现,并且没有使用服务注册(后续考虑使用zk封装一个使用)。我们直接从配置文件中获取服务部署的实例信息。
连接管理
通过服务发现获取路由信息之后,就可以开始建立连接了。此时,我们面临长短连接的选择。
- 短连接实现简单,每次请求前建立连接,请求结束后结束连接。虽然简单,但是成本高,每次请求都要进行三次握手和四次挥手!
- 长连接在请求结束后保持连接,在下次请求时复用连接即可。但是长连接需要管理连接池(如何控制连接池的大小?如何维持连接的可用性?)。