这个项目是基于HTTP/1.0版本的一个简单web服务器,主要用于练习网络编程和系统编程。
使用技术:
项目使用c++编写、cgi技术、多线程、 多进程(处理cgi)、socket网络编程
版本1采用线程池加任务队列的方式处理请求
版本2采用Reactor的设计模式,通过epoll + 线程池 + 就绪队列 + 事件池的方式编写,解决了版本1中处理cgi时工作线程阻塞等待问题。
项目源码: https://github.com/Waorange/http-server
注:该项目参考Tinyhttpd 另外没有进行详细的测试,如果有bug希望指出,还有我也时刚接触网络编程这块,如果在设计上有什么不合理的地方或者文中名词使用有什么问题也希望各位大佬们可以指出。
HTTP定义了不同的客户端和服务器交互方式,每种方法有不同的协议格式,通过某种方法向服务器发送请求,服务器收到请求,根据不同的方法进行处理,处理完后返回HTTP响应。
项目支持的方法GET POST
首先说明两个概念
URI统一资源标识符,用来唯一的标识一个资源
URL是统一资源定位符,用于描述一个网络上的资源。
cgi是外部应用程序与服务器的接口标准,客户端通过请求服务器和其他程序进行交互,这个过程对客户端透明。
GET 获取资源
GET方法用来请求已被URI识别的资源。指定的资源经服务器端解析后返回响应内容(也就是说,如果请求的资源是文本,那就保持原样返回;如果是CGI[通用网关接口]那样的程序,则返回经过执行后的输出结果)主要用于信息获取,GET方法无正文,可以在URI中添加参数传递消息(其有长度限制)。
POST 传输实体主体
一般传递表单或者传递文件采用post方法,其传递的信息放在正文中。其处理均为cgi处理。
还有一些请求方法项目不实现,
HEAD:获得报文首部 HEAD方法和GET方法一样,只是不返回报文的主体部分,用于确认URI的有效性及资源更新的日期时间等
PUT:传输文件
DELETE:删除文件 指明客户端想让服务器删除某个资源,与PUT方法相反,按URI删除指定资源
200 OK 客户端请求被正确处理了
204 NO Content 响应被正确处理, 响应信息无正文
206 Partial Content 客户端对服务器进行了范围请求,服务器返回指定的实体内容,一般如视频请求某一位置的内容
301 Moved Permanently 永久重定向,表示请求的资源被分配了新的URI,下会请求会直接请求新的URI
302 Found 临时重定向, 表示请求资源被临时分配了新的URI, 下会请求仍然用以前的URI
400 Bad Request 请求的报文有语法错误
404 Not Found 很常见的状态码 服务器无请求的资源
500 Internal Server Error 服务器在执行时发生错误
在这介绍来两个项目中使用的:
Content-Length 表示正文长度,HTTP时基于TCP的一种协议,而TCP是面向字节流的传输方式,为了找到正文和下个报文的界限,所以在HTTP首部字段中添加了正文长度,在正文前面添加空行区分正文和TCP首部,首部中每一项通过换行区分,其中第一行为请求行。
Content-Type内容类型, 用于定义网络文件的类型和网页的编码, 编写时可以查看这个对照表
上面基本上就是HTTP服务器所用到的相关知识,下面就可以开始分析服务器的编写。
首先HTTP服务器时基于TCP协议的所以我们需要为HTTP服务器构建一个TCP连接,用于接受客户端的连接,和发送响应。然后是我们收到了连接该如何处理,我最初在写时采用了多线程的方式,即在连接到来时为该连接创建一个线程,然后让该线程去处理该连接,处理完线程释放。这种方式编写比较简单但是线程会频繁的创建和释放造成了资源的浪费。于是采用了第二种方法版本1即采用线程池加任务队列的方式。即主线程负责连接管理,当受到一个客户端的连接的时候将这个连接加入到任务队列中,线程池中的工作线程在任务队列中获取连接,调用处理函数,处理完重新获取,另外在线程池中添加了条件变量,当查看到任务队列中无任务(即为空时)则线程会挂起的等待,可以减少CPU的消耗。(可以先看下面的业务处理然后看版本2方法)在版本1中存在一个问题就是在工作线程处理CGI会子进程进行处理,过程是先建立两个管道分别用于收发,然后产生子进程,父进程通过管道将参数发送给子进程(如果有参数,并且两个管道被重定向到子进程的标准输入和标准输出),子进程通过管道接收参数进行处理,而此时父进程也就是这个工作线程一直在此阻塞等待子进程处理完返回结果。版本2就是通过epoll结局这个问题,在版本2中主线程负责连接。将监听套接字添加到epoll中监听可读事件,如果触发则表示有新连接,此时accept然后将新连接加入到epoll中监听可读等待对方发送请求。并且在事件池中添加一个事件。即该描述符和对应的回调处理方法。当该描述可读时将其加入到线程池就绪队列中(此时该队列中均为可直接处理的事件)同版本1线程池获取事件调用其回调方法。当处理CGI 时,此时工作线程发送完参数不进行等待,而是将该管道文件描述符添加到epoll中同时在事件池中添加该事件,然后当子进程处理完返回结果epoll监听到可读将其添加到就绪队列,等待工作线程处理。不过在改完后发现一个问题就是,当CGI程序消耗时间特别短时,用这种方法反而会速度比较慢,因为这种方法需要控制事件池的互斥操作还有epoll进行监听然后添加事件会消耗时间,当消耗的这个时间大于CGI程序运行时间这种方法反而不好。最后还有一个就是现在的CGI程序进程创建和释放会频繁会导致资源浪费,随后我会对这个方面进行修改。
版本1
版本2
请求业务的处理:
先忽略CGI
当收到请求时,首先应该对其进行读取(对报文的读取和回复报文我封装了一个类Connect所有有关的方法均在次类实现) 因为请求报文请求行和报头均用换行结束,所以可以封装一个接口就是读取一行,后面读取直接调用该方法完成,
bool Connect::ReadOneLine(std::string & str)
在读取时需要注意一个地方就是换行问题,有的客户端换行使用\n有的采用\r 有的采用\r\n所以在读取时如果遇到\n就直接结束,遇到\r时就需要通过recv的探测功能,即只查看不读取,如果下一个是\n则读取,否则不读。
读取请求行直接调用该方法,然后对请求行进行解析(报文解析在Request类中完成)请求行由三个部分组成,分别为请求方法、URI、版本号,我们在这里要通过请求方法来决定后续的读取,如果请求方法是GET则无正文,但是要判断其URI中是否含有参数(有的话将参数和资源路径分离,没有的话只需要分离出资源路径),如果是其他方法我们暂时认为该报文有问题,暂时只支持这两个方法。
接着判断路径是否合法(对服务器资源的操作封装了一个类Resource类)该类用于判断路径的合法性了还有判断响应文件类型,这个类型就是Content-Type中需要填充的。用stat函数来获取文件的属性。
然后开始读取请求报头,因为正文和请求报头通过空行分割所以在读取时我们只要读到空行就表示读完了。然后对报头进行解析,因为报头中参数均为key value方式所以我们采用unordered_map存储,之后判断是否有正文(通过请求方法)如果有正文则读取。
到这对请求的读取和解析就完成了,此时就需要进行构建响应报文并回复(构建响应报文方法在Replay类中,响应分四部分组成,首先是响应的状态行,第一个是版本我们统一为HTTP/1.0, 然后是状态码这个状态码通过前面解析和读取时设置,之后就是状态码的描述,和状态码对应。然后是响应报头的构建,
响应报头需要两个内容Content-Length 和Content-Type,这两个均在解析过程中保存到了Resource类中,此时只需要读取即可,然后加上空行,因为考虑到如果此时将需要的文件读入内存在发送时调用send写入发送缓存区会造成多次拷贝,则在这里不进行构建正文,而是在发送时采用sendfile发送正文部分,可以减少拷贝,到此响应报文构建完成然后直接发送即可。
在这整个过程中,将以上几个类采用has-a的方式将类Connect、Request、Replay、Resource 均实例化到Handler中,然后调用它们的方式实现整个流程。在版本2中对handler封装了两个静态成员用于事件回调
关于CGI的处理:
因为项目中只实现了两个方法即GET和POST所以在判断CGI时,首先判断使用方式,如果是POST方法其肯定传递正文(如果报文正常)那么就是需要进行交互处理则是CGI方式,如果是GET方式则判断其URI如果有参数则同POST 如果没有则判断请求资源类型,如果资源为可执行程序则也是CGI方式处理。
在处理CGI时,首先创建两个匿名管道用于接受和发送然后创建子进程,父进程通过管道将参数发送给子进程(如果有参数,并且两个管道被重定向到子进程的标准输入和标准输出),子进程通过管道接收参数进行处理然后将结果发送到另一个管道,此时父进程读取该管道内容然后构建正文和响应报头中的部分字段。
其他方面