前言:
之前几次面试,总是在问到网络编程的时候,提及epoll,问我有没有写过什么服务,总是答简单的回射服务器,自己感觉这样的东西还是太浅。总得做点能证明自己水平的东西。于是决定还是用Epoll来写一个HTTP服务器吧。
我的github:
我的HTTP服务器是从TinyHTTP上发展而来的,tinyHTTP是CSAPP上给出的一个例子,我看完之后就明白基本的HTTP服务器是怎么运行的了,然后找了一本《图解HTTP》看了看,了解了协议的大概内容,然后开始阅读线程池内容,找到几个简单的线程池例子,然后写了一个自己的HTTP服务器。
该HTTP服务器已经放在了我的Github中。
https://github.com/YinWenAtBIT
说起我自己的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网络编程中的服务器构架方法开始写服务了。
在写自己的HTTP服务器之前,重新去阅读了UNIX网络编程中的关于服务器模型的那章,比较实用的是预先开辟子线程运行的方式。各个子线程加锁accept。不过在这里我打算使用的是一个线程负责accept链接,其他线程负责处理HTTP服务即可的模型。也就是使用线程池和工作队列。也就是通常所所说的半同步半异步模型。
除此之外,还有一个Leader/Follower模型,每个时刻只存在一个Leader,其他都是Follower等待成为leader,或者在执行客户处理,这个模型其实在单机上也就是子线程加锁Accept的模型,这个模型的好处是不用在创建工作队列,少了执行算法的线程切换开销。
不过在这里最后还是用的HA/HS模型。
工作队列设计:
1. 工作队列是一个单链表,其中保存链接的客户的connfd
2.注册的回调函数,用来完成对客户服务的函数。
1. 类的封装:
a. 工作队列封装成一个类,取出工作队列的一个节点后,只需要调用工作队列中的回调函数handle(),即可完成对客户的服务。
b. epoll类,epoll类完成对listenfd的监听,以及将连接的客户加入工作队列中的工作,类初始化时,传入listenfd以及工作队列还有客户要执行的回调函数即可,调用loop函数开始工作,accept连接的客户,以及将发送了消息的客户加入工作队列。
c. TCPServer类,TCP服务类是一个模板类,模板参数为协议种类,如TCSPServer
首先是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类。
1.std::function函数类,以及配合使用的std::bind,用来实现对回调函数的封装。
2. std::move,实现对某些生成临时变量赋值的加速。
3.enum class的强枚举类型,用在对HTTP_Base的封装里,
4. lambda匿名函数的使用,在注册回调函数时非常好用,可以使用当前帧栈中的变量,而注册的匿名函数并不需要输入参数。
5. 常用的auto,以及循环简写。
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);
}
后面想一想,如果设计的时候,就让这个回调函数有一个参数的话,就不会这么复杂了。不过这个实现确实额是非常的精妙,让我挺开心的。
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个星期的晚上都搭进去了。最后的成果确实挺不错!