HTTP服务实现——Epoll+线程池

前言:

  之前几次面试,总是在问到网络编程的时候,提及epoll,问我有没有写过什么服务,总是答简单的回射服务器,自己感觉这样的东西还是太浅。总得做点能证明自己水平的东西。于是决定还是用Epoll来写一个HTTP服务器吧。

我的github:

我的HTTP服务器是从TinyHTTP上发展而来的,tinyHTTP是CSAPP上给出的一个例子,我看完之后就明白基本的HTTP服务器是怎么运行的了,然后找了一本《图解HTTP》看了看,了解了协议的大概内容,然后开始阅读线程池内容,找到几个简单的线程池例子,然后写了一个自己的HTTP服务器。

该HTTP服务器已经放在了我的Github中。

https://github.com/YinWenAtBIT

1. TinyHTTP

说起我自己的HTTP服务器之前,我需要稍微介绍一下TInyHTTP服务器。tinyHTTP大约200行左右,实现了get方法。

服务器是一个迭代服务器,即接收到一个连接,然后服务,完成后等待下一个连接。

简述一下工作过程:

1. 创建listenfd,开始监听

2. accept连接,开始服务

3. 读取第一行,获取到方法,uri,版本号。忽略所有首部

4. 根据方法开始服务,只服务GET方法。

5. 判断uri中是否有query,如果有就执行cgi程序,没有就发送文件。

6. 填写回送首部,然后发送cgi程序结果或者文件。

7. 完成一次HTTP服务。

基础的HTTP服务基本上就这样完成了。在我之后自己写的HTTP服务中,也只支持GET,HEAD,PUT,POST方法。或许之后会加点新方法。

知道了这样的过程之后,就可以开始使用UNIX网络编程中的服务器构架方法开始写服务了。

2. my_HTTP

1. 构架:

在写自己的HTTP服务器之前,重新去阅读了UNIX网络编程中的关于服务器模型的那章,比较实用的是预先开辟子线程运行的方式。各个子线程加锁accept。不过在这里我打算使用的是一个线程负责accept链接,其他线程负责处理HTTP服务即可的模型。也就是使用线程池和工作队列。也就是通常所所说的半同步半异步模型。

除此之外,还有一个Leader/Follower模型,每个时刻只存在一个Leader,其他都是Follower等待成为leader,或者在执行客户处理,这个模型其实在单机上也就是子线程加锁Accept的模型,这个模型的好处是不用在创建工作队列,少了执行算法的线程切换开销。

不过在这里最后还是用的HA/HS模型。

工作队列设计:

1. 工作队列是一个单链表,其中保存链接的客户的connfd

2.注册的回调函数,用来完成对客户服务的函数。

2. 服务器类封装:

在实现这个HTTP服务器上,我想走的更近一步,让自己写的代码可以更好的复用,即不仅仅支持HTTP协议。因此要使用C++来封装整个模型,并且使用模板类来完成协议的设定。

1. 类的封装:

a. 工作队列封装成一个类,取出工作队列的一个节点后,只需要调用工作队列中的回调函数handle(),即可完成对客户的服务。

b. epoll类,epoll类完成对listenfd的监听,以及将连接的客户加入工作队列中的工作,类初始化时,传入listenfd以及工作队列还有客户要执行的回调函数即可,调用loop函数开始工作,accept连接的客户,以及将发送了消息的客户加入工作队列。

c. TCPServer类,TCP服务类是一个模板类,模板参数为协议种类,如TCSPServer则是使用HTTP协议的TCP服务器。TCPSever类负责创建监听listenfd,并且将协议的执行函数handle传递给Epoll类,并且在启动函数run开始执行之后,启动线程池,启动epoll,开始整个服务。

3. HTTP协议类:

一个HTTP服务其实可以分成几个部分来看:

首先是Accept的客户连接,这个部分已经用工作队列封装了,

然后是去读用户的请,有请求行,HTTP首部

再是处理请求,根据请求的内容进行合适的处理

最后将处理的结果发送回客户。

这样我们就可以将HTTP协议分成几个类来封装,每个类的实现可以独立出来,不再是互相依赖。

根据以上叙述,我将HTTP协议封装了四个类:

1. Request类,这个类中保存了请求行解析得来的HTTP版本,方法,以及URI地址。并且将首部选项保存在map中。

2. Response类,保存将要发回去的Response里的相应行,首部选项以及内容

3. HTTP类,拥有Request和Response类成员,真正的HTTP服务处理在这里执行,并且在完成了Response内容之后,将Response发回客户端。

4. HTTP_Base类,这个类中所有的资源都是static的,所以只又一份,其中有enum的HTTP方法类,HTTP版本,以及HTTP返回文件类型map等等,基本上HTTP协议中常用的内容保存在这里,其他三个类都是继承这个Base类。

4. 实现特性:

在实现该HTTP服务器时,尝试了不少C++11的新特性,

1.std::function函数类,以及配合使用的std::bind,用来实现对回调函数的封装。

2. std::move,实现对某些生成临时变量赋值的加速。

3.enum class的强枚举类型,用在对HTTP_Base的封装里,

4. lambda匿名函数的使用,在注册回调函数时非常好用,可以使用当前帧栈中的变量,而注册的匿名函数并不需要输入参数。

5. 常用的auto,以及循环简写。

5. 遇到的问题:

1. 对回调函数的绑定,需要绑定的回调函数是类的一个成员方法。但是对类函数的绑定需要使用类的指针或者改成类的静态函数。最后是使用std::bind加传递类指针完成了这个部分,并且还添加了一个占位符。都是边学边用,当时还以为写不出来了。。

2. pthread_create创建子线程,子线程运行的函数时类的成员方法。当传递类方法去的时候,函数指针类型不同,类的成员方法类型为void* (TCPServer::)(void*),解决办法是将这个函数改成静态函数,并且在pthread_create时,传递类的this 指针给静态函数,再由this指针调用类的成员。

3. 协议类的handle方法传递。协议类的handle方法为一个 void (*)(void)函数,这个函数最终由子线程调用。协议类中需要保存链接客户的connfd,这里就出现了一个问题,协议类只暴露给了TCPServer类,怎么把这个回调函数中加入connfd。而connfd只存在于Epoll类中完成Accept时。

这里解决的办法很复杂,我把代码列在这里:

首先是TCPServer类将协议类回调函数传递给Epoll类:

    void handle_proc(int connfd)
    {
        Protocol pro(connfd);
        pro.handle();
    }
这里将协议类的运行封装成了一个void (*)(int)函数,传递进connfd,就可以建立协议类,然后运行协议。将这个包裹函数传递给Epoll类。

而最终工作类中保存的回调函数,是void(*)(void)类型,则不可以将Epoll类中保存的函数指针传递过去,这里就用上了lambda函数,不传递参数,但是可以使用上下文栈里的参数:

void Epoll::add_to_channel(int fd, ChannelList & work_list)
{
    Channel * new_one = new Channel(fd);
    new_one->set_read_flag(true);
    std::function fct = [=](){read_callback(fd);};
    new_one->set_read_callback(fct);

    pthread_mutex_lock(_mutex_ptr);
    work_list.push_back(new_one);
    pthread_cond_signal(_cond_ptr);
    pthread_mutex_unlock(_mutex_ptr);

}

Epoll类Accept成功之后就调用上面的函数,将客户fd传进来,然后写了一个void(*)(void)的匿名函数,这个匿名函数调用了Epoll中保存的回调函数,使用了传递来的fd参数,这样又把connfd传递成功了。

后面想一想,如果设计的时候,就让这个回调函数有一个参数的话,就不会这么复杂了。不过这个实现确实额是非常的精妙,让我挺开心的。

6. 编写成功后的Debug:

1. 首先遇到的第一个问题,就是访问文件的时候,本应该显示在游览器上的文件,变成了下载文件,经过我反复的调试,使用gdb跟踪,终于发现原来是返回的HTTP首部中,Content-type填写错了内容,本来应该是text/plain写成了/text/plain,多了一个/号,导致服务器无法识别。

2. 第二个问题,服务器在处理了6,7个游览器访问之后,会出现突然崩溃的情况,崩溃之后也看不到消息,不知道具体是出了什么问题。解决办法是将服务器放在gdb中与运行,再次出现了服务器崩溃之后,gdb上显示收到了SIGPIPE信号。这个信号是对已经关闭了的socket进行写,第一次写之后端方将会返回RST信号,这个时候connfd可读,读取会得ECONNREST错误,但是由于在执行写回Response的时候,不会监听connfd可读,于是循环写的过程中就触发了SIGPIPE,解决的办法是调用sigaction函数忽略掉这个信号,转而在write上处理EPIPE错误。遇到该错误之后就可以关闭connfd,并且在Epoll中去除监听。

修改了这个信号监听之后,随意刷新游览器不再有任何问题了。

3. 执行CGI程序时,出现了Segment Fault错误,首先想试一试调试核心转存储文件,通过修改ulimit,给核心转存文件设置为unlimited大小,再运行之后,得到了core.*文件,使用这个文件和gdb进行调试。但是进去之后,却并不能定位到一个合适的地方。backtrace之后得到的出错地点是一个地址不可读,该地址非常小,看起来是在代码段中,当时查找google说可能是栈溢出,但是改了改代码之后发现不像。还是直接用gdb运行程序吧。结果发现原来是在执行CGI返回首部的解析代码中出错了。出现了越界的行为。

可是解析函数看起来是对的,到底哪里出错了?查了好半天,才发现,原来CGI程序运行之后,返回的结束行尾/n,并不是HTTP首部中的/r/n,少了一位字符,发现了这一点之后,就搞定了整个HTTP协议了。

总结:

自己写一个HTTP服务器,确实还是挺有挑战的,写的时候不仅仅回去回顾了不少UNIX网络编程里面的内容,还彻底的熟悉了Epoll的使用,以及对于整个后台服务器构架的设计。写这个服务器差不多整整3个星期的晚上都搭进去了。最后的成果确实挺不错!




你可能感兴趣的:(网络编程学习)