HTTP 服务器项目
整体学习完HTTP 这个应用层协议之后,心血来潮,在老师和学长的帮助下,更多的是在百度的帮助下,算是顺利的完成了项目吧。
功能:
- 收到 TCP/IP 协议栈发送过来的数据并对这些数据进行解析,得到有用的信息,然后对请求做出对应的响应。
- 模拟实现了 HTTP 协议的一些功能,比如:GET、POST 方法。
- 展示一下:比如:搭载一个贪吃蛇游戏的 HTML 网页。
- 在展示一个:搭载我之前的搜索引擎项目,实现对 BOOST 文档的搜索。(前端页面没有做,希望自己后面加上页面)
用到的技术:
- socket网络编程(TCP/IP 协议, socket 流式+数据报式套接字, http 协议)、Web Cgi 技术、进程间通信(管道)、多线程、单例模式、程序替换exec、线程池、分布式方案。
开发环境
- Linux Centos7 + g++ version8.3 + c++
注意:关于HTTP的一些基础知识我就不谈了,有兴趣可以自行了解
接下来,我就分模块的介绍一下我的项目吧
模块化介绍
SocketAPI 模块
- 这个模块做的事情比较简单,就是为了网络之间的通信做准备。
- 用到的函数无非就是大家非常熟悉的一些Socket 函数,比如 socket() 、bind()、listen()、accept()、connect()。
- 注意的是,我设置了端口复用,利用setsockopt()函数。
入口处理模块
- 这个模块是我建立连接之后,客户端请求我的服务器后,服务器线程去处理这个请求的入口。
- 因此我有一个入口函数 HandlerRequse() 函数。用来处理这个请求。
- 这个函数做的事情首先拿到请求起始行,一般请求起始行包含 【请求方法、URL、HTTP版本】。我们拿到这个三个属性后。首先判断请求方法我的服务器是否支持、请求的URL 我的服务器有没有这个资源,对于HTTP版本暂时不管。
- 如果请求的方法我的服务器不支持,我就返回对应的错误码400,并且构建一张错误的网页,发送给客户端。告知客户端发送了一个错误的请求。还没有完,虽然客户端请求的有误,但是我应该将我的缓冲区中还没有读完的数据读完,但是我不处理这些数据。以免下一次接收时出现错误。
- 对于请求的URL,我需要判断我的服务器中有没有这个资源,或者请求的带有目录,如果是一个CGI 的话是否具有执行权限。这些判断我使用一个linux 中的 stat 函数。
- 我们都知道stat 是一个linux 下的一个命令,用于打印出一个节点的详细信息。它的这个函数的功能差不多,如果这个函数返回值小于0,刚好证明了这个文件不存在。这个函数也有一些对应的宏定义中判断是否是目录或者如果是可执行文件的话能否可读。
- 为什么不使用open函数,因为这个文件可能是二进制文件或者可执行文件或者图片文件。就不能直接直接打开。而且此时只是需要判断一下文件是否存在,打开文件属于IO 操作,非常之慢,因此我们选择使用 stat 函数。
- 如果请求的这个资源没有的话,我们依旧是先把缓冲区中的所有数据全部读取完,然后再构建一张404 的页面,告知用户,我的服务器上无法找到所请求的资源。
- 如果以上全部成功,我们再来读取它的请求首部,HTTP 的请求首部是KV类型的。比如:Content-Length: 19
- 因此我们解析的时候,首先将首部整体保存在 vector< string> 中,然后,由于请求首部是KV 类型的,所以将所有首部保存在一个 unordered_map< string, string> 中。
- 接下来,我们需判断一下需不需要读正文,因为GET 方法的话是不没有正文的;
- 想要读取正文的话也比较简单,因为首部字段中有一个字段是 Content-Length,保存了正文的长度。
- 最后,解析完请求,我们需要构建响应返回给客户端。
- 响应的时候第一步需要的是判断是否是CGI 程序,判断完了如果是非CGI 的,那么剩下的任务交给响应模块去处理。如果是CGI 的,交由CGI 模块去处理,然后再交给响应模块去处理。
- 这时,入口处理模块的所有事情就干完了。
处理请求模块
- 这个模块做的事情就是对客户端发送过来的请求去处理。
- 处理请求起始行,上面已经介绍,由三部分构成,我使用 stringstream 对字符串进行分割。得到三个字符串,方法、URI、版本。
- 处理URL,判断如果是 GET 方法,因为可能会在URL 中带有参数,因此我们需要提取出参数。
- 处理请求首部,上面已经介绍过,请求首部是KV 类型的,因此我将他插入到 unordered_map< string, string> 中。
- 判断URL 处理出来的路径是否合法。使用stat 函数,上面也已经说过了。
- 最后我们要处理正文,也是比较简单的。如果是GET 方法的话,正文在URL 中,如果是POST 的话,正文就在正文。正文的长度我们是通过请求首部中 Content-Length 字段来得到长度的。
响应处理模块
- 当我们解析完请求之后,不管请求怎么样,我们服务器都应该给客户端会送一个响应。
- 响应和请求的形式差不多,都是有起始行、首部、正文。但是内容有些区别
- 首先构建响应起始行:【版本、状态码、原因短语】。版本号我们是固定的1.0 版本。根据不同的情况我们填上不同的状态码,根据不同的状态码填写对应的原因短语。
- 构建响应首部,填写不同的字段和字段对应的值。
- 这块需要注意的是,不同文件的文件扩展名对应不同的Content-Type 值,因此我们需要根据URL 中的文件后缀来对应我们的Content-Type 值。
- 对于正文的话,通常客户端请求我的资源大多是一张网页,而我的网页在我的服务器里面就是一个文件。因此发送给客户端一个文件的时候,我们不用讲文件打开,在将文件内容读取出来,最后发送给对方。我们直接调用 linux 中一个函数 sendfile(),就可以实现直接将文件发送出去。
- 如果使用read、write 的话,read 需要从用户态切换至内核态,将数据从用户拷贝至内核;紧接着,然后再从内核到用户,读到某个内存中;write 的话也需要从用户到内核,将数据从用户拷贝至内核,然后再由内核返回。这样的话,消耗时间太大。而sendfile 只是进行了内核态的拷贝,不需要进行cpu 进行切换。因此效率是比较高的。
- 当构建好这些模块之后我们就可以进行发送了。
CGI 模块
- 本来CGI 模块可以放在别的模块进行处理的,但是我把他拎出来。是因为他涉及到一些技术点。
- 首先我介绍一下CGI 是什么东西吧
- CGI 是外部应用程序和WEB 服务器之间的接口标准,是在CGI 程序和WEB 服务器之间的传递信息的过程。CGI 应用程序是独立于服务器的,可以使用任意语言实现。它在服务器和众多的资源类型之间提供一种简单地、函数形式的粘合方式,用来处理各种需要的转换。这个接口还能很好的保护服务器,防止一些糟糕的扩展对它造成破坏。但是这种分离会造成性能的影响。为每条CGI 请求引发一个新进程的开销是很高的,会限制那些使用CGI 的服务器的性能,并且会加重服务端机器资源的负担。
- 为了解决这个问题,人们发明了一种新型的CGI,并将其恰当的称为快速CGI。这个借口模拟了CGI,但它是作为持久守护进程运行的,消除了为每个请求建立或拆除新进程所带来的性能损耗。
- 于是我们了解了CGI 之后,那么我们如何实现CGI 呢,我们先不考虑快速CGI,实现一个原生的CGI,对于我们的Web 服务器需要新启一个进程去替换CGI 程序。
- 不能使用Web 服务器的一个线程去替换CGI,否则 Web 服务器将不复存在。
- 进程的替换我们知道可以使用 exec 函数,同时子进程是由父进程fork 出来的。那么我客户端向Web 服务器发过来的请求数据如何给我的CGI 程序呢,这时就要使用进程间通信了。我们使用的是管道。其中管道是单向的,因此我们使用一对两个管道进行父子进程进行通信。因为进程之间的数据时独有的。
- 我的CGI 程序是我之前写好的搜索引擎。我的搜索引擎项目是基于BOOST文档的。就是输入词,然后返回那些文档中那些地方出现了这些关键字,就像百度一样。哈哈哈。
- 为了效率能够高一点,因此我是这样做的,我的CGI 程序的作用是拿到Web 服务器的数据然后发送给我的搜索引擎服务器,然后搜索引擎返回的数据交给我的CGI 程序然后传给我的Web服务器,由Web 服务器做出响应。
- 现在我们遇到一个问题就是,我们创建好管道,然后进行fork 出子进程,而管道我们可以把它想像成一个文件,因为他也是使用文件描述符来描述的,而当我们fork 出来的子进程之后,由于文件描述符也是数据,而进程之间数据独有,所以管道还在,但是描述管道的文件描述符却不见了。所以针对这种情况,我们将管道的文件描述符重定向到标准输入和标准输出。让子进程从标准输入中去读,去标准输出中去写。
- 这时,我的Web 服务器和 CGI 程序此时就可以进行通信了。接下来,就要和搜索引擎服务器进行通信了,这块就是简单的SocketAPI 的使用了。
线程池模块
- 在我没有加线程池的时候,我的服务器是客户端连接的时候我才创建线程,会导致时间上的消耗比较大。而且我的线程数没有上限,当连接特别多的时候,线程之间的切换是由成本的,服务器就会变卡变慢。而且服务器的资源是有限的,如果有人恶意给服务器发送大量的请求,那么服务器就会充满了大量的线程,导致服务器变卡,有时不仅仅是卡的问题了,可能由于没有资源就会把后面来的连接给拒绝掉。而且连接如果等的时间长的话,那么就会超时,连接就会断掉。
- 针对这种情况我们采用线程池的方法,预先创建一堆线程,并且线程的数量是由上限的。当一大堆请求来的时候,我会将这些请求放到线程池的任务队列中,让线程池中的活跃线程去处理任务就好。虽然谈不上性能有多高,但是最起码是稳定的。
- 最后我将我的线程池设置为单例模式了。单例对象的类必须保证只有一个实例存在。许多时候整个系统只需要拥有一个的全局对象,这样有利于我们协调系统整体的行为。比如在某个服务器程序中,该服务器的配置信息存放在一个文件中,这些配置数据由一个单例对象统一读取,然后服务进程中的其他对象再通过这个单例对象获取这些配置信息。这种方式简化了在复杂环境下的配置管理。 即Web 服务器不管怎么去调用我的线程池,系统中都只有一个实例。
- 至此线程池模块介绍完毕,整体的项目框架也介绍完毕。
项目的问题
- 我的这个Web 服务器可能效率有时会比较低,是这样子的,我的服务器每一个线程要经历一整个 IO 过程,这个整个流程都要一个线程去完成,并且如果请求的是比较大的图片资源的话,那么可能出现5个线程都会阻塞在IO 上,因为我们知道IO 的过程是非常是慢的。那么后来的请求就只能放在任务队列里,如果请求比较多的话,可能任务队列中的请求会越来越多,那么可能会导致等待久的请求超时而退出,服务器处理请求速度变慢。
- 如果解决呢?受限于硬件资源,因此我们可以采用分布式的架构。比如说我的CGI 这块。如果大量请求我的CGI,那么就会导致我的Web 服务器创建大量的进程。这就有很大问题。那么我是这样解决的,如果请求的是CGI 程序的话,我的HTTP Server 可以将数据转给我另一台机器上的CGI 服务器,我的CGI 服务器是我后端的一台独立的服务器。当我的Web Server 收到CGI 请求后,我可以通过新建Socket 连接我与CGI 服务器发送数据。返回时,CGI 服务器可以将数据发给我、,或者直接转发给客户端。
- 假设我后端有4个CGI 服务器,那么我的Web 服务器均衡的将请求发给他们,这样,CGI 请求就可以并行的执行,因此效率会大大的提高。
- 还有一个思路,就是改成多路转接。使用 epoll 。但是在这个服务器中好像并没有多大的帮助。添加到epoll_wait 中的请求还是需要多线程去处理。但是可以节省一个线程去哪里等待其他线程。
- 其实cpu 的处理速度还是挺快的吧,网络带宽的问题也占了相当大的比重。
- 还有就是线程池中线程的数量问题,这个数量和 CPU 的核数有关。尽量选择和CPU 数量和核数相差不大的数字。这样才能真的并发起来,如果多了,听起来是并发,实际上是由CPU 的切换完成的。当然这个切换也是有时间消耗的。
- 但是如果是IO 多的话,那么可以多创建几个线程,因为IO 多的话,等的时间就比较长了,可能经常在等。所以多几个线程可以抵消掉等的时间消耗。
- 增添一个问题:在我使用我的项目时,发现了一个BUG。就是服务器端会出现段错误,然后退出。
- 原因就是SIGPIPE 的问题。这样的场景:通信双方,比如我的浏览器给服务器发送一段请求后,然后浏览器出错了,崩溃了,于是浏览器自己把连接断开了,而且读读文件描述符关了。但是此时服务器还在向这个文件描述符中去写数据,此时服务器端就会触发 sigpipe 信号。导致进程退出,服务器挂掉。
- 解决方法很简单,就是把这个信号处理一下就好,让服务器忽略掉这个信号。
signal(SIGPIPE,SIGIGN);
Web服务器项目源码:https://github.com/zhangyi-13572252156/HTTP
因为我的CGI 程序连接的是我之前写的搜索引擎项目。我在这里贴上搜索引擎项目博客和源码。
搜索引擎项目介绍博客:https://blog.csdn.net/qq_40421919/article/details/96749764
搜索引擎项目源码:https://github.com/zhangyi-13572252156/search_engine
- 我又来了,改进项目的时候发现了一个bug。是在我的CGI 程序模块出现的问题。我做CGI 程序的时候忽略了一个问题,就是管道的大小是有上限的,这个知识点被我忘记了, 啊啊啊,该死。。于是我写了一段程序测了一下,发现我的机器上管道的上限是64K。如果超过64K 管道再往管道中去写,就会阻塞。问题就是我在的搜索引擎服务器向我的CGI 程序回送数据的时候,我发现常常大小是超过64K的。注意哈,我说的是管道的最大容量是64K,但是当我们使用
ulimit -a
去查看的时候,或者使用man 7 pipe
的时候,会发现 pipe_buf 只有4 K,这是在说原子操作下的管道一次可以写4K 的数据,最多管道中可以容纳 64K 的数据。这个64K 貌似没有调。关于管道大小的问题,大家可以参考这个博文:https://blog.csdn.net/judwenwen2009/article/details/44134415 - 那么当我的搜索引擎来的数据超过64K 的话我该怎么办呢?其实是因为我的HTTP 服务器的锅,最初的设计是基于短连接的,因此只能是客户端发送一个报文,我服务端回送一条报文之后双方关闭链接。想要更改的话,我们可以将我们的HTTP 服务器改成长连接,服务端可以多发送几次。或者我们将更改我们的CGI 程序,我以前用的UDP,可以改成TCP,刚好我的管道是基于流的,也可以实现这个目的。或者不用管道了,我换成共享内存。共享内存的大小可以设置更改。改大一点就好了。使用消息队列也有限制,一条消息的大小是short 类型,限制在8192这个大小范围内。
- 当然了,方法有很多,甚至,linux 玩的好的话,你可以进到内核中,修改源代码,然后重新编译内核即可。
第三次修改,加数据库
- 使用了MySQL 数据库,将我的日志信息放到我的数据库中,实现了信息的持久化,方便以后对系统的调试处理。
第四次修改,修复管道的BUG
- 管道是基于流式的,之前一直忘了应该在管道的一边发的同时,就应该在管道的另一边去读数据,不至于使数据放满管道而阻塞。
- 当然,最好的办法就是换掉管道,将管道换成套接字,如果后面管道又出现了什么问题的话,我会毫不犹豫的将管道换成套接字。