【呕心沥血5万两千字】一篇文章带你实现高性能Web服务器

编程入门 行业动态 更新时间:2024-10-08 13:38:46

【<a href=https://www.elefans.com/category/jswz/34/1768986.html style=呕心沥血5万两千字】一篇文章带你实现高性能Web服务器"/>

【呕心沥血5万两千字】一篇文章带你实现高性能Web服务器

前言

       本博客是介绍从零开始自主实现一个Linux下的轻量级Web服务器,项目中将会通过基本的网络套接字读取客户端发来的HTTP请求并进行分析,最终构建HTTP响应并返回给客户端。HTTP在网络应用层中的地位是不可撼动的,无论是移动端还是PC端浏览器,HTTP无疑是打开互联网应用窗口的重要协议。本项目采用CS模型自主实现了基于短链接方式的HTTP服务器,目前支持GET,POST方法,主要目的在于理解HTTP协议的处理资源的过程。所实现的服务器通过Socket套接字读取客户端发来的HTTP请求并进行分析,构建HTTP响应并返回给客户端。引入了CGI机制,服务器能够通过表单获取到客户端提交的信息,转交给服务器的CGI程序进行信息处理,提取信息,最后返回结果给客户端。引入了日志信息,将服务器启动的信息按照固定格式输出在标准输出当中。

         目前又引入了Reactor模式,通过Epoll机制和线程池实现了服务端的高性能。通过单线程I/O多路复用,可以达到高效并发,同时避免了多线程I/O来回切换的各种开销,思路清晰,易于管理,而基于线程池的多工作者线程,又可以充分发挥和利用多线程的优势,利用线程池,进一步提高资源复用性和避免产生过多线程。

技术栈:主要涉及C/C++、HTTP协议、 Socket套接字编程、CGI、单例模式、线程池,进程间通信(管道),哈希表等STL容器。

开发环境 centos 7 + vim/gcc/gdb + C/C++

目录

目录

前言

1、项目背景

1.1网络协议栈介绍

1.2、项目背景

1.3 、URI & URL & URN

2、HTTP协议

2.1HTTP协议格式

2.2、HTTP请求方法

2.3、HTTP的状态码

2.4、HTTP常见的Header 

3、CGI机制

3.1、CGI机制的概念

3.2、CGI机制的实现步骤

3.3、CGI机制的意义

4、HTTP服务器实现

4.1套接字相关代码编写

4.2、日志编写 

 4.3、HTTP服务器主体逻辑实现

4.3.1、HttpServer的实现

4.3.2、主函数的的实现

 4.3.3、HTTP请求结构设计

 4.3.4、EndPoint类编写

4.3.5、设计线程回调

4.3.6、读取HTTP请求

4.3.7、处理HTTP请求

 4.3.8、处理CGI请求

 4.3.9、处理非CGI请求

 4.3.10、构建HTTP响应

4.3.11、发送HTTP响应 

4.3.12、读取错误

 5、接入线程池

 5.1为什么要接入线程池

5.2、实现线程池

6、项目测试

6.1、首页请求测试

6.2、 错误请求测试

6.3、上传数据测试 

6.3.1、GET方法测试

6.3.2、POST方法测试 

7、项目扩展 

7.1项目优化

 7.1.1引入Reactor模式

​7.1.2项目优化及代码实现



1、项目背景

1.1网络协议栈介绍

 网络协议栈的分层情况如下:

网络协议栈中各层的功能如下:

  • 应用层:根据特定的通信目的,对数据进行分析处理,以达到某种业务性的目的。
  • 传输层:处理传输时遇到的问题,主要是保证数据传输的可靠性。
  • 网络层:完成数据的转发,解决数据去哪里的问题。
  • 链路层:负责数据真正的发生过程。

 数据的封装与分用

也就是说,发送端在发生数据前,该数据需要先自顶向下贯穿网络协议栈完成数据的封装,在这个过程中,每一层协议都会为该数据添加上对应的报头信息。接收端在收到数据后,该数据需要先自底向上贯穿网络协议栈完成数据的解包和分用,在这个过程中,每一层协议都会将对应的报头信息提取出来。

而本项目要做的就是,在接收到客户端发来的HTTP请求后,将HTTP的报头信息提取出来,然后对数据进行分析处理,最终将处理结果添加上HTTP报头再发送给客户端。

需要注意的是,该项目中我们所处的位置是应用层,因此我们读取的HTTP请求实际是从传输层读取上来的,而我们发送的HTTP响应实际也只是交给了传输层,数据真正的发送还得靠网络协议栈中的下三层来完成,这里直接说“接收到客户端的HTTP请求”以及“发送HTTP响应给客户端”,只是为了方便大家理解,此外,同层协议之间本身也是可以理解成是在直接通信的。
 

1.2、项目背景

 在本次项目中实现的是  HTTP/1.0,目前主流的服务器协议是 HTTP/1.1,而我们这次要实现的是1.0,其主要的特点就是短链接,所谓短链接,就是请求,相应,客户端关闭连接,这样就完成了一次HTTP请求,使用其主要的原因是因为本项目的主要目的是理解HTTP协议请求与响应的过程。

HTTP/1.0的主要特征如下:

1.客户端服务器模式(CS,BS): 在一条通信线路上必定有一端是客户端,另一端是服务器端,请求从客户端发出,服务器响应请求并返回。

2.简单快速,HTTP服务器的程序规模小,因而通信速度很快。

3.灵活,HTTP允许传输任意类型的数据对象,正在传输的类型由Content-Type加以标记。

4.无连接,每次连接只处理一个请求。服务器处理完客户的请求,并收到客户的应答后,即断开连接。采用 这种方式可以节省传输时间。(http/1.0具有的功能,http/1.1兼容)。

5.无状态 本身是不会记录对方任何状态。

HTTP 协议每当有新的请求产生,就会有对应的新响应产生。协议本身并不会保留你之前的一切请求或者响应,这是为 了更快的处理大量的事务,确保协议的可伸缩性。可是,随着 web 的发展,因为无状态而导致业务处理变的棘手起来。比如保持用户的登陆状态。  HTTP/1.1 虽然也是无状态的协议,但是为了保持状态的功能,引入了 cookie技术

1.3 、URI & URL & URN

URI、URL、URN的定义如下:

    URI(Uniform Resource Indentifier)统一资源标识符:用来唯一标识资源。
    URL(Uniform Resource Locator)统一资源定位符:用来定位唯一的资源。
    URN(Uniform Resource Name)统一资源名称:通过名字来标识资源,比如mailto:java-net@java.sun。

URI 是以一种抽象的,高层次概念定义统一资源标识,而 URL 和 URN 则是具体的资源标识的方式。 URL 和 URN 都是一 种 URI. URL 是 URI 的子集。任何东西,只要能够唯一地标识出来,都可以说这个标识是 URI 。如果这个标识是一个可获取到上述对象的路径,那么同时它也可以是一个 URL ;但如果这个标识不提供获取到对象的路径,那么它就必然不是 URL 。  

浏览器URL格式

 

简单说明

    http://表示的是协议名称,表示请求时需要使用的协议,通常使用的是HTTP协议或安全协议HTTPS。
    user:pass表示的是登录认证信息,包括登录用户的用户名和密码。(可省略)
    www.example.jp表示的是服务器地址,通常以域名的形式表示。
    80表示的是服务器的端口号。 (可省略)
    /dir/index.html表示的是要访问的资源所在的路径 (/表示的是web根目录)。
    uid=1表示的是请求时通过URL传递的参数,这些参数以键值对的形式通过&符号分隔开。(可省略)
    ch1表示的是片段标识符,是对资源的部分补充。(可省略)

注意:

    如果访问服务器时没有指定要访问的资源路径,那么浏览器会自动帮我们添加/,但此时仍然没有指明要访问web根目录下的哪一个资源文件,这时默认访问的是目标服务的首页。
    大部分URL中的端口号都是省略的,因为常见协议对应的端口号都是固定的,比如HTTP、HTTPS和SSH对应的端口号分别是80、443和22,在使用这些常见协议时不必指明协议对应的端口号,浏览器会自动帮我们进行填充。
 

2、HTTP协议

2.1HTTP协议格式

HTTP请求协议格式如下:

HTTP请求由以下四部分组成:

    请求行:[请求方法] + [URI] + [HTTP版本]。
    请求报头:请求的属性,这些属性都是以key: value的形式按行陈列的。
    空行:遇到空行表示请求报头结束。
    请求正文:请求正文允许为空字符串,如果请求正文存在,则在请求报头中会有一个Content-Length属性来标识请求正文的长度。
 

HTTP响应协议格式如下:

HTTP响应由以下四部分组成:

    状态行:[HTTP版本] + [状态码] + [状态码描述]。
    响应报头:响应的属性,这些属性都是以key: value的形式按行陈列的。
    空行:遇到空行表示响应报头结束。
    响应正文:响应正文允许为空字符串,如果响应正文存在,则在响应报头中会有一个Content-Length属性来标识响应正文的长度。
 

 HTTP请求与响应过程如下图所示

2.2、HTTP请求方法

方法说明支持的HTTP协议版本
GET获取资源1.0、1.1
POST传输实体主体1.0、1.1
PUT传输文件1.0、1.1
HEAD获得报文首部1.0、1.1
DELETE删除文件1.0、1.1
OPTIONS询问支持的方法1.1
TRACE追踪路径1.1
CONNECT要求用隧道协议连接代理1.1
LINK建立和资源之间的联系1.0
UNLINK断开连接关系1.0

 GET方法和POST方法

HTTP的请求方法中最常用的就是GET方法和POST方法,其中GET方法一般用于获取某种资源信息,而POST方法一般用于将数据上传给服务器,但实际GET方法也可以用来上传数据,比如百度搜索框中的数据就是使用GET方法提交的。

GET方法和POST方法都可以带参,其中GET方法通过URL传参,POST方法通过请求正文传参。由于URL的长度是有限制的,因此GET方法携带的参数不能太长,而POST方法通过请求正文传参,一般参数长度没有限制。

2.3、HTTP的状态码

HTTP状态码是用来表示服务器HTTP响应状态的3位数字代码,通过状态码可以知道服务器端是否正确的处理了请求,以及请求处理错误的原因。

HTTP的状态码如下:

类别原因短语
1XXInformational(信息性状态码)接收的请求正在处理
2XXSuccess(成功状态码)请求正常处理完毕
3XXRedirection(重定向状态码)需要进行附加操作以完成请求
4XXClient Error(客户端错误状态码)服务器无法处理请求
5XXServer Error(服务器错误状态码)服务器处理请求出错

常见的状态码如下:

状态码状态码描述说明
200OK请求正常处理完毕
204No Content请求正常处理完毕,但响应信息中没有响应正文
206Partial Content请求正常处理完毕,客户端对服务器进行了范围请求,响应报文中包含由Content-Range指定的实体内容范围
301Moved Permanently永久性重定向:请求的资源已经被分配了新的URI,以后应使用新的URI,也就是说,如果之前将老的URI保存为书签了,后面应该按照响应的Location首部字段重新保存书签
302Found临时重定向:目标资源被分配了新的URI,希望用户本次使用新的URI进行访问
307Temporary Redirect临时重定向:目标资源被分配了新的URI,希望用户本次使用新的URI进行访问
400Bad Request请求报文中存在语法错误,需修改请求内容重新发送(浏览器会像200 OK一样对待该状态码)
403Forbidden浏览器所请求的资源被服务器拒绝了。服务器没有必要给出详细的理由,如果想要说明,可以在响应实体内部进行说明
404Not Found浏览器所请求的资源不存在
500Internal Server Error服务器端在执行的时候发生了错误,可能是Web本身存在的bug或者临时故障
503Server Unavailable服务器目前处于超负荷或正在进行停机维护状态,目前无法处理请求。这种情况下,最好写入Retry-After首部字段再返回给客户端

2.4、HTTP常见的Header 

HTTP常见的Header如下:

    Content-Type:数据类型(text/html等)。
    Content-Length:正文的长度。
    Host:客户端告知服务器,所请求的资源是在哪个主机的哪个端口上。
    User-Agent:声明用户的操作系统和浏览器的版本信息。
    Referer:当前页面是哪个页面跳转过来的。
    Location:搭配3XX状态码使用,告诉客户端接下来要去哪里访问。
    Cookie:用户在客户端存储少量信息,通常用于实现会话(session)的功能。
 

3、CGI机制

3.1、CGI机制的概念

CGI(Common Gateway Interface,通用网关接口)是一种重要的互联网技术,可以让一个客户端,从网页浏览器向执行在网络服务器上的程序请求数据。CGI描述了服务器和请求处理程序之间传输数据的一种标准。

实际我们在进行网络请求时,无非就两种情况:

  • 浏览器想从服务器上拿下来某种资源,比如打开网页、下载等。
  • 浏览器想将自己的数据上传至服务器,比如上传视频、登录、注册等。

通常从服务器上获取资源对应的请求方法就是GET方法,而将数据上传至服务器对应的请求方法就是POST方法,但实际GET方法有时也会用于上传数据,只不过POST方法是通过请求正文传参的,而GET方法是通过URL传参的。

而用户将自己的数据上传至服务器并不仅仅是为了上传,用户上传数据的目的是为了让HTTP或相关程序对该数据进行处理,比如用户提交的是搜索关键字,那么服务器就需要在后端进行搜索,然后将搜索结果返回给浏览器,再由浏览器对HTML文件进行渲染刷新展示给用户。

但实际对数据的处理与HTTP的关系并不大,而是取决于上层具体的业务场景的,因此HTTP不对这些数据做处理。但HTTP提供了CGI机制,上层可以在服务器中部署若干个CGI程序,这些CGI程序可以用任何程序设计语言编写,当HTTP获取到数据后会将其提交给对应CGI程序进行处理,然后再用CGI程序的处理结果构建HTTP响应返回给浏览器。
 

何时需要使用CGI模式?

只要用户请求服务器时上传了数据,那么服务器就需要使用CGI模式对用户上传的数据进行处理,而如果用户只是单纯的想请求服务器上的某个资源文件则不需要使用CGI模式,此时直接将用户请求的资源文件返回给用户即可。

此外,如果用户请求的是服务器上的一个可执行程序,说明用户想让服务器运行这个可执行程序,此时也需要使用CGI模式。
 

3.2、CGI机制的实现步骤

一、创建子进程进行程序替换

服务器获取到新连接后一般会创建一个新线程为其提供服务,而要执行CGI程序一定需要调用exec系列函数进行进程程序替换,但服务器创建的新线程与服务器进程使用的是同一个进程地址空间,如果直接让新线程调用exec系列函数进行进程程序替换,此时服务器进程的代码和数据就会直接被替换掉,相当于HTTP服务器在执行一次CGI程序后就直接退出了,这肯定是不合理的。因此新线程需要先调用fork函数创建子进程,然后让子进程调用exec系列函数进行进程程序替换。

 二、完成管道通信信道的建立

调用CGI程序的目的是为了让其进行数据处理,因此我们需要通过某种方式将数据交给CGI程序,并且还要能够获取到CGI程序处理数据后的结果,也就是需要进行进程间通信。因为这里的服务器进程和CGI进程是父子进程,因此优先选择使用匿名管道。

由于父进程不仅需要将数据交给子进程,还需要从子进程那里获取数据处理的结果,而管道是半双工通信的,为了实现双向通信于是需要借助两个匿名管道,因此在创建调用fork子进程之前需要先创建两个匿名管道,在创建子进程后还需要父子进程分别关闭两个管道对应的读写端。

三、完成重定向相关的设置

创建用于父子进程间通信的两个匿名管道时,父子进程都是各自用两个变量来记录管道对应读写端的文件描述符的,但是对于子进程来说,当子进程调用exec系列函数进行程序替换后,子进程的代码和数据就被替换成了目标CGI程序的代码和数据,这也就意味着被替换后的CGI程序无法得知管道对应的读写端,这样父子进程之间也就无法进行通信了。

需要注意的是,进程程序替换只替换对应进程的代码和数据,而对于进程的进程控制块、页表、打开的文件等内核数据结构是不做任何替换的。因此子进程进行进程程序替换后,底层创建的两个匿名管道仍然存在,只不过被替换后的CGI程序不知道这两个管道对应的文件描述符罢了。

这时我们可以做一个约定:被替换后的CGI程序,从标准输入读取数据等价于从管道读取数据,向标准输出写入数据等价于向管道写入数据。这样一来,所有的CGI程序都不需要得知管道对应的文件描述符了,当需要读取数据时直接从标准输入中进行读取,而数据处理的结果就直接写入标准输出就行了。

当然,这个约定并不是你说有就有的,要实现这个约定需要在子进程被替换之前进行重定向,将0号文件描述符重定向到对应管道的读端,将1号文件描述符重定向到对应管道的写端。

四、父子进程交付数据

时父子进程已经能够通过两个匿名管道进行通信了,接下来就应该讨论父进程如何将数据交给CGI程序,以及CGI程序如何将数据处理结果交给父进程了。

父进程将数据交给CGI程序:

    如果请求方法为GET方法,那么用户是通过URL传递参数的,此时可以在子进程进行进程程序替换之前,通过putenv函数将参数导入环境变量,由于环境变量也不受进程程序替换的影响,因此被替换后的CGI程序就可以通过getenv函数来获取对应的参数。
    如果请求方法为POST方法,那么用户是通过请求正文传参的,此时父进程直接将请求正文中的数据写入管道传递给CGI程序即可,但是为了让CGI程序知道应该从管道读取多少个参数,父进程还需要通过putenv函数将请求正文的长度导入环境变量。

说明一下: 请求正文长度、URL传递的参数以及请求方法都比较短,通过写入管道来传递会导致效率降低,因此选择通过导入环境变量的方式来传递。

也就是说,使用CGI模式时如果请求方法为POST方法,那么CGI程序需要从管道读取父进程传递过来的数据,如果请求方法为GET方法,那么CGI程序需要从环境变量中获取父进程传递过来的数据。

但被替换后的CGI程序实际并不知道本次HTTP请求所对应的请求方法,因此在子进程在进行进程程序替换之前,还需要通过putenv函数将本次HTTP请求所对应的请求方法也导入环境变量。因此CGI程序启动后,首先需要先通过环境变量得知本次HTTP请求所对应的请求方法,然后再根据请求方法对应从管道或环境变量中获取父进程传递过来的数据。

CGI程序读取到父进程传递过来的数据后,就可以进行对应的数据处理了,最终将数据处理结果写入到管道中,此时父进程就可以从管道中读取CGI程序的处理结果了。


3.3、CGI机制的意义

处理HTTP请求的步骤如下:

    判断请求方法是GET方法还是POST方法,如果是GET方法带参或POST方法则进行CGI处理,如果是GET方法不带参则进行非CGI处理。
    非CGI处理就是直接根据用户请求的资源构建HTTP响应返回给浏览器。
    CGI处理就是通过创建子进程进行程序替换的方式来调用CGI程序,通过创建匿名管道、重定向、导入环境变量的方式来与CGI程序进行数据通信,最终根据CGI程序的处理结果构建HTTP响应返回给浏览器。

    CGI机制的意义

    CGI机制就是让服务器将获取到的数据交给对应的CGI程序进行处理,然后将CGI程序的处理结果返回给客户端,这显然让服务器逻辑和业务逻辑进行了解耦,让服务器和业务程序可以各司其职。
    CGI机制使得浏览器输入的数据最终交给了CGI程序,而CGI程序输出的结果最终交给了浏览器。这也就意味着CGI程序的开发者,可以完全忽略中间服务器的处理逻辑,相当于CGI程序从标准输入就能读取到浏览器输入的内容,CGI程序写入标准输出的数据最终就能输出到浏览器。

4、HTTP服务器实现

4.1套接字相关代码编写

我们可以将套接字相关的代码封装到TcpServer类中,在初始化TcpServer对象时完成套接字的创建、绑定和监听动作,并向外提供一个Sock接口用于获取监听套接字。

此外,可以将TcpServer设置成单例模式:

    将TcpServer类的构造函数设置为私有,并将拷贝构造和拷贝赋值函数设置为私有或删除,防止外部创建或拷贝对象。
    提供一个指向单例对象的static指针,并在类外将其初始化为nullptr。
    提供一个全局访问点获取单例对象,在单例对象第一次被获取的时候就创建这个单例对象并进行初始化。
 

#pragma once#include<iostream>
#include<stdlib.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<cstring>
#include<netinet/in.h>//定义数据结构sockaddr_in
#include<arpa/inet.h> //提供IP地址转换函数
#include<pthread.h>
#include<unistd.h>
#include"Log.hpp"
#define BACKLOG 5class TcpServer//采用单例懒汉模式
{private:int port;int listen_sock;static TcpServer *svr;private:TcpServer(int _port):port(_port),listen_sock(-1){}TcpServer(const TcpServer &s){}public:static TcpServer *getinstance(int port){//定义一把锁static pthread_mutex_t lock =PTHREAD_MUTEX_INITIALIZER;//用宏对锁初始化if(nullptr==svr){pthread_mutex_lock(&lock);if(nullptr==svr){svr=new TcpServer(port);svr->InitServer();}pthread_mutex_unlock(&lock);}return svr;}//初始化void InitServer(){Socket();Bind();Listen();LOG(INFO,"tcp_server init... success!");}//创建套接字void Socket(){listen_sock=socket(AF_INET,SOCK_STREAM,0);//创建失败if(listen_sock<0){LOG(FATAL,"socket error!");exit(1);}//地址复载,保证服务器宕机时,重启成功int opt=1;setsockopt(listen_sock,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt));LOG(INFO,"create socket... success!");}//绑定,将用户设置的ip和port在内核中和我们的进程强关联void Bind(){struct sockaddr_in local;memset(&local,0,sizeof(local));//要用来定义是哪种地址族local.sin_family=AF_INET;//调用htos()把主机端口号转为网络类型local.sin_port=htons(port);//云服务器不能直接绑定公网IP,让服务器自动绑定local.sin_addr.s_addr=INADDR_ANY;//绑定失败if(bind(listen_sock,(struct sockaddr*)&local,sizeof(local))<0){LOG(FATAL,"bind error!");exit(2);}LOG(INFO,"bind socket... success!");}//监听void Listen(){//第一个参数: int sock: 希望进入等待连接请求状态的套接字文件描述符// ,传递的描述符套接字参数成为服务器端套接字(监听套接字!!!!)//第二个参数: int backlog: 连接请求等待队列的长度,若为5,则队列长度为5,//表示最多使5个连接请求进入队列,其中也包含排队建立3次握手队列和刚刚建立3次握手队列的连接数之和if(listen(listen_sock,BACKLOG)<0){LOG(FATAL,"listen error!");exit(3);}LOG(INFO,"listen socket... success!");}int Sock(){return listen_sock;}~TcpServer(){if(listen_sock>=0)close(listen_sock);}
};TcpServer * TcpServer::svr= nullptr;//静态成员类外类外初始化

说明一下:

    如果使用的是云服务器,那么在设置服务器的IP地址时,不需要显式绑定IP地址,直接将IP地址设置为INADDR_ANY即可,此时服务器就可以从本地任何一张网卡当中读取数据。此外,由于INADDR_ANY本质就是0,因此在设置时不需要进行网络字节序列的转换。
    在第一次调用GetInstance获取单例对象时需要创建单例对象,这时需要定义一个锁来保证线程安全,代码中以PTHREAD_MUTEX_INITIALIZER的方式定义的静态的锁是不需要释放的,同时为了保证后续调用GetInstance获取单例对象时不会频繁的加锁解锁,因此代码中以双检查的方式进行加锁。

4.2、日志编写 

日志说明:

    日志级别: 分为四个等级,从低到高依次是INFO、WARNING、ERROR、FATAL。
    时间戳: 事件产生的时间。
    日志信息: 事件产生的日志信息。
    错误文件名称: 事件在哪一个文件产生。
    行数: 事件在对应文件的哪一行产生。

日志级别说明:

    INFO: 表示正常的日志输出,一切按预期运行。
    WARNING: 表示警告,该事件不影响服务器运行,但存在风险。
    ERROR: 表示发生了某种错误,但该事件不影响服务器继续运行。
    FATAL: 表示发生了致命的错误,该事件将导致服务器停止运行。
我们可以针对日志编写一个输出日志的Log函数,该函数的参数就包括日志级别、日志信息、错误文件名称、错误的行数。如下:

#pragma once#include <iostream>
#include <string>
#include <ctime>#define INFO    1
#define WARNING 2
#define ERROR   3
#define FATAL   4#define LOG(level, message) Log(#level, message, __FILE__, __LINE__)void Log(std::string level, std::string message, std::string file_name, int line)
{std::cout << "[" << level << "]" << "[" << time(nullptr) << "]" << "[" << message << "]" << "[" << file_name << "]" << "[" << line << "]" << std::endl;
}

通过C语言中的预定义符号__FILE____LINE__,分别可以获取当前文件的名称和当前的行数,但最好在调用Log函数时不用调用者显示的传入__FILE____LINE__,因为每次调用Log函数时传入的这两个参数都是固定的。

LOG(INFO, "This is a demo"); //LOG使用示例

 4.3、HTTP服务器主体逻辑实现

4.3.1、HttpServer的实现

我们可以将HTTP服务器封装成一个HttpServer类,在构造HttpServer对象时传入一个端口号,之后就可以调用Loop让服务器运行起来了。服务器运行起来后要做的就是,先获取单例对象TcpServer中的监听套接字,然后不断从监听套接字中获取新连接,每当获取到一个新连接后就创建一个新线程为该连接提供服务。

#pragma once#include<iostream>
#include<pthread.h>
#include <signal.h>
#include"Log.hpp"
#include"TcpServer.hpp"
#include"Task.hpp"
#include"ThreadPool.hpp"#define PORT 8081class HttpServer{private:int port;bool stop;public:HttpServer(int _port = PORT): port(_port),stop(false){}void InitServer(){//信号SIGPIPE需要进行忽略,如果不忽略,在写入时候,可能直接崩溃serversignal(SIGPIPE, SIG_IGN); }void Loop(){TcpServer *tsvr = TcpServer::getinstance(port);LOG(INFO, "Loop begin");while(!stop){struct sockaddr_in peer;socklen_t len = sizeof(peer);int sock = accept(tsvr->Sock(), (struct sockaddr*)&peer, &len);if(sock < 0){continue;}LOG(INFO, "Get a new link");Task task(sock);ThreadPool::getinstance()->PushTask(task);}}~HttpServer(){}//thread_pool.PushTask(task);//分离线程//分离线程,他的存储器资源在他终止的时候可以由系统自动释放//1.默认情况下,新创建的线程是joinable的,线程退出后,需对其进行pthread_join操作,否则无法释放资源,从而造成系统泄露//2.如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源};

说明一下:

    服务器需要将新连接对应的套接字作为参数传递给新线程,为了避免该套接字在新线程读取之前被下一次获取到的套接字覆盖,因此在传递套接字时最好重新new一块空间来存储套接字的值。
    新线程创建后可以将新线程分离,分离后主线程继续获取新连接,而新线程则处理新连接发来的HTTP请求,代码中的HandlerRequest函数就是新线程处理新连接时需要执行的回调函数。


4.3.2、主函数的的实现

主函数逻辑

   运行服务器时要求指定服务器的端口号,我们用这个端口号创建一个HttpServer对象,然后调用Loop函数运行服务器,此时服务器就会不断获取新连接并创建新线程来处理连接。

#include <iostream>
#include <string>
#include <memory>
#include "HttpServer.hpp"static void Usage(std::string proc)
{std::cout << "Usage:\n\t" << proc << " port" << std::endl;;
}int main(int argc, char *argv[])
{if( argc != 2 ){Usage(argv[0]);exit(4);}int port = atoi(argv[1]);std::shared_ptr<HttpServer> http_server(new HttpServer(port));http_server->InitServer();http_server->Loop();return 0;
}

 4.3.3、HTTP请求结构设计

我们可以将HTTP请求封装成一个类,这个类当中包括HTTP请求的内容、HTTP请求的解析结果以及是否需要使用CGI模式的标志位。后续处理请求时就可以定义一个HTTP请求类,读取到的HTTP请求的数据就存储在这个类当中,解析HTTP请求后得到的数据也存储在这个类当中

//HTTP请求
class HttpRequest{public://HTTP请求内容std::string _request_line;                //请求行std::vector<std::string> _request_header; //请求报头std::string blank;                       //空行std::string request_body;                //请求正文//解析结果std::string method;       //请求方法std::string uri;          //URIstd::string version;      //版本号std::unordered_map<std::string, std::string> header_kv; //请求报头中的键值对int content_length;       //正文长度std::string path;         //请求资源的路径std::string suffix; //uri中携带的参数std::string query_string; // 参数int size;//CGI相关bool cgi; //是否需要使用CGI模式public:HttpRequest():_content_length(0) //默认请求正文长度为0,cgi(false)        //默认不使用CGI模式{}~HttpRequest(){}
};

 4.3.4、EndPoint类编写

EndPoint结构设计

//服务端EndPoint
class EndPoint{private:int _sock;                   //通信的套接字HttpRequest _http_request;   //HTTP请求HttpResponse _http_response;bool stop; 
//HTTP响应public:EndPoint(int sock):_sock(sock){}//读取请求void RecvHttpRequest();//处理请求void HandlerHttpRequest();//构建响应void BuildHttpResponse();//发送响应void SendHttpResponse();~EndPoint(){}
};

4.3.5、设计线程回调

 服务器每获取到一个新连接就会创建一个新线程来进行处理,而这个线程要做的实际就是定义一个EndPoint对象,然后依次进行读取请求、处理请求、构建响应、发送响应,处理完毕后将与客户端建立的套接字关闭即可。

class CallBack{public:static void* HandlerRequest(void* arg){LOG(INFO, "handler request begin");int sock = *(int*)arg;EndPoint* ep = new EndPoint(sock);ep->RecvHttpRequest();    //读取请求ep->HandlerHttpRequest(); //处理请求ep->BuildHttpResponse();  //构建响应ep->SendHttpResponse();   //发送响应close(sock); //关闭与该客户端建立的套接字delete ep;LOG(INFO, "handler request end");return nullptr;}
};

4.3.6、读取HTTP请求

 读取HTTP请求的同时可以对HTTP请求进行解析,这里我们分为五个步骤,分别是读取请求行、读取请求报头和空行、解析请求行、解析请求报头、读取请求正文。

//服务端EndPoint
class EndPoint{private:int _sock;                   //通信的套接字HttpRequest _http_request;   //HTTP请求HttpResponse _http_response; //HTTP响应public://读取请求void RecvHttpRequest(){RecvHttpRequestLine();    //读取请求行RecvHttpRequestHeader();  //读取请求报头和空行ParseHttpRequestLine();   //解析请求行ParseHttpRequestHeader(); //解析请求报头RecvHttpRequestBody();    //读取请求正文}
};

一、读取请求行

//服务端EndPoint
class EndPoint{private:int _sock;                   //通信的套接字HttpRequest _http_request;   //HTTP请求HttpResponse _http_response; //HTTP响应private://读取请求行void RecvHttpRequestLine(){auto& line = _http_request._request_line;if(Util::ReadLine(_sock, line) > 0){line.resize(line.size() - 1); //去掉读取上来的\n}}
};

 需要注意的是,这里在按行读取HTTP请求时,不能直接使用C/C++提供的gets或getline函数进行读取,因为不同平台下的行分隔符可能是不一样的,可能是\r、\n或者\r\n。

因此我们这里需要自己写一个ReadLine函数,以确保能够兼容这三种行分隔符。我们可以把这个函数写到一个工具类当中,后续编写的处理字符串的函数也都写到这个类当中。

ReadLine函数的处理逻辑如下:

    从指定套接字中读取一个个字符。
    如果读取到的字符既不是\n也不是\r,则将读取到的字符push到用户提供的缓冲区后继续读取下一个字符。
    如果读取到的字符是\n,则说明行分隔符是\n,此时将\npush到用户提供的缓冲区后停止读取。
    如果读取到的字符是\r,则需要继续窥探下一个字符是否是\n,如果窥探成功则说明行分隔符为\r\n,此时将未读取的\n读取上来后,将\npush到用户提供的缓冲区后停止读取;如果窥探失败则说明行分隔符是\r,此时也将\npush到用户提供的缓冲区后停止读取。

也就是说,无论是哪一种行分隔符,最终读取完一行后我们都把\npush到了用户提供的缓冲区当中,相当于将这三种行分隔符统一转换成了以\n为行分隔符,只不过最终我们把\n一同读取到了用户提供的缓冲区中罢了,因此如果调用者不需要读取上来的\n,需要后续自行将其去掉。

解析请求报头

 解析请求报头要做的就是将读取到的一行一行的请求报头,以: 为分隔符拆分成一个个的键值对存储到HTTP请求的header_kv中,后续就可以直接通过属性名获取到对应的值了。

此处用于切割字符串的CutString函数也可以写到工具类中,切割字符串时先通过find方法找到指定的分隔符,然后通过substr提取切割后的子字符串即可。

#pragma once#include<iostream>
#include<string>
#include<sys/types.h>
#include<sys/socket.h>class Util{
public://把结果都读到out里static int ReadLine(int sock,std::string&out){char ch='X';while(ch !='\n'){ssize_t s=recv(sock,&ch,1,0);if(s>0)//读取成功{if(ch=='\r'){recv(sock,&ch,1,MSG_PEEK);//窥探下一位是不是\nif(ch=='\n'){ //即这里为\r\n//窥探成功recv(sock,&ch,1,0);//再读取ch}else{ch='\n';}}out.push_back(ch);}else if(s==0){return 0;} else{return -1;}}return out.size();}//截取字符串,将报头信息进行截取static bool CutString(const std::string target,std::string &sub1_out,std::string &sub2_out,std::string sep){size_t pos=target.find(sep);//找分隔符位置if(pos!=std::string::npos){sub1_out=target.substr(0,pos);sub2_out=target.substr(pos+sep.size());//不标长度自动截取到结尾return true;}return false;}};

说明一下: recv函数的最后一个参数如果设置为MSG_PEEK,那么recv函数将返回TCP接收缓冲区头部指定字节个数的数据,但是并不把这些数据从TCP接收缓冲区中取走,这个叫做数据的窥探功能。

读取请求正文

在读取请求正文之前,首先需要通过本次的请求方法来判断是否需要读取请求正文,因为只有请求方法是POST方法才可能会有请求正文,此外,如果请求方法为POST,我们还需要通过请求报头中的Content-Length属性来得知请求正文的长度。

在得知需要读取请求正文以及请求正文的长度后,就可以将请求正文读取到HTTP请求类的request_body中了。
 

//服务端EndPoint
class EndPoint{private:int _sock;                   //通信的套接字HttpRequest _http_request;   //HTTP请求HttpResponse _http_response; //HTTP响应private://判断是否需要读取请求正文bool IsNeedRecvHttpRequestBody(){auto& method = _http_request._method;if(method == "POST"){ //请求方法为POST则需要读取正文auto& header_kv = _http_request._header_kv;//通过Content-Length获取请求正文长度auto iter = header_kv.find("Content-Length");if(iter != header_kv.end()){_http_request._content_length = atoi(iter->second.c_str());return true;}}return false;}//读取请求正文void RecvHttpRequestBody(){if(IsNeedRecvHttpRequestBody()){ //先判断是否需要读取正文int content_length = _http_request._content_length;auto& body = _http_request._request_body;//读取请求正文char ch = 0;while(content_length){ssize_t size = recv(_sock, &ch, 1, 0);if(size > 0){body.push_back(ch);content_length--;}else{break;}}}}
};

说明一下:

  • 由于后续还会用到请求正文的长度,因此代码中将其存储到了HTTP请求类的content_length中。
  • 在通过Content-Length获取到请求正文的长度后,需要将请求正文长度从字符串类型转换为整型。

4.3.7、处理HTTP请求

在处理请求的过程中可能会因为某些原因而直接停止处理,比如请求方法不正确、请求资源不存在或服务器处理请求时出错等等。为了告知客户端本次HTTP请求的处理情况,服务器需要定义不同的状态码,当处理请求被终止时就可以设置对应的状态码,后续构建HTTP响应的时候就可以根据状态码返回对应的错误页面。

#define OK 200
#define BAD_REQUEST 400
#define NOT_FOUND 404
#define INTERNAL_SERVER_ERROR 500

处理HTTP请求的步骤如下:

    判断请求方法是否是正确,如果不正确则设置状态码为BAD_REQUEST后停止处理。
    如果请求方法为GET方法,则需要判断URI中是否带参。如果URI不带参,则说明URI即为客户端请求的资源路径;如果URI带参,则需要以?为分隔符对URI进行字符串切分,切分后?左边的内容就是客户端请求的资源路径,而?右边的内容则是GET方法携带的参数,由于此时GET方法携带了参数,因此后续处理需要使用CGI模式,于是需要将HTTP请求类中的cgi设置为true。
    如果请求方法为POST方法,则说明URI即为客户端请求的资源路径,由于POST方法会通过请求正文上传参数,因此后续处理需要使用CGI模式,于是需要将HTTP请求类中的cgi设置为true。
    接下来需要对客户端请求的资源路径进行处理,首先需要在请求的资源路径前拼接上web根目录,然后需要判断请求资源路径的最后一个字符是否是/,如果是则说明客户端请求的是一个目录,这时服务器不会将该目录下全部的资源都返回给客户端,而是默认将该目录下的index.html返回给客户端,因此这时还需要在请求资源路径的后面拼接上index.html。
    对请求资源的路径进行处理后,需要通过stat函数获取客户端请求资源文件的属性信息。如果客户端请求的是一个目录,则需要在请求资源路径的后面拼接上/index.html并重新获取资源文件的属性信息;如果客户端请求的是一个可执行程序,则说明后续处理需要使用CGI模式,于是需要将HTTP请求类中的cgi设置为true。
    根据HTTP请求类中的cgi分别进行CGI或非CGI处理。
 

#define WEB_ROOT "wwwroot"
#define HOME_PAGE "index.html"//服务端EndPoint
class EndPoint{private:int sock;                   //通信的套接字HttpRequest http_request;   //HTTP请求HttpResponse http_response; //HTTP响应public://处理请求void HandlerHttpRequest(){auto& code = http_response.status_code;if(http_request._method != "GET"&&http_request._method != "POST"){ //非法请求LOG(WARNING, "method is not right");code = BAD_REQUEST; //设置对应的状态码,并直接返回return;}if(http_request._method == "GET"){size_t pos = http_request._uri.find('?');if(pos != std::string::npos){ //uri中携带参数//切割uri,得到客户端请求资源的路径和uri中携带的参数Util::CutString(http_request._uri, http_request._path, http_request._query_string, "?");http_request._cgi = true; //上传了参数,需要使用CGI模式}else{ //uri中没有携带参数http_request._path = http_request._uri; //uri即是客户端请求资源的路径}}else if(_http_request._method == "POST"){http_request._path = http_request._uri; //uri即是客户端请求资源的路径http_request._cgi = true; //上传了参数,需要使用CGI模式}else{//Do Nothing}//给请求资源路径拼接web根目录std::string path = http_request._path;http_request._path = WEB_ROOT;http_request._path += path;//请求资源路径以/结尾,说明请求的是一个目录if(http_request.path[http_request.path.size() - 1] == '/'){//拼接上该目录下的index.htmlhttp_request._path += HOME_PAGE;}//获取请求资源文件的属性信息struct stat st;if(stat(http_request.path.c_str(), &st) == 0){ //属性信息获取成功,说明该资源存在if(S_ISDIR(st.st_mode)){ //该资源是一个目录http_request.path += "/"; //需要拼接/,以/结尾的目录前面已经处理过了http_request.path += HOME_PAGE; //拼接上该目录下的index.htmlstat(http_request.path.c_str(), &st); //需要重新资源文件的属性信息}else if(st.st_mode&S_IXUSR||st.st_mode&S_IXGRP||st.st_mode&S_IXOTH){ //该资源是一个可执行程序http_request._cgi = true; //需要使用CGI模式}http_response.size = st.stsize; //设置请求资源文件的大小}else{ //属性信息获取失败,可以认为该资源不存在LOG(WARNING, http_request.path + " NOT_FOUND");code = NOT_FOUND; //设置对应的状态码,并直接返回return;}//获取请求资源文件的后缀size_t pos = _http_request._path.rfind('.');if(pos == std::string::npos){http_response._suffix = ".html"; //默认设置}else{http_response.suffix = http_request.path.substr(pos);}//进行CGI或非CGI处理if(http_request.cgi == true){code = ProcessCgi(); //以CGI的方式进行处理}else{code = ProcessNonCgi(); //简单的网页返回,返回静态网页}}
};

说明一下:

    本项目实现的HTTP服务器只支持GET方法和POST方法,因此如果客户端发来的HTTP请求中不是这两种方法则认为请求方法错误,如果想让服务器支持其他的请求方法则直接增加对应的逻辑即可。
    服务器向外提供的资源都会放在web根目录下,比如网页、图片、视频等资源,本项目中的web根目录取名为wwwroot。web根目录下的所有子目录下都会有一个首页文件,当用户请求的资源是一个目录时,就会默认返回该目录下的首页文件,本项目中的首页文件取名为index.html。
    stat是一个系统调用函数,它可以获取指定文件的属性信息,包括文件的inode编号、文件的权限、文件的大小等。如果调用stat函数获取文件的属性信息失败,则可以认为客户端请求的这个资源文件不存在,此时直接设置状态码为NOT_FOUND后停止处理即可。
    当获取文件的属性信息后发现该文件是一个目录,此时请求资源路径一定不是以/结尾的,因为在此之前已经对/结尾的请求资源路径进行过处理了,因此这时需要给请求资源路径拼接上/index.html。
    只要一个文件的拥有者、所属组、other其中一个具有可执行权限,则说明这是一个可执行文件,此时就需要将HTTP请求类中的cgi设置为true。
    由于后续构建HTTP响应时需要用到请求资源文件的后缀,因此代码中对请求资源路径通过从后往前找.的方式,来获取请求资源文件的后缀,如果没有找到.则默认请求资源的后缀为.html。
    由于请求资源文件的大小后续可能会用到,因此在获取到请求资源文件的属性后,可以将请求资源文件的大小保存到HTTP响应类的size中。

 4.3.8、处理CGI请求

CGI处理时需要创建子进程进行进程程序替换,但是在创建子进程之前需要先创建两个匿名管道。这里站在父进程角度对这两个管道进行命名,父进程用于读取数据的管道叫做input,父进程用于写入数据的管道叫做output。

创建匿名管道并创建子进程后,需要父子进程各自关闭两个管道对应的读写端:

    对于父进程来说,input管道是用来读数据的,因此父进程需要保留input[0]关闭input[1],而output管道是用来写数据的,因此父进程需要保留output[1]关闭output[0]。
    对于子进程来说,input管道是用来写数据的,因此子进程需要保留input[1]关闭input[0],而output管道是用来读数据的,因此子进程需要保留output[0]关闭output[1]。

此时父子进程之间的通信信道已经建立好了,但为了让替换后的CGI程序从标准输入读取数据等价于从管道读取数据,向标准输出写入数据等价于向管道写入数据,因此在子进程进行进程程序替换之前,还需要对子进程进行重定向。
 

假设子进程保留的input[1]和output[0]对应的文件描述符分别是3和4,那么子进程对应的文件描述符表的指向大致如下:

 现在我们要做的就是将子进程的标准输入重定向到output管道,将子进程的标准输出重定向到input管道,也就是让子进程的0号文件描述符指向output管道,让子进程的1号文件描述符指向input管道。

此外,在子进程进行进程程序替换之前,还需要进行各种参数的传递:

    首先需要将请求方法通过putenv函数导入环境变量,以供CGI程序判断应该以哪种方式读取父进程传递过来的参数。
    如果请求方法为GET方法,则需要将URL中携带的参数通过导入环境变量的方式传递给CGI程序。
    如果请求方法为POST方法,则需要将请求正文的长度通过导入环境变量的方式传递给CGI程序,以供CGI程序判断应该从管道读取多少个参数。

此时子进程就可以进行进程程序替换了,而父进程需要做如下工作:

    如果请求方法为POST方法,则父进程需要将请求正文中的参数写入管道中,以供被替换后的CGI程序进行读取。
    然后父进程要做的就是不断调用read函数,从管道中读取CGI程序写入的处理结果,并将其保存到HTTP响应类的response_body当中。
    管道中的数据读取完毕后,父进程需要调用waitpid函数等待CGI程序退出,并关闭两个管道对应的文件描述符,防止文件描述符泄露。
 

int ProcessCgi(){std::cout<<"调用"<<std::endl;LOG(INFO, "process cgi method");int code = OK;// 父进程数据auto &method = http_request.method;auto &query_string = http_request.query_string; // GETauto &body_text = http_request.request_body;    // POSTauto &bin = http_request.path;                  // 所要读取的资源再path里int content_length = http_request.content_length;auto &response_body = http_response.response_body;std::string query_string_env;std::string method_env;std::string content_length_env;// 站在父进程角度int input[2];int output[2];if (pipe(input) < 0){LOG(ERROR, "pipe input error"); // 写入管道创建失败code = SERVER_ERROR;return code;}if (pipe(output) < 0){LOG(ERROR, "pipe output error"); // 读取管道创建失败code = SERVER_ERROR;return code;}// 新线程,但是从头到尾只有一个进程,就是httpserverpid_t pid = fork(); // 建立子进程if (pid == 0){// 子进程close(input[0]);  // 关闭写入管道的写close(output[1]); // 关闭读取管道的读// 让子进程也知道所使用的方法method_env = "METHOD=";method_env += method;putenv((char *)method_env.c_str()); // 获取环境变量if (method == "GET"){query_string_env = "QUERY_STRING=";query_string_env += query_string;putenv((char *)query_string_env.c_str());LOG(INFO, "Get Method, Add Query_String Env");// std::cout<<"写入"<<std::endl;}else if (method == "POST"){content_length_env = "CONTENT_LENGTH=";content_length_env += std::to_string(content_length);putenv((char *)content_length_env.c_str());LOG(INFO, "Post Method, Add Content_Length Env");}else{// Do Nothing}// 站在子进程角度// input[1]:写出->1->input[1];把子进程的写重定向到1// output[0]:读入->0->input[0];子进程的读重定向到0;std::cout << "bin: " << bin << std::endl;dup2(output[0], 0);dup2(input[1], 1);// 子进程不需要再知道文件描述符表,通过0.1读写到管道即可execl(bin.c_str(), bin.c_str(), nullptr);exit(1);}else if (pid < 0){// 创建失败LOG(ERROR, "fork error");return NOT_FOUND;}else{// 父进程close(input[1]);  // 关闭写入管道的读close(output[0]); // 关闭读取管道的写// POST方法的写入,这里进行特殊处理,防止管道空间不够,进行循环写入if (method == "POST"){const char *start = body_text.c_str();int total = 0;int size = 0;while (total < content_length && (size = write(output[1], start + total, body_text.size() - total)) > 0){total += size;}}// 父进程从管道中读取正文数据char ch = 0;while (read(input[0], &ch, 1) > 0){response_body.push_back(ch);}int status = 0;pid_t ret = waitpid(pid, &status, 0);if (ret == pid){if (WIFEXITED(status)){if (WEXITSTATUS(status) == 0){code = OK;}else{code = BAD_REQUEST;}}else{code = SERVER_ERROR;}}close(input[0]);close(output[1]);}return code;}

说明一下:

    在CGI处理过程中,如果管道创建失败或者子进程创建失败,则属于服务器端处理请求时出错,此时返回INTERNAL_SERVER_ERROR状态码后停止处理即可。
    环境变量是key=value形式的,因此在调用putenv函数导入环境变量前需要先正确构建环境变量,此后被替换的CGI程序在调用getenv函数时,就可以通过key获取到对应的value。
    子进程传递参数的代码最好放在重定向之前,否则服务器运行后无法看到传递参数对应的日志信息,因为日志是以cout的方式打印到标准输出的,而dup2函数调用后标准输出已经被重定向到了管道,此时打印的日志信息将会被写入管道。
    父进程循环调用read函数从管道中读取CGI程序的处理结果,当CGI程序执行结束时相当于写端进程将写端关闭了(文件描述符的生命周期随进程),此时读端进程将管道当中的数据读完后,就会继续执行后续代码,而不会被阻塞。
    父进程在等待子进程退出后,可以通过WIFEXITED判断子进程是否是正常退出,如果是正常退出再通过WEXITSTATUS判断处理结果是否正确,然后根据不同情况设置对应的状态码(此时就算子进程异常退出或处理结果不正确也不能立即返回,需要让父进程继续向后执行,关闭两个管道对应的文件描述符,防止文件描述符泄露)。
 

 4.3.9、处理非CGI请求

非CGI处理时只需要将客户端请求的资源构建成HTTP响应发送给客户端即可,理论上这里要做的就是打开目标文件,将文件中的内容读取到HTTP响应类的response_body中,以供后续发送HTTP响应时进行发送即可,但我们并不推荐这种做法。

因为HTTP响应类的response_body属于用户层的缓冲区,而目标文件是存储在服务器的磁盘上的,按照这种方式需要先将文件内容读取到内核层缓冲区,再由操作系统将其拷贝到用户层缓冲区,发送响应正文的时候又需要先将其拷贝到内核层缓冲区,再由操作系统将其发送给对应的网卡进行发送。
 

以看到上述过程涉及数据在用户层和内核层的来回拷贝,但实际这个拷贝操作是不需要的,我们完全可以直接将磁盘当中的目标文件内容读取到内核,再由内核将其发送给对应的网卡进行发送。

要达到上述效果就需要使用sendfile函数,该函数的功能就是将数据从一个文件描述符拷贝到另一个文件描述符,并且这个拷贝操作是在内核中完成的,因此sendfile比单纯的调用read和write更加高效。

但是需要注意的是,这里还不能直接调用sendfile函数,因为sendfile函数调用后文件内容就发送出去了,而我们应该构建HTTP响应后再进行发送,因此我们这里要做的仅仅是将要发送的目标文件打开即可,将打开文件对应的文件描述符保存到HTTP响应的fd当中。
 

  int ProcessNoCgi(){http_response.fd = open(http_request.path.c_str(), O_RDONLY);if (http_response.fd >= 0){return OK;}return NOT_FOUND;}

 4.3.10、构建HTTP响应

构建HTTP响应首先需要构建的就是状态行,状态行由状态码、状态码描述、HTTP版本构成,并以空格作为分隔符,将状态行构建好后保存到HTTP响应的status_line当中即可,而响应报头需要根据请求是否正常处理完毕分别进行构建。

#define SEP ": "
#define WEB_ROOT "wwwroot"
#define HOME_PAGE "index.html" // 首页
#define HTTP_VERSION "HTTP/1.0"
#define LINE_END "\r\n"
#define PAGE_404 "404.html"
void BuildHttpResponse() // 构建相应{std::string _path;struct stat st; // 创建stat变量来判断路径是否合法int size = 0;int found = 0; // 查找后缀的变量auto &code = http_response.status_code;if (http_request.method != "GET" && http_request.method != "POST"){// 非法请求std::cout << "method: " << http_request.method << std::endl;LOG(WARNING, "method is not right");code = BAD_REQUEST;goto END;}// 把URi拆成两部分if (http_request.method == "GET"){size_t pos = http_request.uri.find('?');// 找到了if (pos != std::string::npos){Util::CutString(http_request.uri, http_request.path, http_request.query_string, "?");http_request.cgi = true;}else{http_request.path = http_request.uri;}}else if (http_request.method == "POST"){ // POSThttp_request.cgi = true;http_request.path = http_request.uri;}else{// DO nothing}_path = http_request.path;http_request.path = WEB_ROOT;http_request.path += _path;// 如果没有路径信息直接返回首页if(http_request.path[http_request.path.size()-1] == '/'){http_request.path += HOME_PAGE;}// c_str()是Borland封装的String类中的一个函数,它返回当前字符串的首字符地址if(stat(http_request.path.c_str(), &st) == 0){//说明资源是存在的if(S_ISDIR(st.st_mode)){//说明请求的资源是一个目录,不被允许的,需要做一下相关处理//虽然是一个目录,但是绝对不会以/结尾!http_request.path += "/";http_request.path += HOME_PAGE;stat(http_request.path.c_str(), &st);}if( (st.st_mode&S_IXUSR) || (st.st_mode&S_IXGRP) || (st.st_mode&S_IXOTH) ){//特殊处理http_request.cgi = true;}http_request.size = st.st_size;}else{//说明资源是不存在的LOG(WARNING, http_request.path + " Not Found");code = NOT_FOUND;goto END;}found = http_request.path.rfind(".");if (found == std::string::npos){// 没找到.http_request.suffix = ".html";}else{http_request.suffix = http_request.path.substr(found);}if (http_request.cgi){code = ProcessCgi(); // 执行目标程序,拿到结果:http_response.response_body}else{// 1.网页一定是存在的// 2.返回并不是单单返回网页,而是要构建HTTP响应code = ProcessNoCgi(); // 简单的网页返回,返回静态网页,只需要打开即可}END:// 差错处理BuildHttpResponseHelper();}

注意: 本项目中将服务器的行分隔符设置为\r\n,在构建完状态行以及每行响应报头之后都需要加上对应的行分隔符,而在HTTP响应类的构造函数中已经将空行初始化为了LINE_END,因此在构建HTTP响应时不用处理空行。

对于状态行中的状态码描述,我们可以编写一个函数,该函数能够根据状态码返回对应的状态码描述。
 

static std::string Code2Desc(int code)
{std::string desc;switch (code){case 200:desc = "OK";break;case 404:desc = "Not Found";break;case 500:desc = "Internal Server Error";break;default:break;}return desc;
}

构建响应报头(请求正常处理完毕)

构建HTTP的响应报头时,我们至少需要构建Content-Type和Content-Length这两个响应报头,分别用于告知对方响应资源的类型和响应资源的长度。

对于请求正常处理完毕的HTTP请求,需要根据客户端请求资源的后缀来得知返回资源的类型。而返回资源的大小需要根据该请求被处理的方式来得知,如果该请求是以非CGI方式进行处理的,那么返回资源的大小早已在获取请求资源属性时被保存到了HTTP响应类中的size当中,如果该请求是以CGI方式进行处理的,那么返回资源的大小应该是HTTP响应类中的response_body的大小。
 

 void BuildOkResponse(){std::string line = "Content-Type: ";line += Suffix2Desc(http_request.suffix);line += LINE_END;http_response.response_header.push_back(line);line = "Content-Length: ";if (http_request.cgi){ // PSOT方法,在body里line += std::to_string(http_response.response_body.size());}else{// 非cgi在size里面获得line += std::to_string(http_request.size); // Get}line += LINE_END;http_response.response_header.push_back(line);}

 对于返回资源的类型,我们可以编写一个函数,该函数能够根据文件后缀返回对应的文件类型。查看Content-Type转化表可以得知后缀与文件类型的对应关系,将这个对应关系存储一个unordered_map容器中,当需要根据后缀得知文件类型时直接在这个unordered_map容器中进行查找,如果找到了则返回对应的文件类型,如果没有找到则默认该文件类型为text/html。

static std::string Suffix2Desc(const std::string &suffix)
{static std::unordered_map<std::string, std::string> suffix2desc = {{".xml", "application/xml"},{".doc", "application/msword"},{".exe", "application/x-msdownload"},{".js", "application/javascript"},{".jpg", "application/x-jpg"},{".html", "text/html"},{".css", "text/css"},};auto iter = suffix2desc.find(suffix);if (iter != suffix2desc.end()){return iter->second;}return "text/html";
}

构建响应报头(请求处理出现错误)

对于请求处理过程中出现错误的HTTP请求,服务器将会为其返回对应的错误页面,因此返回的资源类型就是text/html,而返回资源的大小可以通过获取错误页面对应的文件属性信息来得知。此外,为了后续发送响应时可以直接调用sendfile进行发送,这里需要将错误页面对应的文件打开,并将对应的文件描述符保存在HTTP响应类的fd当中。

void HandlerError(std::string page){std::cout << "debug: " << page << std::endl;http_request.cgi = false;// 要给用户返回对应的404页面http_response.fd = open(page.c_str(), O_RDONLY);if (http_response.fd > 0){struct stat st;stat(page.c_str(), &st);http_request.size = st.st_size;std::string line = "Content-Type: text/html";line += LINE_END;http_response.response_header.push_back(line);line = "Content-Length: ";line += std::to_string(st.st_size);line += LINE_END;http_response.response_header.push_back(line);}}

特别注意: 对于处理请求时出错的HTTP请求,需要将其HTTP请求类中的cgi重新设置为false,因为后续发送HTTP响应时,需要根据HTTP请求类中的cgi来进行响应正文的发送,当请求处理出错后要返回给客户端的本质就是一个错误页面文件,相当于是以非CGI方式进行处理的。

4.3.11、发送HTTP响应 

发送HTTP响应的步骤如下:

    调用send函数,依次发送状态行、响应报头和空行。
    发送响应正文时需要判断本次请求的处理方式,如果本次请求是以CGI方式成功处理的,那么待发送的响应正文是保存在HTTP响应类的response_body中的,此时调用send函数进行发送即可。
    如果本次请求是以非CGI方式处理或在处理过程中出错的,那么待发送的资源文件或错误页面文件对应的文件描述符是保存在HTTP响应类的fd中的,此时调用sendfile进行发送即可,发送后关闭对应的文件描述符。
 

 //发送响应bool SendHttpResponse(){//发送状态行if(send(_sock, _http_response._status_line.c_str(), _http_response._status_line.size(), 0) <= 0){_stop = true; //发送失败,设置_stop}//发送响应报头if(!_stop){for(auto& iter : _http_response._response_header){if(send(_sock, iter.c_str(), iter.size(), 0) <= 0){_stop = true; //发送失败,设置_stopbreak;}}}//发送空行if(!_stop){if(send(_sock, _http_response._blank.c_str(), _http_response._blank.size(), 0) <= 0){_stop = true; //发送失败,设置_stop}}//发送响应正文if(_http_request._cgi){if(!_stop){auto& response_body = _http_response._response_body;const char* start = response_body.c_str();size_t size = 0;size_t total = 0;while(total < response_body.size()&&(size = send(_sock, start + total, response_body.size() - total, 0)) > 0){total += size;}}}else{if(!_stop){if(sendfile(_sock, _http_response._fd, nullptr, _http_response._size) <= 0){_stop = true; //发送失败,设置_stop}}//关闭请求的资源文件close(_http_response._fd);}return _stop;}
};

4.3.12、读取错误

逻辑错误是在服务器处理请求时可能出现的错误,而在服务器处理请求之前首先要做的是读取请求,在读取请求的过程中出现的错误就叫做读取错误,比如调用recv读取请求时出错或读取请求时对方连接关闭等。

出现读取错误时,意味着服务器都没有成功读取完客户端发来的HTTP请求,因此服务器也没有必要进行后续的处理请求、构建响应以及发送响应的相关操作了。

可以在EndPoint类中新增一个bool类型的stop成员,表示是否停止本次处理,stop的值默认设置为false,当读取请求出错时就直接设置stop为true并不再进行后续的读取操作,因此读取HTTP请求的代码需要稍作修改。

//服务端EndPoint
class EndPoint{private:int sock;                   //通信的套接字HttpRequest http_request;   //HTTP请求HttpResponse http_response; //HTTP响应bool stop;                  //是否停止本次处理private://读取请求行bool RecvHttpRequestLine(){auto& line = _http_request.request_line;if(Util::ReadLine(sock, line) > 0){line.resize(line.size() - 1); //去掉读取上来的\n}else{ //读取出错,则停止本次处理_top = true;}return _stop;}//读取请求报头和空行bool RecvHttpRequestHeader(){std::string line;while(true){line.clear(); //每次读取之前清空lineif(Util::ReadLine(sock, line) <= 0){ //读取出错,则停止本次处理stop = true;break;}if(line == "\n"){ //读取到了空行http_request._blank = line;break;}//读取到一行请求报头line.resize(line.size() - 1); //去掉读取上来的\nhttp_request._request_header.push_back(line);}return _stop;}//读取请求正文bool RecvHttpRequestBody(){if(IsNeedRecvHttpRequestBody()){ //先判断是否需要读取正文int content_length = http_request.content_length;auto& body = http_request._request_body;//读取请求正文char ch = 0;while(content_length){ssize_t size = recv(sock, &ch, 1, 0);if(size > 0){body.push_back(ch);content_length--;}else{ //读取出错或对端关闭,则停止本次处理stop = true;break;}}}return _stop;}public:EndPoint(int sock):sock(sock),stop(false){}//本次处理是否停止bool IsStop(){return stop;}//读取请求void RecvHttpRequest(){if(!RecvHttpRequestLine()&&!RecvHttpRequestHeader()){ //短路求值ParseHttpRequestLine();ParseHttpRequestHeader();RecvHttpRequestBody();}}
};

说明一下:

    可以将读取请求行、读取请求报头和空行、读取请求正文对应函数的返回值改为bool类型,当读取请求行成功后再读取请求报头和空行,而当读取请求报头和空行成功后才需要进行后续的解析请求行、解析请求报头以及读取请求正文操作,这里利用到了逻辑运算符的短路求值策略。
    EndPoint类当中提供了IsStop函数,用于让外部处理线程得知是否应该停止本次处理。

此时服务器创建的新线程在读取请求后,就需要判断是否应该停止本次处理,如果需要则不再进行处理请求、构建响应以及发送响应操作,而直接关闭于客户端建立的套接字即可。
 

写入错误

除了读取请求时可能出现读取错误,处理请求时可能出现逻辑错误,在响应构建完毕发送响应时同样可能会出现写入错误,比如调用send发送响应时出错或发送响应时对方连接关闭等。

出现写入错误时,服务器也没有必要继续进行发送了,这时需要直接设置stop为true并不再进行后续的发送操作,因此发送HTTP响应的代码也需要进行修改
 

//服务端EndPoint
class EndPoint{private:int _sock;                   //通信的套接字HttpRequest _http_request;   //HTTP请求HttpResponse _http_response; //HTTP响应bool stop;                  //是否停止本次处理public://发送响应bool SendHttpResponse(){//发送状态行if(send(_sock, _http_response._status_line.c_str(), _http_response._status_line.size(), 0) <= 0){_stop = true; //发送失败,设置_stop}//发送响应报头if(!_stop){for(auto& iter : _http_response._response_header){if(send(_sock, iter.c_str(), iter.size(), 0) <= 0){_stop = true; //发送失败,设置_stopbreak;}}}//发送空行if(!_stop){if(send(_sock, _http_response._blank.c_str(), _http_response._blank.size(), 0) <= 0){_stop = true; //发送失败,设置_stop}}//发送响应正文if(_http_request._cgi){if(!_stop){auto& response_body = _http_response._response_body;const char* start = response_body.c_str();size_t size = 0;size_t total = 0;while(total < response_body.size()&&(size = send(_sock, start + total, response_body.size() - total, 0)) > 0){total += size;}}}else{if(!_stop){if(sendfile(_sock, _http_response._fd, nullptr, _http_response._size) <= 0){_stop = true; //发送失败,设置_stop}}//关闭请求的资源文件close(_http_response._fd);}return _stop;}
};

 5、接入线程池

 5.1为什么要接入线程池

当前多线程版服务器存在的问题:

    每当获取到新连接时,服务器主线程都会重新为该客户端创建为其提供服务的新线程,而当服务结束后又会将该新线程销毁,这样做不仅麻烦,而且效率低下。
    如果同时有大量的客户端连接请求,此时服务器就要为每一个客户端创建对应的服务线程,而计算机中的线程越多,CPU压力就越大,因为CPU要不断在这些线程之间来回切换。此外,一旦线程过多,每一个线程再次被调度的周期就变长了,而线程是为客户端提供服务的,线程被调度的周期变长,客户端也就迟迟得不到应答。

这时可以在服务器端引入线程池:

    在服务器端预先创建一批线程和一个任务队列,每当获取到一个新连接时就将其封装成一个任务对象放到任务队列当中。
    线程池中的若干线程就不断从任务队列中获取任务进行处理,如果任务队列当中没有任务则线程进入休眠状态,当有新任务时再唤醒线程进行任务处理。
 

 设计任务

当服务器获取到一个新连接后,需要将其封装成一个任务对象放到任务队列当中。任务类中首先需要有一个套接字,也就是与客户端进行通信的套接字,此外还需要有一个回调函数,当线程池中的线程获取到任务后就可以调用这个回调函数进行任务处理。

#pragma once #include <iostream>
#include "Protocol.hpp"class Task{private:int sock;CallBack handler; //设置回调public:Task(){}Task(int _sock):sock(_sock){}//处理任务void ProcessOn(){handler(sock);}~Task(){}
};

 说明一下: 任务类需要提供一个无参的构造函数,因为后续从任务队列中获取任务时,需要先以无参的方式定义一个任务对象,然后再以输出型参数的方式来获取任务。

编写任务回调

任务类中处理任务时需要调用的回调函数,实际就是之前创建新线程时传入的执行例程CallBack::HandlerRequest,我们可以将CallBack类的()运算符重载为调用HandlerRequest函数,这时CallBack对象就变成了一个仿函数对象,这个仿函数对象被调用时实际就是在调用HandlerRequest函数。
 

class CallBack
{
public:CallBack(){}void operator()(int sock){HandlerRequest(sock);}// 构建线程调用的方法void HandlerRequest(int sock){LOG(INFO, "Hander Request Begin!");#ifdef DEBUGchar buffer[4096];recv(sock, buffer, sizeof(buffer), 0);std::cout << "---------begin---------------" << std::endl;std::cout << buffer << std::endl;std::cout << "---------end---------------" << std::endl;
#elseEndPoint *ep = new EndPoint(sock);ep->RecvHttpRequest();if(!ep->IsStop()){//注意这里是没有STOP才能继续往下运行LOG(INFO, "Recv No Error, Begin Build And Send");ep->BuildHttpResponse();ep->SendHttpResponse();}else{LOG(WARNING, "Recv Error, Stop Build And Send");}delete ep;#endifLOG(INFO, "Hander Request End!");}~CallBack(){}
};

5.2、实现线程池

可以将线程池设计成单例模式

    将ThreadPool类的构造函数设置为私有,并将拷贝构造和拷贝赋值函数设置为私有或删除,防止外部创建或拷贝对象。
    提供一个指向单例对象的static指针,并在类外将其初始化为nullptr。
    提供一个全局访问点获取单例对象,在单例对象第一次被获取时就创建这个单例对象并进行初始化。

ThreadPool类中的成员变量包括

    任务队列:用于暂时存储未被处理的任务对象。
    num:表示线程池中线程的个数。
    互斥锁:用于保证任务队列在多线程环境下的线程安全。
    条件变量:当任务队列中没有任务时,让线程在该条件变量下进行等等,当任务队列中新增任务时,唤醒在该条件变量下进行等待的线程。
    指向单例对象的指针:用于指向唯一的单例线程池对象。

ThreadPool类中的成员函数主要包括

    构造函数:完成互斥锁和条件变量的初始化操作。
    析构函数:完成互斥锁和条件变量的释放操作。
    InitThreadPool:初始化线程池时调用,完成线程池中若干线程的创建。
    PushTask:生产任务时调用,将任务对象放入任务队列,并唤醒在条件变量下等待的一个线程进行处理。
    PopTask:消费任务时调用,从任务队列中获取一个任务对象。
    ThreadRoutine:线程池中每个线程的执行例程,完成线程分离后不断检测任务队列中是否有任务,如果有则调用PopTask获取任务进行处理,如果没有则进行休眠直到被唤醒。
    GetInstance:获取单例线程池对象时调用,如果单例对象未创建则创建并初始化后返回,如果单例对象已经创建则直接返回单例对象。
 

#pragma once#include <iostream>
#include <queue>
#include <pthread.h>
#include "Log.hpp"
#include "Task.hpp"#define NUM 6
class ThreadPool{private:int num;bool stop;std::queue<Task> task_queue;pthread_mutex_t lock;//互斥锁pthread_cond_t cond;//条件变量//构建线程池单例模式ThreadPool(int _num = NUM):num(_num),stop(false){pthread_mutex_init(&lock, nullptr);pthread_cond_init(&cond, nullptr);}ThreadPool(const ThreadPool &){}static ThreadPool *single_instance;public:static ThreadPool* getinstance()//线程安全版 两层判断{static pthread_mutex_t _mutex = PTHREAD_MUTEX_INITIALIZER;if(single_instance == nullptr){pthread_mutex_lock(&_mutex);if(single_instance == nullptr){single_instance = new ThreadPool();single_instance->InitThreadPool();}pthread_mutex_unlock(&_mutex);}return single_instance;}bool IsStop()//判断线程是否停止{return stop;}bool TaskQueueIsEmpty()//判断任务队列是否为空{return task_queue.size() == 0 ? true : false;}void ThreadWait()//当没有任务时,线程等待{pthread_cond_wait(&cond, &lock);}void ThreadWakeup()//唤醒线程{pthread_cond_signal(&cond);}void Lock(){pthread_mutex_lock(&lock);}void Unlock(){pthread_mutex_unlock(&lock);}static void*ThreadRoutine(void *args )//线程的回调函数,也是线程的历程,之后要执行的动作,这里要设置为静态的{ThreadPool *tp = (ThreadPool*)args;while(true){Task t;tp->Lock();while(tp->TaskQueueIsEmpty()){tp->ThreadWait(); //当我醒来的时候,一定是占有互斥锁的!}tp->PopTask(t);tp->Unlock();t.ProcessOn();}           }bool InitThreadPool()//初始化线程{  for(int i=0;i<num;i++){pthread_t tid;if(pthread_create(&tid,nullptr,ThreadRoutine,this)!=0)//创建线程失败{LOG(FATAL,"create thread pool error!");return false;}}LOG(INFO, "create thread pool success!"); //创建成功return true;}void PushTask(const Task &task)//往任务队列中插入任务{Lock();task_queue.push(task);Unlock();ThreadWakeup();}void PopTask(Task &task)//从任务队列中取出任务{task = task_queue.front();task_queue.pop();}~ThreadPool(){pthread_mutex_destroy(&lock);pthread_cond_destroy(&cond);}  };
ThreadPool* ThreadPool::single_instance = nullptr;

说明一下:

    由于线程的执行例程的参数只能有一个void*类型的参数,因此线程的执行例程必须定义成静态成员函数,而线程执行例程中又需要访问任务队列,因此需要将this指针作为参数传递给线程的执行例程,这样线程才能够通过this指针访问任务队列。
    在向任务队列中放任务以及从任务队列中获取任务时,都需要通过加锁的方式来保证线程安全,而线程在调用PopTask之前已经进行过加锁了,因此在PopTask函数中不必再加锁。
    当任务队列中有任务时会唤醒线程进行任务处理,为了防止被伪唤醒的线程调用PopTask时无法获取到任务,因此需要以while的方式判断任务队列是否为空。

引入线程池后服务器要做的就是,每当获取到一个新连接时就构建一个任务,然后调用PushTask将其放入任务队列即可。
 

#pragma once#include<iostream>
#include<pthread.h>
#include <signal.h>
#include"Log.hpp"
#include"TcpServer.hpp"
#include"Task.hpp"
#include"ThreadPool.hpp"#define PORT 8081class HttpServer{private:int port;bool stop;public:HttpServer(int _port = PORT): port(_port),stop(false){}void InitServer(){//信号SIGPIPE需要进行忽略,如果不忽略,在写入时候,可能直接崩溃serversignal(SIGPIPE, SIG_IGN); }void Loop(){TcpServer *tsvr = TcpServer::getinstance(port);LOG(INFO, "Loop begin");while(!stop){struct sockaddr_in peer;socklen_t len = sizeof(peer);int sock = accept(tsvr->Sock(), (struct sockaddr*)&peer, &len);if(sock < 0){continue;}LOG(INFO, "Get a new link");Task task(sock);ThreadPool::getinstance()->PushTask(task);}}~HttpServer(){}//thread_pool.PushTask(task);//分离线程//分离线程,他的存储器资源在他终止的时候可以由系统自动释放//1.默认情况下,新创建的线程是joinable的,线程退出后,需对其进行pthread_join操作,否则无法释放资源,从而造成系统泄露//2.如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源};

6、项目测试

6.1、首页请求测试

服务器的web根目录下的资源文件主要有两种,一种就是用于处理客户端上传上来的数据的CGI程序,另一种就是供客户端请求的各种网页文件了,而网页的制作实际是前端工程师要做的,但现在我们要对服务器进行测试,至少需要编写一个首页,首页文件需要放在web根目录下,取名为index.htm

首先启动服务器

telnet测试

GET / HTTP/1.0

网页测试

同时服务器端也打印出了本次请求的一些日志信息。如下:

此时通过ps -aL命令可以看到线程池中的线程已经被创建好了,其中PID和LWP相同的就是主线程,剩下的就是线程池中处理任务的若干新线程。如下:

6.2、 错误请求测试

如果我们请求的资源服务器并没有提供,那么服务器就会在获取请求资源属性信息时失败,这时服务器会停止本次请求处理,而直接将web根目录下的404.html文件返回浏览器,浏览器收到后经过刷新渲染就显示出了对应的404页面。

telnet测试

 网页测试

这时在服务器端就能看到一条日志级别为WARNING的日志信息,这条日志信息中说明了客户端请求的哪一个资源是不存在的。 

6.3、上传数据测试 

如果用户请求服务器时上传了数据,那么服务器就需要将该数据后交给对应的CGI程序进行处理,因此在测试GET方法上传数据之前,我们需要先编写一个简单的CGI程序。

首先,CGI程序启动后需要先获取父进程传递过来的数据:

    先通过getenv函数获取环境变量中的请求方法。
    如果请求方法为GET方法,则继续通过getenv函数获取父进程传递过来的数据。
    如果请求方法为POST方法,则先通过getenv函数获取父进程传递过来的数据的长度,然后再从0号文件描述符中读取指定长度的数据即可。

GI程序在获取到父进程传递过来的数据后,就可以根据具体的业务场景进行数据处理了,比如用户上传的如果是一个关键字则需要CGI程序做搜索处理。我们这里以演示为目的,认为用户上传的是形如a=10&b=20的两个参数,需要CGI程序进行加减乘除运算。

  因此我们的CGI程序要做的就是,先以&为分隔符切割数据将两个操作数分开,再以=为分隔符切割数据分别获取到两个操作数的值,最后对两个操作数进行加减乘除运算,并将计算结果打印到标准输出即可(标准输出已经被重定向到了管道)。
代码如下:

#include <iostream>
#include <string>
#include <unistd.h>
#include <cstdlib>bool GetQueryString(std::string &query_string)
{ // 因为管道重定向了,这里要用标准错误打印到显示器bool result = false;std::string method = getenv("METHOD"); // 获取请求方法if (method == "GET"){ // GET方法通过环境变量获取参数query_string = getenv("QUERY_STRING");result = true;}else if (method == "POST"){ // POST方法通过管道获取参数int content_length = atoi(getenv("CONTENT_LENGTH"));// 从管道中读取content_length个参数char ch = 0;while (content_length){read(0, &ch, 1);query_string += ch;content_length--;}result = true;}else{// Do Nothingresult = false;}return result;
}void CutString(std::string &in, const std::string &sep, std::string &out1, std::string &out2)
{auto pos = in.find(sep);if (std::string::npos != pos){out1 = in.substr(0, pos);out2 = in.substr(pos + sep.size());}
}int main()
{std::string query_string;GetQueryString(query_string);// a=100&b=200std::string str1;std::string str2;CutString(query_string, "&", str1, str2);std::string name1;std::string value1;CutString(str1, "=", name1, value1);std::string name2;std::string value2;CutString(str2, "=", name2, value2);// 1 ->std::cout << name1 << " : " << value1 << std::endl;std::cout << name2 << " : " << value2 << std::endl;// 2std::cerr << name1 << " : " << value1 << std::endl;std::cerr << name2 << " : " << value2 << std::endl;// 可能向进行某种计算(计算,搜索,登陆等),想进行某种存储(注册)// 处理数据int x = atoi(value1.c_str());int y = atoi(value2.c_str());std::cout << "<html>";std::cout << "<head><meta charset=\"utf-8\"></head>";std::cout << "<body>";std::cout << "<h3> " << value1 << " + " << value2 << " = " << x + y << "</h3>";std::cout << "<h3> " << value1 << " - " << value2 << " = " << x - y << "</h3>";std::cout << "<h3> " << value1 << " * " << value2 << " = " << x * y << "</h3>";std::cout << "<h3> " << value1 << " / " << value2 << " = " << x / y << "</h3>";std::cout << "</body>";std::cout << "</html>";return 0;
}

6.3.1、GET方法测试

telnet测试

网页测试
 

6.3.2、POST方法测试 

telnet测试

 网页测试

        当然,让用户通过更改URL的方式来向服务器上传参数是不现实的,服务器一般会让用户通过表单来上传参数。

  HTML中的表单用于搜集用户的输入,我们可以通过设置表单的method属性来指定表单提交的方法,通过设置表单的action属性来指定表单需要提交给服务器上的哪一个CGI程序。

比如现在将服务器的首页改成以下HTML代码,指定将表单中的数据以GET方法提交给web根目录下的test_cgi程序:

<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>简易的在线计算器</title>
</head>
<body><form action="/test_cgi" method="get" align="center">操作数1:<br><input type="text" name="x"><br>操作数2:<br><input type="text" name="y"><br><br><input type="submit" value="计算"></form>
</body>
</html>

此时我们直接访问服务器看到的就是一个表单,向表单中输入两个操作数并点击“计算”后,表单中的数据就会以GET方法提交给web根目录下的test_cgi程序,此时CGI程序进行数据计算后同样将结果返回给了浏览器。

可以看到,由于POST方法是通过请求正文上传的数据,因此表单提交后浏览器上方的URL中只有请求资源路径发生了改变,而并没有在URL后面添加任何参数。同时观察服务器端输出的日志信息,也可以确认浏览器本次的请求方法为POST方法。

7、项目扩展 

 当前项目的重点在于HTTP服务器后端的处理逻辑,主要完成的是GET和POST请求方法,以及CGI机制的搭建。如果想对当前项目进行扩展,可以选择在技术层面或应用层面进行扩展。

技术层面可以选择进行如下扩展

当前项目编写的是HTTP1.0版本的服务器,每次连接都只会对一个请求进行处理,当服务器对客户端的请求处理完毕并收到客户端的应答后,就会直接断开连接。可以将其扩展为HTTP1.1版本,让服务器支持长连接,即通过一条连接可以对多个请求进行处理,避免重复建立连接(涉及连接管理)。
当前项目虽然在后端接入了线程池,但也只能满足中小型应用,可以考虑将服务器改写成epoll版本,让服务器的IO变得更高效。
可以给当前的HTTP服务器新增代理功能,也就是可以替代客户端去访问某种服务,然后将访问结果再返回给客户端。

应用层面可以选择进行如下扩展

  • 基于当前HTTP服务器,搭建在线博客。
  • 基于当前HTTP服务器,编写在线画图板。
  • 基于当前HTTP服务器,编写一个搜索引擎。

7.1项目优化

 7.1.1引入Reactor模式

说明
1)Reactor模型,是基于事件驱动的,通过一个或多个输入同时传递给服务端处理

2)服务端程序处理传入的多个请求,并分发到相应的处理线程

3)基于I/O多路复用模型:多个连接共用一个阻塞对象,应用程序只需在一个阻塞对象等待,无需阻塞等待所有连接。当某个连接有新的数据可以处理时,操作系统通知应用程序,线程从阻塞状态返回,开始进行业务处理

4)基于线程池复用线程资源:不必为每个连接创建线程,将连接完成后的业务处理任务分配给线程池进行处理,一个线程可以处理多个连接的业务

一、单Reactor单线程
1)可以实现通过一个阻塞对象监听多个链接请求

2)Reactor对象通过select监听客户端请求事件,通过dispatch进行分发

3)如果是建立链接请求,则由Acceptor通过accept处理链接请求,然后创建一个Handler对象处理完成链接后的各种事件

4)如果不是链接请求,则由Reactor分发调用链接对应的Handler来处理

5)Handler会完成Read->业务处理->send的完整业务流程

二、单Reactor多线程
1)Reactor对象通过select监听客户端请求事件,收到事件后,通过dispatch分发

2)如果是建立链接请求,则由Acceptor通过accept处理链接请求,然后创建一个Handler对象处理完成链接后的各种事件

3)如果不是链接请求,则由Reactor分发调用链接对应的Handler来处理

4)Handler只负责事件响应不做具体业务处理

5)通过read读取数据后,分发到worker线程池处理,处理完成后返回给Handler,Handler收到后,通过send将结果返回给client

三、主从Reactor多线程
1)Reactor主线程MainReactor对象通过select监听链接事件,通过Acceptor处理

2)当Acceptor处理链接事件后,MainReactor将链接分配给SubReactor

3)SubReactor将链接加入到队列进行监听,并创建Handler进行事件处理

4)当有新事件发生时,SubReactor就会调用对应的Handler处理

5)Handler通过read读取数据,分发到worker线程池处理,处理完成后返回给Handler,Handler收到后,通过send将结果返回给client

6)Reactor主线程可以对应多个Reactor子线程


7.1.2项目优化及代码实现

在之前本项目的基础上引入epoll机制,通过和线程池配合使用实现单Reactor多线程模式,使服务器高性能,高并发运行.

Epoll 作为 Linux 下高性能网络服务器的必备技术至关重要,Nginx、Redis、Skynet 和大部分游戏服务器都使用到这一多路复用技术。
epoll是Linux内核为处理大批量文件描述符而作了改进的poll,是Linux下多路复用IO接口select/poll的增强版本,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率。另一点原因就是获取事件的时候,它无须遍历整个被侦听的描述符集,只要遍历那些被内核IO事件异步唤醒而加入Ready队列的描述符集合就行了。epoll除了提供select/poll那种IO事件的水平触发(Level Triggered)外,还提供了边缘触发(Edge Triggered),这就使得用户空间程序有可能缓存IO状态,减少epoll_wait/epoll_pwait的调用,提高应用程序效率。

   epoll模型是linux实现高并发的一种方法,基于事件驱动模型,相比于select/poll  模型具有更高的效率,本人对epoll模型做了一个简易的封装,更多的功能还在完善中,在这里仅做学习参考用。

     在epoll编程中,有三个非常重要的函数:
     1. int epoll_create(int size)     :创建epoll 句柄, 入参是表示监听的数目是多大。
     2. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)   : 事件注册函数           

       @param1:epoll句柄,epoli_create()的返回值,  

       @param2: 表示注册的行为, 有ADD事件 、MOD事件、DEL事件,

       @param3: 注册的fd,在网络编程中,一般为sockfd,@param4:表示事件类型,
      3. int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout)   等待事件的产生,类似于select() 调用。

线程池实现要点

server启动时,创建一定数量的工作者线程加入线程池,如(20个),供I/O线程来取用;

每当I/O线程请求空闲工作者线程时,从池中取出一个空闲工作者线程,处理相应请求;

当请求处理完毕,关闭相应I/O连接时,回收相应线程并放回线程池中供下次使用;

若请求空闲工作者线程池时,没有空闲工作者线程,可作如下处理:

(1)若池中"管理"的线程总数不超过最大允许值,可创建一批新的工作者线程加入池中,并返回其中一个供I/O线程使用;

(2)若池中"管理"的线程总数已经达到最大值,不应再继续创建新线程, 则等待一小段时间并重试。注意因为I/O线程是单线程且不应被阻塞等待在此处,所以其实对线程池的管理应由一个专门的管理线程完成,包括创建新工作者线程等工作。此时管理线程阻塞等待(如使用条件变量并等待唤醒),一小段时间之后,线程池中应有空闲工作者线程可使用。否则server负荷估计是出了问题。 

Epoll引入代码实现

#pragma once#include <iostream>
#include <sys/epoll.h>// // 虚基类
// class Poll
// {// };class Epoll
{const static int gnum = 128;const static int gtimeout = 5000;
public:Epoll(int timeout = gtimeout):_timeout(timeout){}void CreateEpoll(){_epfd = epoll_create(gnum);if(_epfd < 0) exit(5);}bool DelFromEpoll(int sock){int n = epoll_ctl(_epfd, EPOLL_CTL_DEL, sock, nullptr);return n == 0;}bool CtrlEpoll(int sock, uint32_t events){events |= EPOLLET;struct epoll_event ev;ev.events = events;ev.data.fd = sock;int n = epoll_ctl(_epfd, EPOLL_CTL_MOD, sock, &ev);return n == 0;}bool AddSockToEpoll(int sock, uint32_t events){struct epoll_event ev;ev.events = events;ev.data.fd = sock;int n = epoll_ctl(_epfd, EPOLL_CTL_ADD, sock, &ev);return n == 0;}int WaitEpoll(struct epoll_event revs[], int num){return epoll_wait(_epfd, revs, num, _timeout);}~Epoll(){}
private:int _epfd;int _timeout;
};// class Select : public Poll
// {// }

TcpSever的改进代码实现

#pragma once#include <iostream>
#include <functional>
#include <string>
#include <vector>
#include <cerrno>
#include <cassert>
#include <unordered_map>
#include "Sock.hpp"
#include "Log.hpp"
#include "epoll.hpp"
#include "EProtocol.hpp"class TcpServer;
class Connection;using func_t = std::function<void(Connection *)>;
using callback_t = std::function<void (Connection*, std::string &request)>;// 我们为了能够正常工作,常规的sock必须是要有自己独立的接收缓冲区&&发送缓冲区
class Connection
{
public:Connection(int sock = -1) : _sock(sock), _tsvr(nullptr){}void SetCallBack(func_t recv_cb, func_t send_cb, func_t except_cb){_recv_cb = recv_cb;_send_cb = send_cb;_except_cb = except_cb;}~Connection(){}public:// 负责进行IO的文件描述符int _sock;// 三个回调方法,表征的就是对_sock进行特定读写对应的方法func_t _recv_cb;func_t _send_cb;func_t _except_cb;// 接收缓冲区&&发送缓冲区std::string _inbuffer; // 暂时没有办法处理二进制流,文本是可以的std::string _outbuffer;// 设置对TcpServer的回值指针TcpServer *_tsvr;// 时间戳uint64_t _lasttimestamp; //time();
};// class user
// {};// 这个网络服务器,要不要和上层业务强耦合?坚决不要
class TcpServer
{const static int gport = 8080;const static int gnum = 128;public:TcpServer(int port = gport) : _port(port), _revs_num(gnum){// 1. 创建listensock_listensock = Sock::Socket();Sock::Bind(_listensock, _port);Sock::Listen(_listensock);// 2. 创建多路转接对象_poll.CreateEpoll();// 3. 添加listensock到服务器中AddConnection(_listensock, std::bind(&TcpServer::Accepter, this, std::placeholders::_1), nullptr, nullptr);// 4. 构建一个获取就绪事件的缓冲区_revs = new struct epoll_event[_revs_num];}// 专门针对任意sock进行添加TcpServervoid AddConnection(int sock, func_t recv_cb, func_t send_cb, func_t except_cb){Sock::SetNonBlock(sock);// 除了_listensock,未来我们会存在大量的socket,每一个sock都必须被封装成为一个Connection//    当服务器中存在大量的Connection的时候,TcpServer就需要将所有的Connection要进行管理:先描述,在组织// 1. 构建conn对象,封装sockConnection *conn = new Connection(sock);conn->SetCallBack(recv_cb, send_cb, except_cb);conn->_tsvr = this;//conn->_lasttimestamp = time();// 2. 添加sock到epoll中_poll.AddSockToEpoll(sock, EPOLLIN | EPOLLET); // 任何多路转接的服务器,一般默认只会打开对读取事件的关心,写入事件会按需进行打开!// 3. 还要将对应的Connection*对象指针添加到Connections映射表中!_connections.insert(std::make_pair(sock, conn));}void Accepter(Connection *conn){// logMessage(DEBUG, "Accepter been called");// 一定是listensock已经就绪了,此次读取会阻塞吗?不会// v1 -> v2 : 你怎么保证,底层只有一个连接就绪呢?while (true){std::string clientip;uint16_t clientport;int accept_errno = 0;// sock一定是常规的IO sockint sock = Sock::Accept(conn->_sock, &clientip, &clientport, &accept_errno);if (sock < 0){if (accept_errno == EAGAIN || accept_errno == EWOULDBLOCK)break;else if (accept_errno == EINTR)continue; // 概率非常低else{// accept失败logMessage(WARNING, "accept error, %d : %s", accept_errno, strerror(accept_errno));break;}}// 将sock托管给TcpServerif (sock >= 0){AddConnection(sock, std::bind(&TcpServer::Recver, this, std::placeholders::_1),std::bind(&TcpServer::Sender, this, std::placeholders::_1),std::bind(&TcpServer::Excepter, this, std::placeholders::_1));logMessage(DEBUG, "accept client %s:%d success, add to epoll&&TcpServer success, sock: %d",\clientip.c_str(), clientport, sock);}}}void EnableReadWrite(Connection *conn, bool readable, bool writeable){uint32_t events = ((readable ? EPOLLIN : 0) | (writeable ? EPOLLOUT : 0));bool res = _poll.CtrlEpoll(conn->_sock, events);assert(res); //更改成if}// v1 -> v2void Recver(Connection *conn){//conn->_lasttimestamp = time(); //更新最近访问时间const int num = 1024;bool err = false;// logMessage(DEBUG, "Recver event exists, Recver() been called");// v1: 直接面向字节流,先进行常规读取while(true){char buffer[num];ssize_t n = recv(conn->_sock, buffer, sizeof(buffer) - 1, 0);if(n < 0){if(errno == EAGAIN || errno == EWOULDBLOCK) break; //正常的else if(errno == EINTR) continue;else{logMessage(ERROR, "recv error, %d : %s", errno, strerror(errno));conn->_except_cb(conn);err = true;break;}}else if(n == 0){logMessage(DEBUG, "client[%d] quit, server close [%d]", conn->_sock, conn->_sock);conn->_except_cb(conn);err = true;break;}else{//读取成功buffer[n] = 0;conn->_inbuffer += buffer;}} //end whilelogMessage(DEBUG, "conn->_inbuffer[sock: %d]: %s", conn->_sock, conn->_inbuffer.c_str());if(!err) {std::vector<std::string> messages;SpliteMessage(conn->_inbuffer, &messages);// 我能保证走到这里,就是一个完整报文for(auto & msg : messages) _cb(conn, msg); //可以在这里将message封装成为task,然后push到任务队列,任务处理交给后端线程池}}// 最开始的时候,我们的conn 是没有被触发的!void Sender(Connection *conn){while(true){ssize_t n = send(conn->_sock, conn->_outbuffer.c_str(), conn->_outbuffer.size(), 0);if(n > 0){conn->_outbuffer.erase(0, n);if(conn->_outbuffer.empty()) break;}else{if(errno == EAGAIN || errno == EWOULDBLOCK) break;else if(errno == EINTR) continue;else{logMessage(ERROR, "send error, %d : %s", errno, strerror(errno));conn->_except_cb(conn);break;}}}// 发完了吗?不确定,但是我们保证,如果没有出错,一定是要么发完,要么发送条件不满足,下次发送if(conn->_outbuffer.empty()) EnableReadWrite(conn, true, false);else EnableReadWrite(conn, true, true);}void Excepter(Connection *conn){if(!IsConnectionExists(conn->_sock)) return;// 1. 从epoll中移除bool res = _poll.DelFromEpoll(conn->_sock);assert(res); //要判断// 2. 从我们的unorder_map中移除_connections.erase(conn->_sock);// 3. close(sock);close(conn->_sock);// 4. delete conn;delete conn;logMessage(DEBUG, "Excepter 回收完毕,所有的异常情况");}void LoopOnce(){int n = _poll.WaitEpoll(_revs, _revs_num);for (int i = 0; i < n; i++){int sock = _revs[i].data.fd;uint32_t revents = _revs[i].events;// 将所有的异常,全部交给read或者write来统一处理!if(revents & EPOLLERR) revents |= (EPOLLIN | EPOLLOUT);if(revents & EPOLLHUP) revents |= (EPOLLIN | EPOLLOUT);if (revents & EPOLLIN){if (IsConnectionExists(sock) && _connections[sock]->_recv_cb != nullptr)_connections[sock]->_recv_cb(_connections[sock]);}if (revents & EPOLLOUT){if (IsConnectionExists(sock) && _connections[sock]->_send_cb != nullptr)_connections[sock]->_send_cb(_connections[sock]);}}}void ConnectAliveCheck(){// 遍历所有的_connections,通过检测最近conn的活动时间,如果长时间没有动// 进入到链接超时的逻辑//for(auto &iter : _connections)//{//    uint64_t currtime = time();//    deadtime = currtime - iter->_lasttimestamp;//    if(deadtime > XXXX) //差错处理逻辑//}}// 根据就绪的事件,进行特定事件的派发void Dispather(callback_t cb){_cb = cb;while (true){ConnectAliveCheck();LoopOnce();// 将epoll当做定时器来使用}}~TcpServer(){if (_listensock >= 0)close(_listensock);if (_revs)delete[] _revs;}bool IsConnectionExists(int sock){auto iter = _connections.find(sock);if (iter == _connections.end())return false;elsereturn true;}private:int _listensock;int _port;Epoll _poll;// sock : connectionstd::unordered_map<int, Connection *> _connections;struct epoll_event *_revs;int _revs_num;// 这里是上层的业务处理callback_t _cb;
};

项目源码:Xc/Linux下的高性能Web服务器 - 码云 - 开源中国 (gitee)

更多推荐

【呕心沥血5万两千字】一篇文章带你实现高性能Web服务器

本文发布于:2024-03-12 10:01:15,感谢您对本站的认可!
本文链接:https://www.elefans.com/category/jswz/34/1731262.html
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,我们将在24小时内删除。
本文标签:呕心沥血   千字   高性能   带你   一篇文章

发布评论

评论列表 (有 0 条评论)
草根站长

>www.elefans.com

编程频道|电子爱好者 - 技术资讯及电子产品介绍!