【项目计划】自主HTTP服务器

打印 上一主题 下一主题

主题 455|帖子 455|积分 1365

项目介绍

  本项目实现的是一个HTTP服务器,项目中将会通过基本的网络套接字读取客户端发来的HTTP请求并举行分析,最终构建HTTP相应并返回给客户端。

  HTTP在网络应用层中的职位是不可撼动的,无论是移动端照旧PC端浏览器,HTTP无疑是打开互联网应用窗口的重要协议。
  该项目将会把HTTP中最焦点的模块抽取出来,采用CS模子实现一个小型的HTTP服务器,目的在于理解HTTP协议的处置惩罚过程。
  该项目重要涉及C/C++、HTTP协议、网络套接字编程、CGI、单例模式、多线程、线程池等方面的技能。
网络协议栈介绍

协议分层

   协议分层
  网络协议栈的分层环境如下:

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


  • 应用层:根据特定的通信目的,对数据举行分析处置惩罚,以达到某种业务性的目的。
  • 传输层:处置惩罚传输时遇到的问题,重要是包管数据传输的可靠性。
  • 网络层:完成数据的转发,办理数据去哪里的问题。
  • 链路层:负责数据真正的发生过程。
数据的封装与分用

   数据的封装与分用
  数据封装与分用的过程如下:

  也就是说,发送端在发生数据前,该数据需要先自顶向下贯穿网络协议栈完成数据的封装,在这个过程中,每一层协议都会为该数据添加上对应的报头信息。接收端在收到数据后,该数据需要先自底向上贯穿网络协议栈完成数据的解包和分用,在这个过程中,每一层协议都会将对应的报头信息提取出来。
  而本项目要做的就是,在接收到客户端发来的HTTP请求后,将HTTP的报头信息提取出来,然后对数据举行分析处置惩罚,最终将处置惩罚结果添加上HTTP报头再发送给客户端。
  需要留意的是,该项目中我们所处的位置是应用层,因此我们读取的HTTP请求实际是从传输层读取上来的,而我们发送的HTTP相应实际也只是交给了传输层,数据真正的发送还得靠网络协议栈中的下三层来完成,这里直接说“接收到客户端的HTTP请求”以及“发送HTTP相应给客户端”,只是为了方便大家理解,此外,同层协议之间自己也是可以理解成是在直接通信的。
HTTP相干知识介绍

HTTP的特点

   HTTP的五大特点
  HTTP的五大特点如下:


  • 客户端服务器模式(CS,BS): 在一条通信线路上必定有一端是客户端,另一端是服务器端,请求从客户端发出,服务器相应请求并返回。
  • 简单快速: 客户端向服务器请求服务时,只需传送请求方法和请求资源路径,不需要发送额外过多的数据,并且由于HTTP协议结构较为简单,使得HTTP服务器的程序规模小,因此通信速率很快。
  • 灵活: HTTP协议对数据对象没有要求,允许传输恣意类型的数据对象,对于正在传输的数据类型,HTTP协议将通过报头中的Content-Type属性加以标志。
  • 无毗连: 每次毗连都只会对一个请求举行处置惩罚,当服务器对客户端的请求处置惩罚完毕并收到客户端的应答后,就会直接断开毗连。HTTP协议采用这种方式可以大大节省传输时间,进步传输服从。
  • 无状态: HTTP协议自身不对请求和相应之间的通信状态举行保存,每个请求都是独立的,这是为了让HTTP能更快地处置惩罚大量变乱,确保协议的可伸缩性而特意计划的。
分析一下:


  • 随着HTTP的普及,文档中包罗大量图片的环境多了起来,每次请求都要断开毗连,无疑增长了通信量的开销,因此HTTP1.1支持了长毗连Keey-Alive,就是恣意一端只要没有明白提出断开毗连,则保持毗连状态。(当前项目实现的是1.0版本的HTTP服务器,因此不涉及长毗连)
  • HTTP无状态的特点无疑可以淘汰服务器内存资源的消耗,但是问题也是显而易见的。比如某个网站需要登录后才气访问,由于无状态的特点,那么每次跳转页面的时候都需要重新登录。为了办理无状态的问题,于是引入了Cookie技能,通过在请求和相应报文中写入Cookie信息来控制客户端的状态,同时为了保护用户数据的安全,又引入了Session技能,因此现在主流的HTTP服务器都是通过Cookie+Session的方式来控制客户端的状态的。
URL格式

   URL(Uniform Resource Lacator)叫做同一资源定位符,也就是我们通常所说的网址,是因特网的万维网服务程序上用于指定信息位置的表示方法。
  一个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,在使用这些常见协议时不必指明协议对应的端标语,浏览器会主动帮我们举行添补。
URI、URL、URN

   URI、URL、URN的定义
  URI、URL、URN的定义如下:


  • URI(Uniform Resource Indentifier)同一资源标识符:用来唯一标识资源。
  • URL(Uniform Resource Locator)同一资源定位符:用来定位唯一的资源。
  • URN(Uniform Resource Name)同一资源名称:通过名字来标识资源,比如mailto:java-net@java.sun.com。
   URI、URL、URN三者的关系
    URL是URI的一种,URL不仅能唯一标识资源,还定义了该怎样访问或定位该资源,URN也是URI的一种,URN通过名字来标识资源,因此URL和URN都是URI的子集。
URI、URL、URN三者的关系如下:

   绝对的URI和相对的URI
  URI有绝对和相对之分:


  • 绝对的URI: 对标识符出现的环境没有依靠,比如URL就是一种绝对的URI,同一个URL无论出现在什么地方都能唯一标识同一个资源。
  • 相对的URI: 对标识符出现的环境有依靠,比如HTTP请求行中的请求资源路径就是一种相对的URI,这个资源路径出现在差别的主机上标识的就是差别的资源。
HTTP的协议格式

   HTTP请求协议格式
  HTTP请求协议格式如下:

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


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

HTTP相应由以下四部分构成:


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

   HTTP的请求方法
  HTTP常见的请求方法如下:
方法分析支持的HTTP协议版本GET获取资源1.0、1.1POST传输实体主体1.0、1.1PUT传输文件1.0、1.1HEAD获得报文首部1.0、1.1DELETE删除文件1.0、1.1OPTIONS询问支持的方法1.1TRACE追踪路径1.1CONNECT要求用隧道协议毗连署理1.1LINK创建和资源之间的接洽1.0UNLINK断开毗连关系1.0   GET方法和POST方法
    HTTP的请求方法中最常用的就是GET方法和POST方法,其中GET方法一样寻常用于获取某种资源信息,而POST方法一样寻常用于将数据上传给服务器,但实际GET方法也可以用来上传数据,比如百度搜刮框中的数据就是使用GET方法提交的。
  GET方法和POST方法都可以带参,其中GET方法通过URL传参,POST方法通过请求正文传参。由于URL的长度是有限定的,因此GET方法携带的参数不能太长,而POST方法通过请求正文传参,一样寻常参数长度没有限定。
HTTP的状态码

   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首部字段再返回给客户端 HTTP常见的Header

   HTTP常见的Header
  HTTP常见的Header如下:


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

CGI机制的概念

   CGI(Common Gateway Interface,通用网关接口)是一种重要的互联网技能,可以让一个客户端,从网页浏览器向执行在网络服务器上的程序请求数据。CGI描述了服务器和请求处置惩罚程序之间传输数据的一种尺度。
  实际我们在举行网络请求时,无非就两种环境:


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

  通常从服务器上获取资源对应的请求方法就是GET方法,而将数据上传至服务器对应的请求方法就是POST方法,但实际GET方法有时也会用于上传数据,只不过POST方法是通过请求正文传参的,而GET方法是通过URL传参的。
  而用户将自己的数据上传至服务器并不仅仅是为了上传,用户上传数据的目的是为了让HTTP或相干程序对该数据举行处置惩罚,比如用户提交的是搜刮关键字,那么服务器就需要在后端举行搜刮,然后将搜刮结果返回给浏览器,再由浏览器对HTML文件举行渲染革新展示给用户。
  但实际对数据的处置惩罚与HTTP的关系并不大,而是取决于上层详细的业务场景的,因此HTTP不对这些数据做处置惩罚。但HTTP提供了CGI机制,上层可以在服务器中摆设若干个CGI程序,这些CGI程序可以用任何程序计划语言编写,当HTTP获取到数据后会将其提交给对应CGI程序举行处置惩罚,然后再用CGI程序的处置惩罚结果构建HTTP相应返回给浏览器。

  其中HTTP获取到数据后,怎样调用目的CGI程序、怎样传递数据给CGI程序、怎样拿到CGI程序的处置惩罚结果,这些都属于CGI机制的通信细节,而本项目就是要实现一个HTTP服务器,因此CGI的全部交互细节都需要由我们来完成。
   何时需要使用CGI模式
    只要用户请求服务器时上传了数据,那么服务器就需要使用CGI模式对用户上传的数据举行处置惩罚,而如果用户只是单纯的想请求服务器上的某个资源文件则不需要使用CGI模式,此时直接将用户请求的资源文件返回给用户即可。
  此外,如果用户请求的是服务器上的一个可执行程序,分析用户想让服务器运行这个可执行程序,此时也需要使用CGI模式。
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程序的处置惩罚结果了。
CGI机制的意义

   CGI机制的处置惩罚流程
  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程序写入尺度输出的数据最终就能输出到浏览器。
日记编写

服务器在运作时会产生一些日记,这些日记会纪录下服务器运行过程中产生的一些变乱。
   日记格式
  本项目中的日记格式如下:

日记分析:


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


  • INFO: 表示正常的日记输出,一切按预期运行。
  • WARNING: 表示警告,该变乱不影响服务器运行,但存在风险。
  • ERROR: 表示发生了某种错误,但该变乱不影响服务器继承运行。
  • FATAL: 表示发生了致命的错误,该变乱将导致服务器停止运行。
   日记函数编写
    我们可以针对日记编写一个输出日记的Log函数,该函数的参数就包括日记级别、日记信息、错误文件名称、错误的行数。如下:
  1. void Log(std::string level, std::string message, std::string file_name, int line)
  2. {
  3.     std::cout<<"["<<level<<"]["<<time(nullptr)<<"]["<<message<<"]["<<file_name<<"]["<<line<<"]"<<std::endl;
  4. }
复制代码
分析一下: 调用time函数时传入nullptr即可获取当前的时间戳,因此调用Log函数时不必传入时间戳。
   文件名称和行数的问题
    通过C语言中的预定义符号__FILE__和__LINE__,分别可以获取当前文件的名称和当前的行数,但最好在调用Log函数时不消调用者显示的传入__FILE__和__LINE__,由于每次调用Log函数时传入的这两个参数都是固定的。
  需要留意的是,不能将__FILE__和__LINE__设置为参数的缺省值,由于这样每次获取到的都是Log函数地点的文件名称和地点的行数。而宏可以在预处置惩罚期间将代码插入到目的地点,因此我们可以定义如下宏:
  1. #define LOG(level, message) Log(level, message, __FILE__, __LINE__)
复制代码
  后续需要打印日记的时候就直接调用LOG,调用时只需要传入日记级别和日记信息,在预处置惩罚期间__FILE__和__LINE__就会被插入到目的地点,这时就能获取到日记产生的文件名称和对应的行数了。
   日记级别传入问题
    我们后续调用LOG传入日记级别时,肯定盼望以INFO、WARNING这样的方式传入,而不是以"INFO"、"WARNING"这样的形式传入,这时我们可以将这四个日记级别定义为宏,然后通过#将宏参数level变成对应的字符串。如下:
  1. #define INFO    1
  2. #define WARNING 2
  3. #define ERROR   3
  4. #define FATAL   4
  5. #define LOG(level, message) Log(#level, message, __FILE__, __LINE__)
复制代码
此时以INFO、WARNING的方式传入LOG的宏参数,就会被转换成对应的字符串传递给Log函数的level参数,后续我们就可以以如下方式输出日记了:
  1. LOG(INFO, "This is a demo"); //LOG使用示例
复制代码
套接字相干代码编写

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

  • 将TcpServer类的构造函数设置为私有,并将拷贝构造和拷贝赋值函数设置为私有或删除,防止外部创建或拷贝对象。
  • 提供一个指向单例对象的static指针,并在类外将其初始化为nullptr。
  • 提供一个全局访问点获取单例对象,在单例对象第一次被获取的时候就创建这个单例对象并举行初始化。
代码如下:
  1. #define BACKLOG 5
  2. //TCP服务器
  3. class TcpServer{
  4.     private:
  5.         int _port;              //端口号
  6.         int _listen_sock;       //监听套接字
  7.         static TcpServer* _svr; //指向单例对象的static指针
  8.     private:
  9.         //构造函数私有
  10.         TcpServer(int port)
  11.             :_port(port)
  12.             ,_listen_sock(-1)
  13.         {}
  14.         //将拷贝构造函数和拷贝赋值函数私有或删除(防拷贝)
  15.         TcpServer(const TcpServer&)=delete;
  16.         TcpServer* operator=(const TcpServer&)=delete;
  17.     public:
  18.         //获取单例对象
  19.         static TcpServer* GetInstance(int port)
  20.         {
  21.             static pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER; //定义静态的互斥锁
  22.             if(_svr == nullptr){
  23.                 pthread_mutex_lock(&mtx); //加锁
  24.                 if(_svr == nullptr){
  25.                     //创建单例TCP服务器对象并初始化
  26.                     _svr = new TcpServer(port);
  27.                     _svr->InitServer();
  28.                 }
  29.                 pthread_mutex_unlock(&mtx); //解锁
  30.             }
  31.             return _svr; //返回单例对象
  32.         }
  33.         //初始化服务器
  34.         void InitServer()
  35.         {
  36.             Socket(); //创建套接字
  37.             Bind();   //绑定
  38.             Listen(); //监听
  39.             LOG(INFO, "tcp_server init ... success");
  40.         }
  41.         //创建套接字
  42.         void Socket()
  43.         {
  44.             _listen_sock = socket(AF_INET, SOCK_STREAM, 0);
  45.             if(_listen_sock < 0){ //创建套接字失败
  46.                 LOG(FATAL, "socket error!");
  47.                 exit(1);
  48.             }
  49.             //设置端口复用
  50.             int opt = 1;
  51.             setsockopt(_listen_sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
  52.             LOG(INFO, "create socket ... success");
  53.         }
  54.         //绑定
  55.         void Bind()
  56.         {
  57.             struct sockaddr_in local;
  58.             memset(&local, 0, sizeof(local));
  59.             local.sin_family = AF_INET;
  60.             local.sin_port = htons(_port);
  61.             local.sin_addr.s_addr = INADDR_ANY;
  62.             if(bind(_listen_sock, (struct sockaddr*)&local, sizeof(local)) < 0){ //绑定失败
  63.                 LOG(FATAL, "bind error!");
  64.                 exit(2);
  65.             }
  66.             LOG(INFO, "bind socket ... success");
  67.         }
  68.         //监听
  69.         void Listen()
  70.         {
  71.             if(listen(_listen_sock, BACKLOG) < 0){ //监听失败
  72.                 LOG(FATAL, "listen error!");
  73.                 exit(3);
  74.             }
  75.             LOG(INFO, "listen socket ... success");
  76.         }
  77.         //获取监听套接字
  78.         int Sock()
  79.         {
  80.             return _listen_sock;
  81.         }
  82.         ~TcpServer()
  83.         {
  84.             if(_listen_sock >= 0){ //关闭监听套接字
  85.                 close(_listen_sock);
  86.             }
  87.         }
  88. };
  89. //单例对象指针初始化为nullptr
  90. TcpServer* TcpServer::_svr = nullptr;
复制代码
分析一下:


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

   HTTP服务器主体逻辑
    我们可以将HTTP服务器封装成一个HttpServer类,在构造HttpServer对象时传入一个端标语,之后就可以调用Loop让服务器运行起来了。服务器运行起来后要做的就是,先获取单例对象TcpServer中的监听套接字,然后不停从监听套接字中获取新毗连,每当获取到一个新毗连后就创建一个新线程为该毗连提供服务。
代码如下:
  1. #define PORT 8081
  2. //HTTP服务器
  3. class HttpServer{
  4.     private:
  5.         int _port; //端口号
  6.     public:
  7.         HttpServer(int port)
  8.             :_port(port)
  9.         {}
  10.         //启动服务器
  11.         void Loop()
  12.         {
  13.             LOG(INFO, "loop begin");
  14.             TcpServer* tsvr = TcpServer::GetInstance(_port); //获取TCP服务器单例对象
  15.             int listen_sock = tsvr->Sock(); //获取监听套接字
  16.             while(true){
  17.                 struct sockaddr_in peer;
  18.                 memset(&peer, 0, sizeof(peer));
  19.                 socklen_t len = sizeof(peer);
  20.                 int sock = accept(listen_sock, (struct sockaddr*)&peer, &len); //获取新连接
  21.                 if(sock < 0){
  22.                     continue; //获取失败,继续获取
  23.                 }
  24.                 //打印客户端相关信息
  25.                 std::string client_ip = inet_ntoa(peer.sin_addr);
  26.                 int client_port = ntohs(peer.sin_port);
  27.                 LOG(INFO, "get a new link: ["+client_ip+":"+std::to_string(client_port)+"]");
  28.                
  29.                 //创建新线程处理新连接发起的HTTP请求
  30.                 int* p = new int(sock);
  31.                 pthread_t tid;
  32.                 pthread_create(&tid, nullptr, CallBack::HandlerRequest, (void*)p);
  33.                 pthread_detach(tid); //线程分离
  34.             }
  35.         }
  36.         ~HttpServer()
  37.         {}
  38. };
复制代码
分析一下:


  • 服务器需要将新毗连对应的套接字作为参数传递给新线程,为了制止该套接字在新线程读取之前被下一次获取到的套接字覆盖,因此在传递套接字时最好重新new一块空间来存储套接字的值。
  • 新线程创建后可以将新线程分离,分离后主线程继承获取新毗连,而新线程则处置惩罚新毗连发来的HTTP请求,代码中的HandlerRequest函数就是新线程处置惩罚新毗连时需要执行的回调函数。
   主函数逻辑
    运行服务器时要求指定服务器的端标语,我们用这个端标语创建一个HttpServer对象,然后调用Loop函数运行服务器,此时服务器就会不停获取新毗连并创建新线程来处置惩罚毗连。
代码如下:
  1. static void Usage(std::string proc)
  2. {
  3.     std::cout<<"Usage:\n\t"<<proc<<" port"<<std::endl;
  4. }
  5. int main(int argc, char* argv[])
  6. {
  7.     if(argc != 2){
  8.         Usage(argv[0]);
  9.         exit(4);
  10.     }
  11.     int port = atoi(argv[1]); //端口号
  12.     std::shared_ptr<HttpServer> svr(new HttpServer(port)); //创建HTTP服务器对象
  13.     svr->Loop(); //启动服务器
  14.     return 0;
  15. }
复制代码
HTTP请求结构计划

   HTTP请求类
    我们可以将HTTP请求封装成一个类,这个类当中包括HTTP请求的内容、HTTP请求的剖析结果以及是否需要使用CGI模式的标志位。后续处置惩罚请求时就可以定义一个HTTP请求类,读取到的HTTP请求的数据就存储在这个类当中,剖析HTTP请求后得到的数据也存储在这个类当中。
代码如下:
  1. //HTTP请求
  2. class HttpRequest{
  3.     public:
  4.         //HTTP请求内容
  5.         std::string _request_line;                //请求行
  6.         std::vector<std::string> _request_header; //请求报头
  7.         std::string _blank;                       //空行
  8.         std::string _request_body;                //请求正文
  9.         //解析结果
  10.         std::string _method;       //请求方法
  11.         std::string _uri;          //URI
  12.         std::string _version;      //版本号
  13.         std::unordered_map<std::string, std::string> _header_kv; //请求报头中的键值对
  14.         int _content_length;       //正文长度
  15.         std::string _path;         //请求资源的路径
  16.         std::string _query_string; //uri中携带的参数
  17.         //CGI相关
  18.         bool _cgi; //是否需要使用CGI模式
  19.     public:
  20.         HttpRequest()
  21.             :_content_length(0) //默认请求正文长度为0
  22.             ,_cgi(false)        //默认不使用CGI模式
  23.         {}
  24.         ~HttpRequest()
  25.         {}
  26. };
复制代码
HTTP相应结构计划

   HTTP相应类
    HTTP相应也可以封装成一个类,这个类当中包括HTTP相应的内容以及构建HTTP相应所需要的数据。后续构建相应时就可以定义一个HTTP相应类,构建相应需要使用的数据就存储在这个类当中,构建后得到的相应内容也存储在这个类当中。
代码如下:
  1. //HTTP响应
  2. class HttpResponse{
  3.     public:
  4.         //HTTP响应内容
  5.         std::string _status_line;                  //状态行
  6.         std::vector<std::string> _response_header; //响应报头
  7.         std::string _blank;                        //空行
  8.         std::string _response_body;                //响应正文(CGI相关)
  9.         //所需数据
  10.         int _status_code;    //状态码
  11.         int _fd;             //响应文件的fd  (非CGI相关)
  12.         int _size;           //响应文件的大小(非CGI相关)
  13.         std::string _suffix; //响应文件的后缀(非CGI相关)
  14.     public:
  15.         HttpResponse()
  16.             :_blank(LINE_END) //设置空行
  17.             ,_status_code(OK) //状态码默认为200
  18.             ,_fd(-1)          //响应文件的fd初始化为-1
  19.             ,_size(0)         //响应文件的大小默认为0
  20.         {}
  21.         ~HttpResponse()
  22.         {}
  23. };
复制代码
EndPoint类编写

EndPoint结构计划

   EndPoint结构计划
    EndPoint这个词常常用来描述历程间通信,比如在客户端和服务器通信时,客户端是一个EndPoint,服务器则是另一个EndPoint,因此这里将处置惩罚请求的类取名为EndPoint。
EndPoint类中包罗三个成员变量:


  • sock:表示与客户端举行通信的套接字。
  • http_request:表示客户端发来的HTTP请求。
  • http_response:表示将会发送给客户端的HTTP相应。
EndPoint类中重要包罗四个成员函数:


  • RecvHttpRequest:读取客户端发来的HTTP请求。
  • HandlerHttpRequest:处置惩罚客户端发来的HTTP请求。
  • BuildHttpResponse:构建将要发送给客户端的HTTP相应。
  • SendHttpResponse:发送HTTP相应给客户端。
代码如下:
  1. //服务端EndPoint
  2. class EndPoint{
  3.     private:
  4.         int _sock;                   //通信的套接字
  5.         HttpRequest _http_request;   //HTTP请求
  6.         HttpResponse _http_response; //HTTP响应
  7.     public:
  8.         EndPoint(int sock)
  9.             :_sock(sock)
  10.         {}
  11.         //读取请求
  12.         void RecvHttpRequest();
  13.         //处理请求
  14.         void HandlerHttpRequest();
  15.         //构建响应
  16.         void BuildHttpResponse();
  17.         //发送响应
  18.         void SendHttpResponse();
  19.         ~EndPoint()
  20.         {}
  21. };
复制代码
计划线程回调

   计划线程回调
    服务器每获取到一个新毗连就会创建一个新线程来举行处置惩罚,而这个线程要做的实际就是定义一个EndPoint对象,然后依次举行读取请求、处置惩罚请求、构建相应、发送相应,处置惩罚完毕后将与客户端创建的套接字关闭即可。
代码如下:
  1. class CallBack{
  2.     public:
  3.         static void* HandlerRequest(void* arg)
  4.         {
  5.             LOG(INFO, "handler request begin");
  6.             int sock = *(int*)arg;
  7.             
  8.             EndPoint* ep = new EndPoint(sock);
  9.             ep->RecvHttpRequest();    //读取请求
  10.             ep->HandlerHttpRequest(); //处理请求
  11.             ep->BuildHttpResponse();  //构建响应
  12.             ep->SendHttpResponse();   //发送响应
  13.             close(sock); //关闭与该客户端建立的套接字
  14.             delete ep;
  15.             LOG(INFO, "handler request end");
  16.             return nullptr;
  17.         }
  18. };
复制代码
读取HTTP请求

   读取HTTP请求
    读取HTTP请求的同时可以对HTTP请求举行剖析,这里我们分为五个步骤,分别是读取请求行、读取请求报头和空行、剖析请求行、剖析请求报头、读取请求正文。
代码如下:
  1. //服务端EndPoint
  2. class EndPoint{
  3.     private:
  4.         int _sock;                   //通信的套接字
  5.         HttpRequest _http_request;   //HTTP请求
  6.         HttpResponse _http_response; //HTTP响应
  7.     public:
  8.         //读取请求
  9.         void RecvHttpRequest()
  10.         {
  11.             RecvHttpRequestLine();    //读取请求行
  12.             RecvHttpRequestHeader();  //读取请求报头和空行
  13.             ParseHttpRequestLine();   //解析请求行
  14.             ParseHttpRequestHeader(); //解析请求报头
  15.             RecvHttpRequestBody();    //读取请求正文
  16.         }
  17. };
复制代码
  一、读取请求行
    读取请求行很简单,就是从套接字中读取一行内容存储到HTTP请求类中的request_line中即可。
代码如下:
  1. //服务端EndPoint
  2. class EndPoint{
  3.     private:
  4.         int _sock;                   //通信的套接字
  5.         HttpRequest _http_request;   //HTTP请求
  6.         HttpResponse _http_response; //HTTP响应
  7.     private:
  8.         //读取请求行
  9.         void RecvHttpRequestLine()
  10.         {
  11.             auto& line = _http_request._request_line;
  12.             if(Util::ReadLine(_sock, line) > 0){
  13.                 line.resize(line.size() - 1); //去掉读取上来的\n
  14.             }
  15.         }
  16. };
复制代码
  需要留意的是,这里在按行读取HTTP请求时,不能直接使用C/C++提供的gets或getline函数举行读取,由于差别平台下的行分隔符可能是不一样的,可能是\r、\n大概\r\n。
比如下面是用WFetch请求百度首页时得到的HTTP相应,可以看到其中使用的行分隔符就是\r\n:

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


  • 从指定套接字中读取一个个字符。
  • 如果读取到的字符既不是\n也不是\r,则将读取到的字符push到用户提供的缓冲区后继承读取下一个字符。
  • 如果读取到的字符是\n,则分析行分隔符是\n,此时将\npush到用户提供的缓冲区后停止读取。
  • 如果读取到的字符是\r,则需要继承窥伺下一个字符是否是\n,如果窥伺成功则分析行分隔符为\r\n,此时将未读取的\n读取上来后,将\npush到用户提供的缓冲区后停止读取;如果窥伺失败则分析行分隔符是\r,此时也将\npush到用户提供的缓冲区后停止读取。
  也就是说,无论是哪一种行分隔符,最终读取完一行后我们都把\npush到了用户提供的缓冲区当中,相称于将这三种行分隔符同一转换成了以\n为行分隔符,只不过最终我们把\n一同读取到了用户提供的缓冲区中罢了,因此如果调用者不需要读取上来的\n,需要后续自行将其去掉。
代码如下:
  1. //工具类
  2. class Util{
  3.     public:
  4.         //读取一行
  5.         static int ReadLine(int sock, std::string& out)
  6.         {
  7.             char ch = 'X'; //ch只要不初始化为\n即可(保证能够进入while循环)
  8.             while(ch != '\n'){
  9.                 ssize_t size = recv(sock, &ch, 1, 0);
  10.                 if(size > 0){
  11.                     if(ch == '\r'){
  12.                         //窥探下一个字符是否为\n
  13.                         recv(sock, &ch, 1, MSG_PEEK);
  14.                         if(ch == '\n'){ //下一个字符是\n
  15.                             //\r\n->\n
  16.                             recv(sock, &ch, 1, 0); //将这个\n读走
  17.                         }
  18.                         else{ //下一个字符不是\n
  19.                             //\r->\n
  20.                             ch = '\n'; //将ch设置为\n
  21.                         }
  22.                     }
  23.                     //普通字符或\n
  24.                     out.push_back(ch);
  25.                 }
  26.                 else if(size == 0){ //对方关闭连接
  27.                     return 0;
  28.                 }
  29.                 else{ //读取失败
  30.                     return -1;
  31.                 }
  32.             }
  33.             return out.size(); //返回读取到的字符个数
  34.         }
  35. };
复制代码
分析一下: recv函数的末了一个参数如果设置为MSG_PEEK,那么recv函数将返回TCP接收缓冲区头部指定字节个数的数据,但是并不把这些数据从TCP接收缓冲区中取走,这个叫做数据的窥伺功能。
   二、读取请求报头和空行
    由于HTTP的请求报头和空行都是按行陈列的,因此可以循环调用ReadLine函数举行读取,并将读取到的每行数据都存储到HTTP请求类的request_header中,直到读取到空行为止。
代码如下:
  1. //服务端EndPoint
  2. class EndPoint{
  3.     private:
  4.         int _sock;                   //通信的套接字
  5.         HttpRequest _http_request;   //HTTP请求
  6.         HttpResponse _http_response; //HTTP响应
  7.     private:
  8.         //读取请求报头和空行
  9.         void RecvHttpRequestHeader()
  10.         {
  11.             std::string line;
  12.             while(true){
  13.                 line.clear(); //每次读取之前清空line
  14.                 Util::ReadLine(_sock, line);
  15.                 if(line == "\n"){ //读取到了空行
  16.                     _http_request._blank = line;
  17.                     break;
  18.                 }
  19.                 //读取到一行请求报头
  20.                 line.resize(line.size() - 1); //去掉读取上来的\n
  21.                 _http_request._request_header.push_back(line);
  22.             }
  23.         }
  24. };
复制代码
分析一下:


  • 由于ReadLine函数是将读取到的数据直接push_back到用户提供的缓冲区中的,因此每次调用ReadLine函数举行读取之前需要将缓冲区清空。
  • ReadLine函数会将行分隔符\n一同读取上来,但对于我们来说\n并不是有用数据,因此在将读取到的行存储到HTTP请求类的request_header中之前,需要先将\n去掉。
   三、剖析请求行
    剖析请求行要做的就是将请求行中的请求方法、URI和HTTP版本号拆分出来,依次存储到HTTP请求类的method、uri和version中,由于请求行中的这些数据都是以空格作为分隔符的,因此可以借助一个stringstream对象来举行拆分。此外,为了后续能够正确判断用户的请求方法,这里需要通过transform函数同一将请求方法转换为全大写。
代码如下:
  1. //服务端EndPoint
  2. class EndPoint{
  3.     private:
  4.         int _sock;                   //通信的套接字
  5.         HttpRequest _http_request;   //HTTP请求
  6.         HttpResponse _http_response; //HTTP响应
  7.     private:
  8.         //解析请求行
  9.         void ParseHttpRequestLine()
  10.         {
  11.             auto& line = _http_request._request_line;
  12.             //通过stringstream拆分请求行
  13.             std::stringstream ss(line);
  14.             ss>>_http_request._method>>_http_request._uri>>_http_request._version;
  15.             //将请求方法统一转换为全大写
  16.             auto& method = _http_request._method;
  17.             std::transform(method.begin(), method.end(), method.begin(), toupper);
  18.         }
  19. };
复制代码
  四、剖析请求报头
    剖析请求报头要做的就是将读取到的一行一行的请求报头,以: 为分隔符拆分成一个个的键值对存储到HTTP请求的header_kv中,后续就可以直接通过属性名获取到对应的值了。
代码如下:
  1. #define SEP ": "
  2. //服务端EndPoint
  3. class EndPoint{
  4.     private:
  5.         int _sock;                   //通信的套接字
  6.         HttpRequest _http_request;   //HTTP请求
  7.         HttpResponse _http_response; //HTTP响应
  8.     private:
  9.         //解析请求报头
  10.         void ParseHttpRequestHeader()
  11.         {
  12.             std::string key;
  13.             std::string value;
  14.             for(auto& iter : _http_request._request_header){
  15.                 //将每行请求报头打散成kv键值对,插入到unordered_map中
  16.                 if(Util::CutString(iter, key, value, SEP)){
  17.                     _http_request._header_kv.insert({key, value});
  18.                 }
  19.             }
  20.         }
  21. };
复制代码
  此处用于切割字符串的CutString函数也可以写到工具类中,切割字符串时先通过find方法找到指定的分隔符,然后通过substr提取切割后的子字符串即可。
代码如下:
  1. //工具类
  2. class Util{
  3.     public:
  4.         //切割字符串
  5.         static bool CutString(std::string& target, std::string& sub1_out, std::string& sub2_out, std::string sep)
  6.         {
  7.             size_t pos = target.find(sep, 0);
  8.             if(pos != std::string::npos){
  9.                 sub1_out = target.substr(0, pos);
  10.                 sub2_out = target.substr(pos + sep.size());
  11.                 return true;
  12.             }
  13.             return false;
  14.         }
  15. };
复制代码
  五、读取请求正文
    在读取请求正文之前,起首需要通过本次的请求方法来判断是否需要读取请求正文,由于只有请求方法是POST方法才可能会有请求正文,此外,如果请求方法为POST,我们还需要通过请求报头中的Content-Length属性来得知请求正文的长度。
  在得知需要读取请求正文以及请求正文的长度后,就可以将请求正文读取到HTTP请求类的request_body中了。
代码如下:
  1. //服务端EndPoint
  2. class EndPoint{
  3.     private:
  4.         int _sock;                   //通信的套接字
  5.         HttpRequest _http_request;   //HTTP请求
  6.         HttpResponse _http_response; //HTTP响应
  7.     private:
  8.         //判断是否需要读取请求正文
  9.         bool IsNeedRecvHttpRequestBody()
  10.         {
  11.             auto& method = _http_request._method;
  12.             if(method == "POST"){ //请求方法为POST则需要读取正文
  13.                 auto& header_kv = _http_request._header_kv;
  14.                 //通过Content-Length获取请求正文长度
  15.                 auto iter = header_kv.find("Content-Length");
  16.                 if(iter != header_kv.end()){
  17.                     _http_request._content_length = atoi(iter->second.c_str());
  18.                     return true;
  19.                 }
  20.             }
  21.             return false;
  22.         }
  23.         //读取请求正文
  24.         void RecvHttpRequestBody()
  25.         {
  26.             if(IsNeedRecvHttpRequestBody()){ //先判断是否需要读取正文
  27.                 int content_length = _http_request._content_length;
  28.                 auto& body = _http_request._request_body;
  29.                 //读取请求正文
  30.                 char ch = 0;
  31.                 while(content_length){
  32.                     ssize_t size = recv(_sock, &ch, 1, 0);
  33.                     if(size > 0){
  34.                         body.push_back(ch);
  35.                         content_length--;
  36.                     }
  37.                     else{
  38.                         break;
  39.                     }
  40.                 }
  41.             }
  42.         }
  43. };
复制代码
分析一下:


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

   定义状态码
    在处置惩罚请求的过程中可能会由于某些原因而直接停止处置惩罚,比如请求方法不正确、请求资源不存在或服务器处置惩罚请求时出错等等。为了告知客户端本次HTTP请求的处置惩罚环境,服务器需要定义差别的状态码,当处置惩罚请求被终止时就可以设置对应的状态码,后续构建HTTP相应的时候就可以根据状态码返回对应的错误页面。
状态码定义如下:
  1. #define OK 200
  2. #define BAD_REQUEST 400
  3. #define NOT_FOUND 404
  4. #define INTERNAL_SERVER_ERROR 500
复制代码
  处置惩罚HTTP请求
  处置惩罚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处置惩罚。
代码如下:
  1. #define WEB_ROOT "wwwroot"
  2. #define HOME_PAGE "index.html"
  3. //服务端EndPoint
  4. class EndPoint{
  5.     private:
  6.         int _sock;                   //通信的套接字
  7.         HttpRequest _http_request;   //HTTP请求
  8.         HttpResponse _http_response; //HTTP响应
  9.     public:
  10.         //处理请求
  11.         void HandlerHttpRequest()
  12.         {
  13.             auto& code = _http_response._status_code;
  14.             if(_http_request._method != "GET"&&_http_request._method != "POST"){ //非法请求
  15.                 LOG(WARNING, "method is not right");
  16.                 code = BAD_REQUEST; //设置对应的状态码,并直接返回
  17.                 return;
  18.             }
  19.             if(_http_request._method == "GET"){
  20.                 size_t pos = _http_request._uri.find('?');
  21.                 if(pos != std::string::npos){ //uri中携带参数
  22.                     //切割uri,得到客户端请求资源的路径和uri中携带的参数
  23.                     Util::CutString(_http_request._uri, _http_request._path, _http_request._query_string, "?");
  24.                     _http_request._cgi = true; //上传了参数,需要使用CGI模式
  25.                 }
  26.                 else{ //uri中没有携带参数
  27.                     _http_request._path = _http_request._uri; //uri即是客户端请求资源的路径
  28.                 }
  29.             }
  30.             else if(_http_request._method == "POST"){
  31.                 _http_request._path = _http_request._uri; //uri即是客户端请求资源的路径
  32.                 _http_request._cgi = true; //上传了参数,需要使用CGI模式
  33.             }
  34.             else{
  35.                 //Do Nothing
  36.             }
  37.             //给请求资源路径拼接web根目录
  38.             std::string path = _http_request._path;
  39.             _http_request._path = WEB_ROOT;
  40.             _http_request._path += path;
  41.             //请求资源路径以/结尾,说明请求的是一个目录
  42.             if(_http_request._path[_http_request._path.size() - 1] == '/'){
  43.                 //拼接上该目录下的index.html
  44.                 _http_request._path += HOME_PAGE;
  45.             }
  46.             
  47.             //获取请求资源文件的属性信息
  48.             struct stat st;
  49.             if(stat(_http_request._path.c_str(), &st) == 0){ //属性信息获取成功,说明该资源存在
  50.                 if(S_ISDIR(st.st_mode)){ //该资源是一个目录
  51.                     _http_request._path += "/"; //需要拼接/,以/结尾的目录前面已经处理过了
  52.                     _http_request._path += HOME_PAGE; //拼接上该目录下的index.html
  53.                     stat(_http_request._path.c_str(), &st); //需要重新资源文件的属性信息
  54.                 }
  55.                 else if(st.st_mode&S_IXUSR||st.st_mode&S_IXGRP||st.st_mode&S_IXOTH){ //该资源是一个可执行程序
  56.                     _http_request._cgi = true; //需要使用CGI模式
  57.                 }
  58.                 _http_response._size = st.st_size; //设置请求资源文件的大小
  59.             }
  60.             else{ //属性信息获取失败,可以认为该资源不存在
  61.                 LOG(WARNING, _http_request._path + " NOT_FOUND");
  62.                 code = NOT_FOUND; //设置对应的状态码,并直接返回
  63.                 return;
  64.             }
  65.             //获取请求资源文件的后缀
  66.             size_t pos = _http_request._path.rfind('.');
  67.             if(pos == std::string::npos){
  68.                 _http_response._suffix = ".html"; //默认设置
  69.             }
  70.             else{
  71.                 _http_response._suffix = _http_request._path.substr(pos);
  72.             }
  73.             //进行CGI或非CGI处理
  74.             if(_http_request._cgi == true){
  75.                 code = ProcessCgi(); //以CGI的方式进行处理
  76.             }
  77.             else{
  78.                 code = ProcessNonCgi(); //简单的网页返回,返回静态网页
  79.             }
  80.         }
  81. };
复制代码
分析一下:


  • 本项目实现的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中。
   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程序退出,并关闭两个管道对应的文件描述符,防止文件描述符走漏。
代码如下:
  1. //服务端EndPoint
  2. class EndPoint{
  3.     private:
  4.         int _sock;                   //通信的套接字
  5.         HttpRequest _http_request;   //HTTP请求
  6.         HttpResponse _http_response; //HTTP响应
  7.     private:
  8.         //CGI处理
  9.         int ProcessCgi()
  10.         {
  11.             int code = OK; //要返回的状态码,默认设置为200
  12.             auto& bin = _http_request._path;      //需要执行的CGI程序
  13.             auto& method = _http_request._method; //请求方法
  14.             //需要传递给CGI程序的参数
  15.             auto& query_string = _http_request._query_string; //GET
  16.             auto& request_body = _http_request._request_body; //POST
  17.             int content_length = _http_request._content_length;  //请求正文的长度
  18.             auto& response_body = _http_response._response_body; //CGI程序的处理结果放到响应正文当中
  19.             //1、创建两个匿名管道(管道命名站在父进程角度)
  20.             //创建从子进程到父进程的通信信道
  21.             int input[2];
  22.             if(pipe(input) < 0){ //管道创建失败,则返回对应的状态码
  23.                 LOG(ERROR, "pipe input error!");
  24.                 code = INTERNAL_SERVER_ERROR;
  25.                 return code;
  26.             }
  27.             //创建从父进程到子进程的通信信道
  28.             int output[2];
  29.             if(pipe(output) < 0){ //管道创建失败,则返回对应的状态码
  30.                 LOG(ERROR, "pipe output error!");
  31.                 code = INTERNAL_SERVER_ERROR;
  32.                 return code;
  33.             }
  34.             //2、创建子进程
  35.             pid_t pid = fork();
  36.             if(pid == 0){ //child
  37.                 //子进程关闭两个管道对应的读写端
  38.                 close(input[0]);
  39.                 close(output[1]);
  40.                 //将请求方法通过环境变量传参
  41.                 std::string method_env = "METHOD=";
  42.                 method_env += method;
  43.                 putenv((char*)method_env.c_str());
  44.                 if(method == "GET"){ //将query_string通过环境变量传参
  45.                     std::string query_env = "QUERY_STRING=";
  46.                     query_env += query_string;
  47.                     putenv((char*)query_env.c_str());
  48.                     LOG(INFO, "GET Method, Add Query_String env");
  49.                 }
  50.                 else if(method == "POST"){ //将正文长度通过环境变量传参
  51.                     std::string content_length_env = "CONTENT_LENGTH=";
  52.                     content_length_env += std::to_string(content_length);
  53.                     putenv((char*)content_length_env.c_str());
  54.                     LOG(INFO, "POST Method, Add Content_Length env");
  55.                 }
  56.                 else{
  57.                     //Do Nothing
  58.                 }
  59.                 //3、将子进程的标准输入输出进行重定向
  60.                 dup2(output[0], 0); //标准输入重定向到管道的输入
  61.                 dup2(input[1], 1);  //标准输出重定向到管道的输出
  62.                 //4、将子进程替换为对应的CGI程序
  63.                 execl(bin.c_str(), bin.c_str(), nullptr);
  64.                 exit(1); //替换失败
  65.             }
  66.             else if(pid < 0){ //创建子进程失败,则返回对应的错误码
  67.                 LOG(ERROR, "fork error!");
  68.                 code = INTERNAL_SERVER_ERROR;
  69.                 return code;
  70.             }
  71.             else{ //father
  72.                 //父进程关闭两个管道对应的读写端
  73.                 close(input[1]);
  74.                 close(output[0]);
  75.                 if(method == "POST"){ //将正文中的参数通过管道传递给CGI程序
  76.                     const char* start = request_body.c_str();
  77.                     int total = 0;
  78.                     int size = 0;
  79.                     while(total < content_length && (size = write(output[1], start + total, request_body.size() - total)) > 0){
  80.                         total += size;
  81.                     }
  82.                 }
  83.                 //读取CGI程序的处理结果
  84.                 char ch = 0;
  85.                 while(read(input[0], &ch, 1) > 0){
  86.                     response_body.push_back(ch);
  87.                 } //不会一直读,当另一端关闭后会继续执行下面的代码
  88.                 //等待子进程(CGI程序)退出
  89.                 int status = 0;
  90.                 pid_t ret = waitpid(pid, &status, 0);
  91.                 if(ret == pid){
  92.                     if(WIFEXITED(status)){ //正常退出
  93.                         if(WEXITSTATUS(status) == 0){ //结果正确
  94.                             LOG(INFO, "CGI program exits normally with correct results");
  95.                             code = OK;
  96.                         }
  97.                         else{
  98.                             LOG(INFO, "CGI program exits normally with incorrect results");
  99.                             code = BAD_REQUEST;
  100.                         }
  101.                     }
  102.                     else{
  103.                         LOG(INFO, "CGI program exits abnormally");
  104.                         code = INTERNAL_SERVER_ERROR;
  105.                     }
  106.                 }
  107.                 //关闭两个管道对应的文件描述符
  108.                 close(input[0]);
  109.                 close(output[1]);
  110.             }
  111.             return code; //返回状态码
  112.         }
  113. };
复制代码
分析一下:


  • 在CGI处置惩罚过程中,如果管道创建失败大概子历程创建失败,则属于服务器端处置惩罚请求时出错,此时返回INTERNAL_SERVER_ERROR状态码后停止处置惩罚即可。
  • 环境变量是key=value形式的,因此在调用putenv函数导入环境变量前需要先正确构建环境变量,此后被替换的CGI程序在调用getenv函数时,就可以通过key获取到对应的value。
  • 子历程传递参数的代码最好放在重定向之前,否则服务器运行后无法看到传递参数对应的日记信息,由于日记是以cout的方式打印到尺度输出的,而dup2函数调用后尺度输出已经被重定向到了管道,此时打印的日记信息将会被写入管道。
  • 父历程循环调用read函数从管道中读取CGI程序的处置惩罚结果,当CGI程序执行竣事时相称于写端历程将写端关闭了(文件描述符的生命周期随历程),此时读端历程将管道当中的数据读完后,就会继承执行后续代码,而不会被阻塞。
  • 父历程在等待子历程退出后,可以通过WIFEXITED判断子历程是否是正常退出,如果是正常退出再通过WEXITSTATUS判断处置惩罚结果是否正确,然后根据不恻隐况设置对应的状态码(此时就算子历程异常退出或处置惩罚结果不正确也不能立即返回,需要让父历程继承向后执行,关闭两个管道对应的文件描述符,防止文件描述符走漏)。
   非CGI处置惩罚
    非CGI处置惩罚时只需要将客户端请求的资源构建成HTTP相应发送给客户端即可,理论上这里要做的就是打开目的文件,将文件中的内容读取到HTTP相应类的response_body中,以供后续发送HTTP相应时举行发送即可,但我们并不推荐这种做法。
  由于HTTP相应类的response_body属于用户层的缓冲区,而目的文件是存储在服务器的磁盘上的,按照这种方式需要先将文件内容读取到内核层缓冲区,再由操作系统将其拷贝到用户层缓冲区,发送相应正文的时候又需要先将其拷贝到内核层缓冲区,再由操作系统将其发送给对应的网卡举行发送。
表示图如下:

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

  要达到上述结果就需要使用sendfile函数,该函数的功能就是将数据从一个文件描述符拷贝到另一个文件描述符,并且这个拷贝操作是在内核中完成的,因此sendfile比单纯的调用read和write更加高效。
  但是需要留意的是,这里还不能直接调用sendfile函数,由于sendfile函数调用后文件内容就发送出去了,而我们应该构建HTTP相应后再举行发送,因此我们这里要做的仅仅是将要发送的目的文件打开即可,将打开文件对应的文件描述符保存到HTTP相应的fd当中。
代码如下:
  1. //服务端EndPoint
  2. class EndPoint{
  3.     private:
  4.         int _sock;                   //通信的套接字
  5.         HttpRequest _http_request;   //HTTP请求
  6.         HttpResponse _http_response; //HTTP响应
  7.     private:
  8.         //非CGI处理
  9.         int ProcessNonCgi()
  10.         {
  11.             //打开客户端请求的资源文件,以供后续发送
  12.             _http_response._fd = open(_http_request._path.c_str(), O_RDONLY);
  13.             if(_http_response._fd >= 0){ //打开文件成功
  14.                 return OK;
  15.             }
  16.             return INTERNAL_SERVER_ERROR; //打开文件失败
  17.         }
  18. };
复制代码
分析一下: 如果打开文件失败,则返回INTERNAL_SERVER_ERROR状态码表示服务器处置惩罚请求时出错,而不能返回NOT_FOUND,由于之前调用stat获取过客户端请求资源的属性信息,分析该资源文件是肯定存在的。
构建HTTP相应

   构建HTTP相应
    构建HTTP相应起首需要构建的就是状态行,状态行由状态码、状态码描述、HTTP版本构成,并以空格作为分隔符,将状态行构建好后保存到HTTP相应的status_line当中即可,而相应报头需要根据请求是否正常处置惩罚完毕分别举行构建。
代码如下:
  1. #define HTTP_VERSION "HTTP/1.0"
  2. #define LINE_END "\r\n"
  3. #define PAGE_400 "400.html"
  4. #define PAGE_404 "404.html"
  5. #define PAGE_500 "500.html"
  6. //服务端EndPoint
  7. class EndPoint{
  8.     private:
  9.         int _sock;                   //通信的套接字
  10.         HttpRequest _http_request;   //HTTP请求
  11.         HttpResponse _http_response; //HTTP响应
  12.     public:
  13.         //构建响应
  14.         void BuildHttpResponse()
  15.         {
  16.             int code = _http_response._status_code;
  17.             //构建状态行
  18.             auto& status_line = _http_response._status_line;
  19.             status_line += HTTP_VERSION;
  20.             status_line += " ";
  21.             status_line += std::to_string(code);
  22.             status_line += " ";
  23.             status_line += CodeToDesc(code);
  24.             status_line += LINE_END;
  25.             //构建响应报头
  26.             std::string path = WEB_ROOT;
  27.             path += "/";
  28.             switch(code){
  29.                 case OK:
  30.                     BuildOkResponse();
  31.                     break;
  32.                 case NOT_FOUND:
  33.                     path += PAGE_404;
  34.                     HandlerError(path);
  35.                     break;
  36.                 case BAD_REQUEST:
  37.                     path += PAGE_400;
  38.                     HandlerError(path);
  39.                     break;
  40.                 case INTERNAL_SERVER_ERROR:
  41.                     path += PAGE_500;
  42.                     HandlerError(path);
  43.                     break;
  44.                 default:
  45.                     break;
  46.             }
  47.         }
  48. };
复制代码
留意: 本项目中将服务器的行分隔符设置为\r\n,在构建完状态行以及每行相应报头之后都需要加上对应的行分隔符,而在HTTP相应类的构造函数中已经将空行初始化为了LINE_END,因此在构建HTTP相应时不消处置惩罚空行。
  对于状态行中的状态码描述,我们可以编写一个函数,该函数能够根据状态码返回对应的状态码描述。
代码如下:
  1. //根据状态码获取状态码描述
  2. static std::string CodeToDesc(int code)
  3. {
  4.     std::string desc;
  5.     switch(code){
  6.         case 200:
  7.             desc = "OK";
  8.             break;
  9.         case 400:
  10.             desc = "Bad Request";
  11.             break;
  12.         case 404:
  13.             desc = "Not Found";
  14.             break;
  15.         case 500:
  16.             desc = "Internal Server Error";
  17.             break;
  18.         default:
  19.             break;
  20.     }
  21.     return desc;
  22. }
复制代码
  构建相应报头(请求正常处置惩罚完毕)
    构建HTTP的相应报头时,我们至少需要构建Content-Type和Content-Length这两个相应报头,分别用于告知对方相应资源的类型和相应资源的长度。
  对于请求正常处置惩罚完毕的HTTP请求,需要根据客户端请求资源的后缀来得知返回资源的类型。而返回资源的巨细需要根据该请求被处置惩罚的方式来得知,如果该请求是以非CGI方式举行处置惩罚的,那么返回资源的巨细早已在获取请求资源属性时被保存到了HTTP相应类中的size当中,如果该请求是以CGI方式举行处置惩罚的,那么返回资源的巨细应该是HTTP相应类中的response_body的巨细。
代码如下:
  1. //服务端EndPoint
  2. class EndPoint{
  3.     private:
  4.         int _sock;                   //通信的套接字
  5.         HttpRequest _http_request;   //HTTP请求
  6.         HttpResponse _http_response; //HTTP响应
  7.     private:
  8.         void BuildOkResponse()
  9.         {
  10.             //构建响应报头
  11.             std::string content_type = "Content-Type: ";
  12.             content_type += SuffixToDesc(_http_response._suffix);
  13.             content_type += LINE_END;
  14.             _http_response._response_header.push_back(content_type);
  15.             std::string content_length = "Content-Length: ";
  16.             if(_http_request._cgi){ //以CGI方式请求
  17.                 content_length += std::to_string(_http_response._response_body.size());
  18.             }
  19.             else{ //以非CGI方式请求
  20.                 content_length += std::to_string(_http_response._size);
  21.             }
  22.             content_length += LINE_END;
  23.             _http_response._response_header.push_back(content_length);
  24.         }
  25. };
复制代码
  对于返回资源的类型,我们可以编写一个函数,该函数能够根据文件后缀返回对应的文件类型。查看Content-Type转化表可以得知后缀与文件类型的对应关系,将这个对应关系存储一个unordered_map容器中,当需要根据后缀得知文件类型时直接在这个unordered_map容器中举行查找,如果找到了则返回对应的文件类型,如果没有找到则默认该文件类型为text/html。
代码如下:
  1. //根据后缀获取资源类型
  2. static std::string SuffixToDesc(const std::string& suffix)
  3. {
  4.     static std::unordered_map<std::string, std::string> suffix_to_desc = {
  5.         {".html", "text/html"},
  6.         {".css", "text/css"},
  7.         {".js", "application/x-javascript"},
  8.         {".jpg", "application/x-jpg"},
  9.         {".xml", "text/xml"}
  10.     };
  11.     auto iter = suffix_to_desc.find(suffix);
  12.     if(iter != suffix_to_desc.end()){
  13.         return iter->second;
  14.     }
  15.     return "text/html"; //所给后缀未找到则默认该资源为html文件
  16. }
复制代码
  构建相应报头(请求处置惩罚出现错误)
    对于请求处置惩罚过程中出现错误的HTTP请求,服务器将会为其返回对应的错误页面,因此返回的资源类型就是text/html,而返回资源的巨细可以通过获取错误页面对应的文件属性信息来得知。此外,为了后续发送相应时可以直接调用sendfile举行发送,这里需要将错误页面对应的文件打开,并将对应的文件描述符保存在HTTP相应类的fd当中。
代码如下:
  1. //服务端EndPoint
  2. class EndPoint{
  3.     private:
  4.         int _sock;                   //通信的套接字
  5.         HttpRequest _http_request;   //HTTP请求
  6.         HttpResponse _http_response; //HTTP响应
  7.     private:
  8.         void HandlerError(std::string page)
  9.         {
  10.             _http_request._cgi = false; //需要返回对应的错误页面(非CGI返回)
  11.             //打开对应的错误页面文件,以供后续发送
  12.             _http_response._fd = open(page.c_str(), O_RDONLY);
  13.             if(_http_response._fd > 0){ //打开文件成功
  14.                 //构建响应报头
  15.                 struct stat st;
  16.                 stat(page.c_str(), &st); //获取错误页面文件的属性信息
  17.                 std::string content_type = "Content-Type: text/html";
  18.                 content_type += LINE_END;
  19.                 _http_response._response_header.push_back(content_type);
  20.                 std::string content_length = "Content-Length: ";
  21.                 content_length += std::to_string(st.st_size);
  22.                 content_length += LINE_END;
  23.                 _http_response._response_header.push_back(content_length);
  24.                 _http_response._size = st.st_size; //重新设置响应文件的大小
  25.             }
  26.         }
  27. };
复制代码
特殊留意: 对于处置惩罚请求时出错的HTTP请求,需要将其HTTP请求类中的cgi重新设置为false,由于后续发送HTTP相应时,需要根据HTTP请求类中的cgi来举行相应正文的发送,当请求处置惩罚出错后要返回给客户端的本质就是一个错误页面文件,相称于是以非CGI方式举行处置惩罚的。
发送HTTP相应

   发送HTTP相应
  发送HTTP相应的步骤如下:


  • 调用send函数,依次发送状态行、相应报头和空行。
  • 发送相应正文时需要判断本次请求的处置惩罚方式,如果本次请求是以CGI方式成功处置惩罚的,那么待发送的相应正文是保存在HTTP相应类的response_body中的,此时调用send函数举行发送即可。
  • 如果本次请求是以非CGI方式处置惩罚或在处置惩罚过程中出错的,那么待发送的资源文件或错误页面文件对应的文件描述符是保存在HTTP相应类的fd中的,此时调用sendfile举行发送即可,发送后关闭对应的文件描述符。
代码如下:
  1. //服务端EndPoint
  2. class EndPoint{
  3.     private:
  4.         int _sock;                   //通信的套接字
  5.         HttpRequest _http_request;   //HTTP请求
  6.         HttpResponse _http_response; //HTTP响应
  7.     public:
  8.         //发送响应
  9.         void SendHttpResponse()
  10.         {
  11.             //发送状态行
  12.             send(_sock, _http_response._status_line.c_str(), _http_response._status_line.size(), 0);
  13.             //发送响应报头
  14.             for(auto& iter : _http_response._response_header){
  15.                 send(_sock, iter.c_str(), iter.size(), 0);
  16.             }
  17.             //发送空行
  18.             send(_sock, _http_response._blank.c_str(), _http_response._blank.size(), 0);
  19.             //发送响应正文
  20.             if(_http_request._cgi){
  21.                 auto& response_body = _http_response._response_body;
  22.                 const char* start = response_body.c_str();
  23.                 size_t size = 0;
  24.                 size_t total = 0;
  25.                 while(total < response_body.size()&&(size = send(_sock, start + total, response_body.size() - total, 0)) > 0){
  26.                     total += size;
  27.                 }
  28.             }
  29.             else{
  30.                 sendfile(_sock, _http_response._fd, nullptr, _http_response._size);
  31.                 //关闭请求的资源文件
  32.                 close(_http_response._fd);
  33.             }
  34.         }
  35. };
复制代码
差错处置惩罚

  至此服务器逻辑实在已经已经走通了,但你会发现服务器在处置惩罚请求的过程中有时会莫名其妙的崩溃,根本原因就是当前服务器的错误处置惩罚还没有完全处置惩罚完毕。
逻辑错误

   逻辑错误
    逻辑错误重要是服务器在处置惩罚请求的过程中出现的一些错误,比如请求方法不正确、请求资源不存在或服务器处置惩罚请求时出错等等。逻辑错误实在我们已经处置惩罚过了,当出现这类错误时服务器会将对应的错误页面返回给客户端。
读取错误

   读取错误
    逻辑错误是在服务器处置惩罚请求时可能出现的错误,而在服务器处置惩罚请求之前起首要做的是读取请求,在读取请求的过程中出现的错误就叫做读取错误,比如调用recv读取请求时出错或读取请求时对方毗连关闭等。
  出现读取错误时,意味着服务器都没有成功读取完客户端发来的HTTP请求,因此服务器也没有必要举行后续的处置惩罚请求、构建相应以及发送相应的相干操作了。
  可以在EndPoint类中新增一个bool类型的stop成员,表示是否停止本次处置惩罚,stop的值默认设置为false,当读取请求出错时就直接设置stop为true并不再举行后续的读取操作,因此读取HTTP请求的代码需要稍作修改。
代码如下:
  1. //服务端EndPoint
  2. class EndPoint{
  3.     private:
  4.         int _sock;                   //通信的套接字
  5.         HttpRequest _http_request;   //HTTP请求
  6.         HttpResponse _http_response; //HTTP响应
  7.         bool _stop;                  //是否停止本次处理
  8.     private:
  9.         //读取请求行
  10.         bool RecvHttpRequestLine()
  11.         {
  12.             auto& line = _http_request._request_line;
  13.             if(Util::ReadLine(_sock, line) > 0){
  14.                 line.resize(line.size() - 1); //去掉读取上来的\n
  15.             }
  16.             else{ //读取出错,则停止本次处理
  17.                 _stop = true;
  18.             }
  19.             return _stop;
  20.         }
  21.         //读取请求报头和空行
  22.         bool RecvHttpRequestHeader()
  23.         {
  24.             std::string line;
  25.             while(true){
  26.                 line.clear(); //每次读取之前清空line
  27.                 if(Util::ReadLine(_sock, line) <= 0){ //读取出错,则停止本次处理
  28.                     _stop = true;
  29.                     break;
  30.                 }
  31.                 if(line == "\n"){ //读取到了空行
  32.                     _http_request._blank = line;
  33.                     break;
  34.                 }
  35.                 //读取到一行请求报头
  36.                 line.resize(line.size() - 1); //去掉读取上来的\n
  37.                 _http_request._request_header.push_back(line);
  38.             }
  39.             return _stop;
  40.         }
  41.         //读取请求正文
  42.         bool RecvHttpRequestBody()
  43.         {
  44.             if(IsNeedRecvHttpRequestBody()){ //先判断是否需要读取正文
  45.                 int content_length = _http_request._content_length;
  46.                 auto& body = _http_request._request_body;
  47.                 //读取请求正文
  48.                 char ch = 0;
  49.                 while(content_length){
  50.                     ssize_t size = recv(_sock, &ch, 1, 0);
  51.                     if(size > 0){
  52.                         body.push_back(ch);
  53.                         content_length--;
  54.                     }
  55.                     else{ //读取出错或对端关闭,则停止本次处理
  56.                         _stop = true;
  57.                         break;
  58.                     }
  59.                 }
  60.             }
  61.             return _stop;
  62.         }
  63.     public:
  64.         EndPoint(int sock)
  65.             :_sock(sock)
  66.             ,_stop(false)
  67.         {}
  68.         //本次处理是否停止
  69.         bool IsStop()
  70.         {
  71.             return _stop;
  72.         }
  73.         //读取请求
  74.         void RecvHttpRequest()
  75.         {
  76.             if(!RecvHttpRequestLine()&&!RecvHttpRequestHeader()){ //短路求值
  77.                 ParseHttpRequestLine();
  78.                 ParseHttpRequestHeader();
  79.                 RecvHttpRequestBody();
  80.             }
  81.         }
  82. };
复制代码
分析一下:


  • 可以将读取请求行、读取请求报头和空行、读取请求正文对应函数的返回值改为bool类型,当读取请求行成功后再读取请求报头和空行,而当读取请求报头和空行成功后才需要举行后续的剖析请求行、剖析请求报头以及读取请求正文操作,这里利用到了逻辑运算符的短路求值计谋。
  • EndPoint类当中提供了IsStop函数,用于让外部处置惩罚线程得知是否应该停止本次处置惩罚。
  此时服务器创建的新线程在读取请求后,就需要判断是否应该停止本次处置惩罚,如果需要则不再举行处置惩罚请求、构建相应以及发送相应操作,而直接关闭于客户端创建的套接字即可。
代码如下:
  1. class CallBack{
  2.     public:
  3.         static void* HandlerRequest(void* arg)
  4.         {
  5.             LOG(INFO, "handler request begin");
  6.             int sock = *(int*)arg;
  7.             EndPoint* ep = new EndPoint(sock);
  8.             ep->RecvHttpRequest(); //读取请求
  9.             if(!ep->IsStop()){
  10.                 LOG(INFO, "Recv No Error, Begin Handler Request");
  11.                 ep->HandlerHttpRequest(); //处理请求
  12.                 ep->BuildHttpResponse();  //构建响应
  13.                 ep->SendHttpResponse();   //发送响应
  14.             }
  15.             else{
  16.                 LOG(WARNING, "Recv Error, Stop Handler Request");
  17.             }
  18.             close(sock); //关闭与该客户端建立的套接字
  19.             delete ep;
  20.             LOG(INFO, "handler request end");
  21.             return nullptr;
  22.         }
  23. };
复制代码
写入错误

   写入错误
    除了读取请求时可能出现读取错误,处置惩罚请求时可能出现逻辑错误,在相应构建完毕发送相应时同样可能会出现写入错误,比如调用send发送相应时出错或发送相应时对方毗连关闭等。
  出现写入错误时,服务器也没有必要继承举行发送了,这时需要直接设置stop为true并不再举行后续的发送操作,因此发送HTTP相应的代码也需要举行修改。
代码如下:
  1. //服务端EndPoint
  2. class EndPoint{
  3.     private:
  4.         int _sock;                   //通信的套接字
  5.         HttpRequest _http_request;   //HTTP请求
  6.         HttpResponse _http_response; //HTTP响应
  7.     public:
  8.         //发送响应
  9.         bool SendHttpResponse()
  10.         {
  11.             //发送状态行
  12.             if(send(_sock, _http_response._status_line.c_str(), _http_response._status_line.size(), 0) <= 0){
  13.                 _stop = true; //发送失败,设置_stop
  14.             }
  15.             //发送响应报头
  16.             if(!_stop){
  17.                 for(auto& iter : _http_response._response_header){
  18.                     if(send(_sock, iter.c_str(), iter.size(), 0) <= 0){
  19.                         _stop = true; //发送失败,设置_stop
  20.                         break;
  21.                     }
  22.                 }
  23.             }
  24.             //发送空行
  25.             if(!_stop){
  26.                 if(send(_sock, _http_response._blank.c_str(), _http_response._blank.size(), 0) <= 0){
  27.                     _stop = true; //发送失败,设置_stop
  28.                 }
  29.             }
  30.             //发送响应正文
  31.             if(_http_request._cgi){
  32.                 if(!_stop){
  33.                     auto& response_body = _http_response._response_body;
  34.                     const char* start = response_body.c_str();
  35.                     size_t size = 0;
  36.                     size_t total = 0;
  37.                     while(total < response_body.size()&&(size = send(_sock, start + total, response_body.size() - total, 0)) > 0){
  38.                         total += size;
  39.                     }
  40.                 }
  41.             }
  42.             else{
  43.                 if(!_stop){
  44.                     if(sendfile(_sock, _http_response._fd, nullptr, _http_response._size) <= 0){
  45.                         _stop = true; //发送失败,设置_stop
  46.                     }
  47.                 }
  48.                 //关闭请求的资源文件
  49.                 close(_http_response._fd);
  50.             }
  51.             return _stop;
  52.         }
  53. };
复制代码
  此外,当服务器发送相应出错时会收到SIGPIPE信号,而该信号的默认处置惩罚动作是终止当前历程,为了防止服务器由于写入出错而被终止,需要在初始化HTTP服务器时调用signal函数忽略SIGPIPE信号。
代码如下:
  1. //HTTP服务器
  2. class HttpServer{
  3.     private:
  4.         int _port; //端口号
  5.     public:
  6.         //初始化服务器
  7.         void InitServer()
  8.         {
  9.             signal(SIGPIPE, SIG_IGN); //忽略SIGPIPE信号,防止写入时崩溃
  10.         }
  11. };
复制代码
接入线程池

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


  • 每当获取到新毗连时,服务器主线程都会重新为该客户端创建为其提供服务的新线程,而当服务竣事后又会将该新线程销毁,这样做不仅贫苦,而且服从低下。
  • 如果同时有大量的客户端毗连请求,此时服务器就要为每一个客户端创建对应的服务线程,而计算机中的线程越多,CPU压力就越大,由于CPU要不停在这些线程之间往返切换。此外,一旦线程过多,每一个线程再次被调度的周期就变长了,而线程是为客户端提供服务的,线程被调度的周期变长,客户端也就迟迟得不到应答。
这时可以在服务器端引入线程池:


  • 在服务器端预先创建一批线程和一个任务队列,每当获取到一个新毗连时就将其封装成一个任务对象放到任务队列当中。
  • 线程池中的若干线程就不停从任务队列中获取任务举行处置惩罚,如果任务队列当中没有任务则线程进入休眠状态,当有新任务时再叫醒线程举行任务处置惩罚。
表示图如下:

计划任务

   计划任务
    当服务器获取到一个新毗连后,需要将其封装成一个任务对象放到任务队列当中。任务类中起首需要有一个套接字,也就是与客户端举行通信的套接字,此外还需要有一个回调函数,当线程池中的线程获取到任务后就可以调用这个回调函数举行任务处置惩罚。
代码如下:
  1. //任务类
  2. class Task{
  3.     private:
  4.         int _sock;         //通信的套接字
  5.         CallBack _handler; //回调函数
  6.     public:
  7.         Task()
  8.         {}
  9.         Task(int sock)
  10.             :_sock(sock)
  11.         {}
  12.         //处理任务
  13.         void ProcessOn()
  14.         {
  15.             _handler(_sock); //调用回调
  16.         }
  17.         ~Task()
  18.         {}
  19. };
复制代码
分析一下: 任务类需要提供一个无参的构造函数,由于后续从任务队列中获取任务时,需要先以无参的方式定义一个任务对象,然后再以输出型参数的方式来获取任务。
   编写任务回调
    任务类中处置惩罚任务时需要调用的回调函数,实际就是之前创建新线程时传入的执行例程CallBack::HandlerRequest,我们可以将CallBack类的()运算符重载为调用HandlerRequest函数,这时CallBack对象就变成了一个仿函数对象,这个仿函数对象被调用时实际就是在调用HandlerRequest函数。
代码如下:
  1. class CallBack{
  2.     public:
  3.         CallBack()
  4.         {}
  5.         void operator()(int sock)
  6.         {
  7.             HandlerRequest(sock);
  8.         }
  9.         void HandlerRequest(int sock)
  10.         {
  11.             LOG(INFO, "handler request begin");
  12.             EndPoint* ep = new EndPoint(sock);
  13.             ep->RecvHttpRequest(); //读取请求
  14.             if(!ep->IsStop()){
  15.                 LOG(INFO, "Recv No Error, Begin Handler Request");
  16.                 ep->HandlerHttpRequest(); //处理请求
  17.                 ep->BuildHttpResponse();  //构建响应
  18.                 ep->SendHttpResponse();   //发送响应
  19.                 if(ep->IsStop()){
  20.                     LOG(WARNING, "Send Error, Stop Send Response");
  21.                 }
  22.             }
  23.             else{
  24.                 LOG(WARNING, "Recv Error, Stop Handler Request");
  25.             }
  26.             close(sock); //关闭与该客户端建立的套接字
  27.             delete ep;
  28.             LOG(INFO, "handler request end");
  29.         }
  30.         ~CallBack()
  31.         {}
  32. };
复制代码
编写线程池

   计划线程池结构
  可以将线程池计划成单例模式:

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


  • 任务队列:用于暂时存储未被处置惩罚的任务对象。
  • num:表示线程池中线程的个数。
  • 互斥锁:用于包管任务队列在多线程环境下的线程安全。
  • 条件变量:当任务队列中没有任务时,让线程在该条件变量下举行等等,当任务队列中新增任务时,叫醒在该条件变量下举行等待的线程。
  • 指向单例对象的指针:用于指向唯一的单例线程池对象。
ThreadPool类中的成员函数重要包括:


  • 构造函数:完成互斥锁和条件变量的初始化操作。
  • 析构函数:完成互斥锁和条件变量的开释操作。
  • InitThreadPool:初始化线程池时调用,完成线程池中若干线程的创建。
  • PushTask:生产任务时调用,将任务对象放入任务队列,并叫醒在条件变量下等待的一个线程举行处置惩罚。
  • PopTask:消费任务时调用,从任务队列中获取一个任务对象。
  • ThreadRoutine:线程池中每个线程的执行例程,完成线程分离后不停检测任务队列中是否有任务,如果有则调用PopTask获取任务举行处置惩罚,如果没有则举行休眠直到被叫醒。
  • GetInstance:获取单例线程池对象时调用,如果单例对象未创建则创建并初始化后返回,如果单例对象已经创建则直接返回单例对象。
代码如下:
  1. #define NUM 6
  2. //线程池
  3. class ThreadPool{
  4.     private:
  5.         std::queue<Task> _task_queue; //任务队列
  6.         int _num;                     //线程池中线程的个数
  7.         pthread_mutex_t _mutex;       //互斥锁
  8.         pthread_cond_t _cond;         //条件变量
  9.         static ThreadPool* _inst;     //指向单例对象的static指针
  10.     private:
  11.         //构造函数私有
  12.         ThreadPool(int num = NUM)
  13.             :_num(num)
  14.         {
  15.             //初始化互斥锁和条件变量
  16.             pthread_mutex_init(&_mutex, nullptr);
  17.             pthread_cond_init(&_cond, nullptr);
  18.         }
  19.         //将拷贝构造函数和拷贝赋值函数私有或删除(防拷贝)
  20.         ThreadPool(const ThreadPool&)=delete;
  21.         ThreadPool* operator=(const ThreadPool&)=delete;
  22.         //判断任务队列是否为空
  23.         bool IsEmpty()
  24.         {
  25.             return _task_queue.empty();
  26.         }
  27.         //任务队列加锁
  28.         void LockQueue()
  29.         {
  30.             pthread_mutex_lock(&_mutex);
  31.         }
  32.         
  33.         //任务队列解锁
  34.         void UnLockQueue()
  35.         {
  36.             pthread_mutex_unlock(&_mutex);
  37.         }
  38.         //让线程在条件变量下进行等待
  39.         void ThreadWait()
  40.         {
  41.             pthread_cond_wait(&_cond, &_mutex);
  42.         }
  43.         
  44.         //唤醒在条件变量下等待的一个线程
  45.         void ThreadWakeUp()
  46.         {
  47.             pthread_cond_signal(&_cond);
  48.         }
  49.     public:
  50.         //获取单例对象
  51.         static ThreadPool* GetInstance()
  52.         {
  53.             static pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER; //定义静态的互斥锁
  54.             //双检查加锁
  55.             if(_inst == nullptr){
  56.                 pthread_mutex_lock(&mtx); //加锁
  57.                 if(_inst == nullptr){
  58.                     //创建单例线程池对象并初始化
  59.                     _inst = new ThreadPool();
  60.                     _inst->InitThreadPool();
  61.                 }
  62.                 pthread_mutex_unlock(&mtx); //解锁
  63.             }
  64.             return _inst; //返回单例对象
  65.         }
  66.         //线程的执行例程
  67.         static void* ThreadRoutine(void* arg)
  68.         {
  69.             pthread_detach(pthread_self()); //线程分离
  70.             ThreadPool* tp = (ThreadPool*)arg;
  71.             while(true){
  72.                 tp->LockQueue(); //加锁
  73.                 while(tp->IsEmpty()){
  74.                     //任务队列为空,线程进行wait
  75.                     tp->ThreadWait();
  76.                 }
  77.                 Task task;
  78.                 tp->PopTask(task); //获取任务
  79.                 tp->UnLockQueue(); //解锁
  80.                 task.ProcessOn(); //处理任务
  81.             }
  82.         }
  83.         
  84.         //初始化线程池
  85.         bool InitThreadPool()
  86.         {
  87.             //创建线程池中的若干线程
  88.             pthread_t tid;
  89.             for(int i = 0;i < _num;i++){
  90.                 if(pthread_create(&tid, nullptr, ThreadRoutine, this) != 0){
  91.                     LOG(FATAL, "create thread pool error!");
  92.                     return false;
  93.                 }
  94.             }
  95.             LOG(INFO, "create thread pool success");
  96.             return true;
  97.         }
  98.         
  99.         //将任务放入任务队列
  100.         void PushTask(const Task& task)
  101.         {
  102.             LockQueue();    //加锁
  103.             _task_queue.push(task); //将任务推入任务队列
  104.             UnLockQueue();  //解锁
  105.             ThreadWakeUp(); //唤醒一个线程进行任务处理
  106.         }
  107.         //从任务队列中拿任务
  108.         void PopTask(Task& task)
  109.         {
  110.             //获取任务
  111.             task = _task_queue.front();
  112.             _task_queue.pop();
  113.         }
  114.         ~ThreadPool()
  115.         {
  116.             //释放互斥锁和条件变量
  117.             pthread_mutex_destroy(&_mutex);
  118.             pthread_cond_destroy(&_cond);
  119.         }
  120. };
  121. //单例对象指针初始化为nullptr
  122. ThreadPool* ThreadPool::_inst = nullptr;
复制代码
分析一下:


  • 由于线程的执行例程的参数只能有一个void*类型的参数,因此线程的执行例程必须定义成静态成员函数,而线程执行例程中又需要访问任务队列,因此需要将this指针作为参数传递给线程的执行例程,这样线程才气够通过this指针访问任务队列。
  • 在向任务队列中放任务以及从任务队列中获取任务时,都需要通过加锁的方式来包管线程安全,而线程在调用PopTask之前已经举行过加锁了,因此在PopTask函数中不必再加锁。
  • 当任务队列中有任务时会叫醒线程举行任务处置惩罚,为了防止被伪叫醒的线程调用PopTask时无法获取到任务,因此需要以while的方式判断任务队列是否为空。
  引入线程池后服务器要做的就是,每当获取到一个新毗连时就构建一个任务,然后调用PushTask将其放入任务队列即可。
代码如下:
  1. //HTTP服务器
  2. class HttpServer{
  3.     private:
  4.         int _port; //端口号
  5.     public:
  6.         //启动服务器
  7.         void Loop()
  8.         {
  9.             LOG(INFO, "loop begin");
  10.             TcpServer* tsvr = TcpServer::GetInstance(_port); //获取TCP服务器单例对象
  11.             int listen_sock = tsvr->Sock(); //获取监听套接字
  12.             while(true){
  13.                 struct sockaddr_in peer;
  14.                 memset(&peer, 0, sizeof(peer));
  15.                 socklen_t len = sizeof(peer);
  16.                 int sock = accept(listen_sock, (struct sockaddr*)&peer, &len); //获取新连接
  17.                 if(sock < 0){
  18.                     continue; //获取失败,继续获取
  19.                 }
  20.                 //打印客户端相关信息
  21.                 std::string client_ip = inet_ntoa(peer.sin_addr);
  22.                 int client_port = ntohs(peer.sin_port);
  23.                 LOG(INFO, "get a new link: ["+client_ip+":"+std::to_string(client_port)+"]");
  24.                
  25.                 //构建任务并放入任务队列中
  26.                 Task task(sock);
  27.                 ThreadPool::GetInstance()->PushTask(task);
  28.             }
  29.         }
  30. };
复制代码
项目测试

   服务器结构
    至此HTTP服务器后端逻辑已经全部编写完毕,此时我们要做的就是将对外提供的资源文件放在一个名为wwwroot的目录下,然后将生成的HTTP服务器可执行程序与wwwroot放在同级目录下。比如:

由于当前HTTP服务器没有任何业务逻辑,因此向外提供的资源文件只有三个错误页面文件,这些错误页面文件中的内容大致如下:
  1. <!DOCTYPE html>
  2. <html>
  3.     <head>
  4.         <meta charset="UTF-8">
  5.         <title>404 Not Found</title>
  6.     </head>
  7.     <body>
  8.         <h1>404 Not Found</h1>
  9.         <p>对不起,你所要访问的资源不存在!</p>
  10.     </body>
  11. </html>
复制代码
首页请求测试

   服务器首页编写
    服务器的web根目录下的资源文件重要有两种,一种就是用于处置惩罚客户端上传上来的数据的CGI程序,另一种就是供客户端请求的各种网页文件了,而网页的制作实际是前端工程师要做的,但现在我们要对服务器举行测试,至少需要编写一个首页,首页文件需要放在web根目录下,取名为index.html。
以演示为主,首页的代码如下:
  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4.     <meta charset="UTF-8">
  5.     <meta http-equiv="X-UA-Compatible" content="IE=edge">
  6.     <meta name="viewport" content="width=device-width, initial-scale=1.0">
  7.     <title>Document</title>
  8.     <style>
  9.         .box{
  10.             width: 400px;
  11.             height: 400px;
  12.             margin: 40px auto;
  13.             background-color: #2b92d4;
  14.             border-radius: 50%; /*圆角效果*/
  15.             box-shadow: 0 1px 2px rgba(0, 0, 0, .3); /*阴影效果*/
  16.             animation: breathe 2700ms ease-in-out infinite alternate;
  17.         }
  18.         @keyframes breathe {
  19.             0%{
  20.                 opacity: 0.2; /*透明度*/
  21.                 box-shadow: 0 1px 2px rgba(255, 255, 255, 0.1);
  22.             }
  23.             50%{
  24.                 opacity: 0.5; /*透明度*/
  25.                 box-shadow: 0 1px 2px rgba(18, 190, 84, 0.76);
  26.             }
  27.             100%{
  28.                 opacity: 1; /*透明度*/
  29.                 box-shadow: 0 1px 30px rgba(59, 255, 255, 1);
  30.             }
  31.         }
  32.     </style>
  33. </head>
  34. <body>
  35.     <div class="box"></div>
  36. </body>
  37. </html>
复制代码
  首页请求测试
    指定端标语运行服务器后可以看到一系列日记信息被打印出来,包括套接字创建成功、绑定成功、监听成功,这时底层用于通信的TCP服务器已经初始化成功了。

  此时在浏览器上指定IP和端口访问我们的HTTP服务器,由于我们没有指定要访问服务器web根目录下的谁人资源,此时服务器就会默认将web根目录下的index.html文件举行返回,浏览器收到index.html文件后颠末革新渲染就显示出了对应的首页页面。

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

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

错误请求测试

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

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

GET方法上传数据测试

   编写CGI程序
    如果用户请求服务器时上传了数据,那么服务器就需要将该数据后交给对应的CGI程序举行处置惩罚,因此在测试GET方法上传数据之前,我们需要先编写一个简单的CGI程序。
起首,CGI程序启动后需要先获取父历程传递过来的数据:

  • 先通过getenv函数获取环境变量中的请求方法。
  • 如果请求方法为GET方法,则继承通过getenv函数获取父历程传递过来的数据。
  • 如果请求方法为POST方法,则先通过getenv函数获取父历程传递过来的数据的长度,然后再从0号文件描述符中读取指定长度的数据即可。
代码如下:
  1. //获取参数
  2. bool GetQueryString(std::string& query_string)
  3. {
  4.     bool result = false;
  5.     std::string method = getenv("METHOD"); //获取请求方法
  6.     if(method == "GET"){ //GET方法通过环境变量获取参数
  7.         query_string = getenv("QUERY_STRING");
  8.         result = true;
  9.     }
  10.     else if(method == "POST"){ //POST方法通过管道获取参数
  11.         int content_length = atoi(getenv("CONTENT_LENGTH"));
  12.         //从管道中读取content_length个参数
  13.         char ch = 0;
  14.         while(content_length){
  15.             read(0, &ch, 1);
  16.             query_string += ch;
  17.             content_length--;
  18.         }
  19.         result = true;
  20.     }
  21.     else{
  22.         //Do Nothing
  23.         result = false;
  24.     }
  25.     return result;
  26. }
复制代码
  CGI程序在获取到父历程传递过来的数据后,就可以根据详细的业务场景举行数据处置惩罚了,比如用户上传的如果是一个关键字则需要CGI程序做搜刮处置惩罚。我们这里以演示为目的,以为用户上传的是形如a=10&b=20的两个参数,需要CGI程序举行加减乘除运算。
  因此我们的CGI程序要做的就是,先以&为分隔符切割数据将两个操作数分开,再以=为分隔符切割数据分别获取到两个操作数的值,末了对两个操作数举行加减乘除运算,并将计算结果打印到尺度输出即可(尺度输出已经被重定向到了管道)。
代码如下:
  1. //切割字符串
  2. bool CutString(std::string& in, const std::string& sep, std::string& out1, std::string& out2)
  3. {
  4.     size_t pos = in.find(sep);
  5.     if(pos != std::string::npos){
  6.         out1 = in.substr(0, pos);
  7.         out2 = in.substr(pos + sep.size());
  8.         return true;
  9.     }
  10.     return false;
  11. }
  12. int main()
  13. {
  14.     std::string query_string;
  15.     GetQueryString(query_string); //获取参数
  16.     //以&为分隔符将两个操作数分开
  17.     std::string str1;
  18.     std::string str2;
  19.     CutString(query_string, "&", str1, str2);
  20.     //以=为分隔符分别获取两个操作数的值
  21.     std::string name1;
  22.     std::string value1;
  23.     CutString(str1, "=", name1, value1);
  24.     std::string name2;
  25.     std::string value2;
  26.     CutString(str2, "=", name2, value2);
  27.     //处理数据
  28.     int x = atoi(value1.c_str());
  29.     int y = atoi(value2.c_str());
  30.     std::cout<<"<html>";
  31.     std::cout<<"<head><meta charset="UTF-8"></head>";
  32.     std::cout<<"<body>";
  33.     std::cout<<"<h3>"<<x<<" + "<<y<<" = "<<x+y<<"</h3>";
  34.     std::cout<<"<h3>"<<x<<" - "<<y<<" = "<<x-y<<"</h3>";
  35.     std::cout<<"<h3>"<<x<<" * "<<y<<" = "<<x*y<<"</h3>";
  36.     std::cout<<"<h3>"<<x<<" / "<<y<<" = "<<x/y<<"</h3>"; //除0后cgi程序崩溃,属于异常退出
  37.     std::cout<<"</body>";
  38.     std::cout<<"</html>";
  39.     return 0;
  40. }
复制代码
分析一下:


  • CGI程序输出的结果最终会交给浏览器,因此CGI程序输出的最好是一个HTML文件,这样浏览器收到后就可以其渲染到页面上,让用户看起来更雅观。
  • 可以看到,使用C/C++以HTML的格式举行输出是很费劲的,因此这部分操作一样寻常是由Python等语言来完成的,而在此之前对数据举行业务处置惩罚的动作一样寻常才用C/C++等语言来完成。
  • 在编写CGI程序时如果要举行调试,debug内容应该通过尺度错误流举行输出,由于子历程在被替换成CGI程序之前,已经将尺度输出重定向到管道了。
   URL上传数据测试
    CGI程序编写编写完毕并生成可执行程序后,将这个可执行程序放到web根目录下,这时在请求服务器时就可以指定请求这个CGI程序,并通过URL上传参数让其举行处置惩罚,最终我们就能得到计算结果。

  此外,如果请求CGI程序时指定的第二个操作数为0,那么CGI程序在举行除法运算时就会崩溃,这时父历程等待子历程后就会发现子历程是异常退出的,进而设置状态码为INTERNAL_SERVER_ERROR,最终服务器就会构建对应的错误页面返回给浏览器。

   表单上传数据测试
    当然,让用户通过更改URL的方式来向服务器上传参数是不实际的,服务器一样寻常会让用户通过表单来上传参数。
  HTML中的表单用于搜集用户的输入,我们可以通过设置表单的method属性来指定表单提交的方法,通过设置表单的action属性来指定表单需要提交给服务器上的哪一个CGI程序。
比如现在将服务器的首页改成以下HTML代码,指定将表单中的数据以GET方法提交给web根目录下的test_cgi程序:
  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4.     <meta charset="UTF-8">
  5.     <meta http-equiv="X-UA-Compatible" content="IE=edge">
  6.     <meta name="viewport" content="width=device-width, initial-scale=1.0">
  7.     <title>简易的在线计算器</title>
  8. </head>
  9. <body>
  10.     <form action="/test_cgi" method="get" align="center">
  11.         操作数1:<br>
  12.         <input type="text" name="x"><br>
  13.         操作数2:<br>
  14.         <input type="text" name="y"><br><br>
  15.         <input type="submit" value="计算">
  16.     </form>
  17. </body>
  18. </html>
复制代码
  此时我们直接访问服务器看到的就是一个表单,向表单中输入两个操作数并点击“计算”后,表单中的数据就会以GET方法提交给web根目录下的test_cgi程序,此时CGI程序举行数据计算后同样将结果返回给了浏览器。

  同时在提交表单的一瞬间可以看到,通过表单上传的数据也回显到了浏览器上方的URL中,并且请求的资源也变成了web根目录下的test_cgi。实际就是我们在点击“计算”后,浏览器检测到表单method为“get”后,将把表单中数据添加到了URL中,并将请求资源路径替换成了表单action指定的路径,然后再次向服务器发起HTTP请求。
   理解百度搜刮
    当我们在百度的搜刮框输入关键字并回车后,可以看到上方的URL发生了变革,URL中的请求资源路径为/s,并且URL后面携带了很多参数。

  实际这里的/s就可以理解成是百度web根目录下的一个CGI程序,而URL中携带的各种参数就是交给这个CGI程序做搜刮处置惩罚的,可以看到携带的参数中有一个名为wd的参数,这个参数正是用户的搜刮关键字。
POST方法上传数据测试

   表单上传数据测试
    测试表单通过POST方法上传数据时,只需要将表单中的method属性改为“post”即可,此时点击“计算”提交表单时,浏览器检测到表单的提交方法为POST后,就会将表单中的数据添加到请求正文中,并将请求资源路径替换成表单action指定的路径,然后再次向服务器发起HTTP请求。

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

项目扩展

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


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


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

Github:https://github.com/chenlong-cxy/Implement-Http-Server/tree/main/HTTP_End

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

大连密封材料

金牌会员
这个人很懒什么都没写!

标签云

快速回复 返回顶部 返回列表