文章目录
基于epoll_ET工作、Reactor设计模式的HTTP服务
在此前,我们学习了很多的网络通信的内容:
如网络基础的认识、网络编程的基础Socket,也学习了协议的作用及自定义了协议。
针对于应用层的协议,我们学习了最常用的HTTP协议。
学习网络原理的时候,我们自顶向下的了解了TCP/IP协议,理解了每一层的作用。
最后,我们还学习了高级IO,了解了多路转接的方案!
现在,我们需要对上述学习的知识进行一个大的整合,从而加深对上述内容的理解:
而我们要做的:
就是基于epoll的多路转接方案,ET工作模式下,以Reactor设计模式实现HTTP的服务!
实现目标
1.首先,我们需要尽可能地把我们今天要实现的服务,每个模块尽可能地解耦!
2.需要正确地使用epoll实现IO服务!
3.要尽可能地将过往学到的内容进行结合!
4.需要尽可能地体现出Reactor地设计模式!
这里,我们先不讲什么是Reactor模式。一时半会是讲不清楚的。我们将会在写一小部分代码后,再来讲解这里的Reactor到底是什么!
代码的实现过程
下面,我们一起来看一下代码的实现过程是如何实现的,同时,这里给出源码:
https://gitee.com/yangnp/linux-network-learning/tree/master/2025_9_24
这里的HTTP服务,其实就是之前实现过的HTTP服务!只不过进行了一些修改。
因为要嵌入到今天这一份代码内,所以需要进行一定的修改。
基础框架
首先,我们需要先把基础框架搭建出来!
ReactorServer
今天的服务端,就用Reactor来进行指代我们的Reatcor服务器!(后序解释这其实不是服务器)
对于今天这个服务端来说,它内部需要至少包含以下两个方面:
1.需要一个epoll的模型!来进行多路转接的。
2.需要有一个专门用来进行链接管理的模块。
但是,我们今天需要的是:把每一个模块进行解耦合,所以我们需要对Epoll模型进行封装,也许需要对连接管理的类进行封装!
Epoller------epoll模型的封装
Epoller.hpp:
cpp
#include <sys/epoll.h>
#include <unistd.h>
#include "Log.hpp"
#include "Common.hpp"
using namespace myLog;
const static int default_epfd = -1;
class Epoller{
private:
//用于epoll_ctl的公共部分
void EpollCtlHelper(int op, int fd, uint32_t events){
epoll_event ev;
ev.events = events;
ev.data.fd = fd; //这个是有用的! -> 到时候用于查询对应的_connections来进行操作!
int n = epoll_ctl(_epfd, op, fd, &ev);
if(n < 0) LOG(LogLevel::WARNING) << "epoll ctl error!";
else LOG(LogLevel::INFO) << "epoll ctl success! fd is " << fd;
}
public:
Epoller()
:_epfd(default_epfd)
{
int n = _epfd = epoll_create(256);
if(n < 0){
LOG(LogLevel::FATAL) << "epoll create error!!!";
exit(EPOLL_CREATE_ERROR);
}
else LOG(LogLevel::INFO) << "epoll create success, epfd is " << _epfd;
}
~Epoller(){
int close_id = close(_epfd);
if(close_id == 0) LOG(LogLevel::INFO) << "the epoll fd is closed" << _epfd;
}
//添加事件
void AddEvent(int fd, uint32_t events){
EpollCtlHelper(EPOLL_CTL_ADD, fd, events);
}
//删除事件
void DelEvent(int fd){
int n = epoll_ctl(_epfd, EPOLL_CTL_DEL, fd, nullptr);
(void)n;
}
//修改事件
void ModEvent(int fd, uint32_t events){
EpollCtlHelper(EPOLL_CTL_MOD, fd, events);
}
//等待事件就绪
int WaitEvent(epoll_event evs[], int max_evs, int timeout){
int n = epoll_wait(_epfd, evs, max_evs, timeout);
if(n < 0) LOG(LogLevel::WARNING) << "epoll_wait error!!!";
else if(n == 0) LOG(LogLevel::WARNING) << "epoll_wait timeout...";
else LOG(LogLevel::WARNING) << "epoll_wait success!!!";
return n;
}
private:
int _epfd;
};
Connection
虽然我们说是要有专门的一个专门管理连接的类,我们称它为Listenner。
但是,其实监听套接字和普通套接字,它们差别很大吗?其实根本不算很大!它们本质上,处理的工作都是进行读和写!对于Listenner来说,甚至都不用写操作。
所以,如果写两份重合度比较高的代码:链接管理类和普通的链接类,这是非常费事费力费时间的!所以,针对于二者在一定的程度上相同,我们可以使用继承 + 多态
!
所以,我们就看这三份代码就知道三者之间的关系了:
Connection.hpp -> 表示链接的基类,内部仅仅是实现纯虚方法和存放公共属性!
Listener.hpp -> 表示专门用于监听套接字的类!
Connection.hpp -> 专门用于普通文件描述符操作的类!
Reactor的主逻辑
既然,我们今天要实现的是一个基于epoll的ET模式的IO处理服务。
所以,主逻辑自然是要基于底层的epoll的模型进行就绪事件的等待!
Epoller内部的成员变量:

每个变量都有相应的注释,这里就不进行解释了!
主逻辑:

LoopOnce是循环内进行等待的逻辑!如果能够从这里往下走到后序逻辑:
就说明,就绪事件已经返回了(ET模式)!
虽然我们还没有讲解这里是如何变为ET模式的,但是我们就先暂且当为ET模式来处理!
一旦有事件就绪,就绪的事件就会存储在ReactorServer内存放的一个_revs_arr内!
返回值n就是就绪事件的个数,然后进行不同个数对应的不同处理!
当n > 0,说明真的有就绪事件需要上层进行处理,在ET模式下,我们需要尽快地处理完所有的就绪事件,提高工作的效率,同时也是工程实践的要求!
👉尽快交付给Dispatcher——事件派发器:

对于就绪的事件,我们目前知道它们肯定是非阻塞的fd,且是ET工作模式!
然后就需要把就绪事件的文件描述符和事件取出来,进行操作!(这里的fd是从位图拿出来的!一般来说,这个位图内用的都是fd字段,这个在epoll模式的echo server那里说过)。
但是,就绪事件可不只是EPOLLIN、EPOLLOUT,还可能是其它的一些错误事件!
如果一个个处理还是太麻烦了,代码会很乱。我们直接把它转化为IO错误!
具体的操作方式是:如果是其他错误,直接把事件追加读和写!
如果真的出错了,后序读写的时候肯定是也会出错的!直接在IO模块内进行异常处理即可!
所以,在Dispatcher这里,只需要处理fd的读和写即可!
_connections中存的都是一个个的Connection基类指针,内部有读Recver和写Sender方法,所以在Dispatcher内调用的Recver和Sender方法,其实是子类重写的!
所以,我们后序只需要针对于不同的文件描述符做读写处理即可!
Listener的读事件
我们先不管普通连接的读写处理,我们来关心Listener的读取!因为这个类虽然是继承了Connection基类,但其实它只需要真正地把都方法给重写即可!

此时,我们能只进行一次Accept吗?答案是当然不能!
因为今天使用的ET模式,如果只读一次,获取一个连接,那么后序都读不到了!
所以,这里需要通过
while(true)
来进行循环读取!读取出来sockfd < 0,不一定是出错的!上述的逻辑早已讲过,我们这里就不再说了。
我们只需要了解,当真的拿到了一个合法的sockfd,应该怎么样操作?能直接读写吗?
答案肯定是不能的!因为不确定该文件描述符是否就绪!即使给他设置称非阻塞了,可以读,但是不符合我们本次实现目标的需求!
我们需要想办法把这个新获取到的连接(fd),交给epoll进行关注。等待它下一次就绪再处理。
但是,当前是在Listener模块啊,但是epoll模型是在ReactorServer模块内,只有ReactorServer有资格进行epoll关心和所有地连接地管理。
👇所以,这就是为什么在Connection基类中,会存在一个回调指针!

这个回调指针回指Reactor模块,从而进行操作!
所以,这就可以很轻而易举地完成获取新连接后,加入到内核和Reactor模块管理的工作了:
而且,每个Channel被建立得时候,都需要做一件事情:
就是把该文件描述符设置为非阻塞!
所以,这就是为什么,我们可以保证:拿到的就绪事件,一定是ET模式下得非阻塞的fd!
模块结合------理解整体逻辑
但是,上述的过程中:仅仅只是讲解了每个模块的作用!
没有讲清楚,这个服务是如何启动的。
其实就是因为,监听套接字没有设置给epoll关心!一开始epoll模型内什么关心的fd都没有。
在前面实现多路转接的时候,我们都是需要先把至少一个监听套接字设置给内核的。所以,这里也是一样的,我们也需要一开始就把监听套接字设置给epoll去关心。
从而获取新连接,或者是后续对普通的连接做IO操作。
所以,在主函数开启之前,就需要提前创建好监听模块,然后就后通过ReactorServer的添加连接接口来交付给内核,和底层的哈希表:
然后再来进行启动服务!

而Listener的创建,默认就是要设置非阻塞和ET模式的!
至此,逻辑就闭环了。为什么我们可以在前面读取就绪时间的时候,直接就认为该文件描述符是非阻塞的!也知道为什么是ET模式!
Reactor反应堆模式的理解
上面理解了基础框架,这个时候,我们就可以来正式地理解,为什么该服务是Reactor模式,即反应堆模式!
下面用一张图给予解释:
这是我们本次软件服务实现的分层结构。
内核中有一个epoll模型,它负责关注着所有的被关心的文件描述符是否就绪。
一旦就绪,它就会交给管理着所有Connection的ReactorServer类,然后根据就绪事件进行事件的派发!有一个fd就绪,就处理一个fd!
这不就像是核反应堆吗?一个一个的就绪,然后一个个的处理!
如果说需要进行连接的管理,就可以通过Connection内的回调指针进行操作!
至此,我们就能大致理解,我们本次实现的服务,为什么是Reactor反应堆模式了!
就像是下面这个打地鼠的场景一样:
添加业务
上面,我们也仅仅是把基础的框架给搭出来了。处理的是宏观逻辑。
但是,每个连接读取的数据,或者要写的数据,是什么呢?------这个需要结合需要的业务!
而且,我们使用的是TCP来创建的Listener,所以,有一个很大的问题就是:
TCP是面向字节流的!我们在读取的时候,没办法保证报文的完整性!
所以,是需要引入协议的!以保证通信的双方能够正确地读取到请求和应答。
这里,我们也不打算自定义协议了,因为有现成的可以使用:
1.网络版本的计算器
2.HTTP服务
如果想要简单一点,就直接拿网络版本的计算器来使用。需要带上客户端!
但是,今天我不想写客户端了,所以直接拿HTTP服务来写!
调用HTTP服务位置
首先,每个普通的文件描述符就绪后,就会进行Channel的Recv操作:
这里虽然recv读取的时候,是把选项传为0,但是因为我们早已设置过非阻塞了!
所以,这里的读取一定是非阻塞读取的!至于非阻塞的读取,早在高级IO那里讲过了!
因为是TCP协议读取的,所以没办法保证报文的完整性!发送的时候也是:
所以每个Channel内都会有自己的接收缓冲区和发送缓冲区!
Tips:这里为了简单,直接使用string了!
但如果是图片、音频、视频等二进制内容,就可能会处理出错的!
要想正确使用的话应该是用vector< string >的!
但我这里为了简单,我就直接使用string了!
然后当Recv的读取模块结束后,就需要进入到_handler_conn的服务处理了!因为要接收到对方的HTTP请求然后处理后返回应答!
if(!_inbuffer.empty()) _outbuffer = _handler_conn(_inbuffer);
把HTTP请求进行处理,返回应答的反序列化串,然后添加到发送缓冲区!
HTTP业务绑定
上述的_handler_conn,是每个文件描述符加入到内核和Reactor的时候,就已经为它注册好了的服务的!我们会把它放在基类Connection上!
但是,具体的HTTP的业务,是如何绑定给每个链接的呢?
难道是添加到内核的时候,Listener给每个文件描述符都进行注册了吗?
如果是上述的做法,就需要在Listener的Recv参数部分加一个绑定服务的参数,要不然就会得把服务写死!这是不愿意看到的!
但是,加了一个参数,导致基类和子类的函数参数就不一样了,就没办法构成重写了!
所以,应该怎么办呢?
👇
其实可以这样做:
1.在Listener交给内核关心的时候,就先把服务注册到Listener上!
2.Listener的基类部分就拿了服务了,然后,在Recv的时候,把绑定在Listener上的服务一一注册给新来的链接!
至此,就可以很轻松的把服务绑定给每个连接了!
主逻辑中还对HTTP进行了交互服务的注册!这个根据自己的需求来就好。
业务逻辑修改
我们上一次写的HTTP服务的代码并不是直接就能拿来用的!还是需要进行一定的修改。
我们给每个链接绑定的服务,就是HTTP内的HandlerRequest函数。
但是原来的HandlerRequest函数,是通过TCP套接字和客户端地址来作为参数,进行一个处理,然后直接发送序列化后的应答给对端的!
但是,今天绑定的服务类型是:
参数为string,返回值也是string!
其实就是:今天的HTTP服务不再需要考虑读取和发送数据的问题,只需要接收到一串报文,正确解析报文,然后把应答以返回值的形式返回!
cpp
//这里就只需要做一件事情,就是把接收到输入缓冲区给进行处理!(因为不需要这里来读取)
std::string HandleHttpRequest(std::string& readbuffer){
std::string response_str;
while(1){
//首先,得保证读到完整的请求->如果这一次没能成功读到完整请求,就不进行处理了!
std::string all_reqline;
if(ReadAllRequestHeader(readbuffer, &all_reqline) == false) break;
//读到完整的请求报头 -> all_reqline
//all_reqline里面有一个字段是指向正文长度的(前提是,Http请求中,正文部分长度 > 0,要不然其实是看不到的!)
//如果发送来的正文长度 == 0,看不到这个字段!
std::string text_len;
if(AnalyseRequestLine(all_reqline, "Content-Length", &text_len) == false) text_len = "0";
//成功读取长度到text_len -> 需要转成整数
int len = std::stoi(text_len);
//读取正文(从readbuffer中, 长度为len)
if(readbuffer.size() < len) break; //正文长度不对!
//这里就能够正确地把整个http请求报文都出来了!在req_str内
std::string text = readbuffer.substr(0, len);
std::string req_str = all_reqline + text;
//需要手动的把读到的一个完整的报文从缓冲区内拿下来!
readbuffer.erase(0, req_str.size());
//反序列化
HttpRequest hreq;
hreq.DeSerialize(req_str);
//应答对应的协议结构
HttpResponse hresp;
//今天这里加多一步,反序列化后,就需要知道当前是否需要进行交互了
if(hreq.Is_Interact()){
//需要进行交互
std::string service_name = hreq.GetUri();
//但是,这个服务可能不存在于_route表中
if(_route.find(service_name) == _route.end()){
//重定向到对应的404网页
hresp.SetCodeAndDesc(301);
hresp.SetHeaders("Location", "/404.html");
response_str += hresp.Serialize();
}
else{
_route[service_name](hreq, hresp);
response_str += hresp.Serialize();
}
}
//如果不需要进行交互访问,只访问静态资源,就走原来的逻辑!
else{
//分析请求 + 制作应答
//反序列化的时候,已经把要访问的web根目录底下的文件进行处理了!
hresp.SetTargetFile(hreq.GetUri());
hresp.MakeResponse();
//应答进行序列化
std::string resp_str = hresp.Serialize();
response_str += resp_str;
}
}
return response_str;
}
只需要在原来的基础上做一点点小修改即可!
同时,因为可能一连串发送来多个报文的粘包报,所以这里采取一次性解析完所有的请求报文,然后一次性的生成多个报文。所有的序列化后的应答报文都在response_str。
然后发送的时候粘报不用怕,对端是浏览器,浏览器自己会处理的!
处理多路转接下写的问题
HTTP业务返回后,每个Channel就有可能得到完整的应答报文!
然后就需要想办法给对端进行发送了!
但是,我们一直没有处理多路转接下的写问题,这里应该怎么处理呢?
我们需要秉持着以下几个结论:
1.写事件默认是就绪的!不能把写事件的关心直接加入到epoll!因为那样会导致一直就绪!
2.写事件应该是按需设置的!如果写就绪,那就直接发送给对端!如果没能发送完,那就只能加入到epoll内进行写事件的关心!
3.一旦触发 EPOLLOUT 事件并成功写入数据后,应立即移除 EPOLLOUT 监听!
4.把fd的写事件加入epoll关心,默认会触发一次写就绪!
所以,这就是为什么,Recv接口在接受处理好数据后,就直接进行发送了!
同时,我们再来看Sender的实现:
进行非阻塞发送,发送的内部处理逻辑其实和读取时一样的!
非阻塞发送模块结束后:
发送缓冲区不为空 -> 底层发送缓冲区没有空间 -> 没法再发送 -> 对端接收到不完整报文!
👉只能把写事件加入到epoll内关心!下一次就同时关心读和写!
发送缓冲区为空 -> 底层还有空间可以发送 -> 但是,不确定此时fd是否在内核中被关心了!
👉需要手动地把写事件的关心给关闭!
但是,关心和不关心,都是Reactor做的事情,所以,还是需要通过回调指针来做:
异常处理
最后,还有一个模块没有进行操作:就是异常处理!
其实异常处理,无论怎么样,这个文件描述符就是出问题了!直接删除关心和链接即可!
这样,所有的异常,都被归一到了IO处理异常了!
总结
至此,我们就完成了这样一个代码的编写!后序就不再展示实验效果了,其实和之前写的HTTP服务比起来看不出太大的差别。
差别只能在于:没办法处理图片、音视频等内容!