自主实现Web服务器

Web服务器


项目背景:
项目源码链接:Web服务器
http协议被广泛使用,从移动端,pc端浏览器,http协议无疑是打开互联网应用窗口的重要协议,http在网络应用层中的地位不可能撼动,是能准确区分前后台的重要协议。
项目描述:
采用C/S模型,编写支持中小型应用的http,理解常见的互联网应用行为。
对http协议的理论学习,从零开始完成web服务器开发,坐拥下三层协议,从技术到应用。
涉及的应用技术:
网络编程(TCP/IP协议, socket流式套接字,http协议)
多线程技术
cgi技术
线程池
进程间通信(管道),进程程序替换execl

一、了解HTTP

1. HTTP的分层概览

在这里插入图片描述
利用TCP/IP协议族进行网络通信时,会通过分层顺序与对方进行通信,发送端从应用层往下走,接收端则会从链路层往上走。

例如HTTP,首先作为发送端的客户端在应用层(HTTP)协议发出一个想看某个Web页面的HTTP请求。

为了方便传输,在传输层(TCP协议)把从应用层处收到的数据(HTTP请求报文)进行分割,并在各个报文上面打上标记序号及端口号后转发给网络层。

在网络层(IP协议),增加作为通信目的的MAC地址后转发给链路层。

接收端的服务器在链路层接收到数据,按需往上层发送,一直到应用层。当传输到应用层,才能算真正接收到由客户端发送过来的HTTP请求。

在这里插入图片描述

2.其他协议与HTTP协议之间的合作

在这里插入图片描述

3.HTTP协议的特点

1.简单快速,HTTP服务器的程序规模小,因而通信速度很快。
2.灵活,HTTP允许传输任意类型的数据对象,正在传输的类型由Content-Type字段加以标记。
3.无连接,每次连接只处理一次请求,服务器处理完客户的请求,并收到客户的应答后,即断开
连接。采用该方式可节省传输时间。
4.无状态,HTTP协议自身不具备保存之前发送过来的请求或响应的功能

在这里插入图片描述
http协议每当有新的请求产生,就会有对应的响应产生。协议本身并不会保留你之前的一切请求或者响应,这是为了更快的处理大量的事务,确保协议的可伸缩性。
http/1.1虽然也是无状态的协议,但是为了保持状态的功能,引入了cookie技术。

在这里插入图片描述

4.HTTP报文结构

HTTP报文的整体结构

在这里插入图片描述

浅谈URI URL(重点说明) URN

URI(uniform resource identifier)统一资源标识符,用来唯一的标识一个资源
URL(uniform resource locator)统一资源定位符,它是一种具体的URI,即URL可以用来标识一个资源,而且还指明了如何locate到这个资源。
URN(uniform resource name)统一资源命名,是通过名字来标识资源

让我们来了解一下绝对URL的格式

1. 浏览器中的URL格式:http://host【":"port】【abs_path】
HTTP(超文本传输协议)是基于TCP的连接方式进行网络连接
HTTP/1.1版本中给出一种持续连接的机制
绝大多数的Web开发,都是构建在HTTP之上的Web应用

HTTP URL (URL是一种特殊类型的URI,包含了如何获取指定资源)的格式如下:

http表示要通过HTTP协议来定位网络资源
host表示合法的Internet主机域名或者IP地址,本主机IP:127.0.0.1
port指定一个端口号,为空则使用缺省端口80
abs_path指定请求资源的URI
如果URL中没有给出abs_path,那么当它作为请求URI时,必须以“/”的形式给出,通常这个工作浏览器自动帮我们完成。
如:
输入: www.baidu.com,浏览器自动转换成:http(s)://www.baidu.com/

HTTP请求与响应

在这里插入图片描述

具体http细节说明

请求
在这里插入图片描述
响应
在这里插入图片描述

下面我们使用postman软件来获取报文信息
在这里插入图片描述

HTTP请求 - 方法

在这里插入图片描述

GET:获取资源
获取被URI标识的资源,指定的资源经服务器端解析后返回响应内容。若请求的资源是文本,那就保持原样发回;若是像CGI(Common Gateway Interface, 通用网关接口)那样的程序,则返回经过执行后的输出结果。

在这里插入图片描述
POST:传输实体的主体
在这里插入图片描述
POST方法用来传输实体的主体。
POST的功能和GET的功能很相似。但POST的主要功能并不是获取响应的主体内容。而是提交参数(存在于请求报文的body正文部分),供CGI程序进行数据处理。
在这里插入图片描述

HTTP响应 - 状态码及其描述

HTTP状态码(HTTP Status Code)是用以表示服务器HTTP响应状态的3位数字代码。通过状态码,就可以知道服务器端是否正确的处理的请求,如果不正确,是因为什么原因导致的
在这里插入图片描述
状态码的类别
在这里插入图片描述

5.HTTP服务器的构建

在这里插入图片描述
首先先介绍请求报文类与响应报文类
请求报文
在这里插入图片描述
响应报文
在这里插入图片描述

工具类

首先是需要编写一个工具类 class Until,他所包含的成员方法分别是ReadLine和CutString
1.ReadLine
ReadLine成员方法的任务是从接收缓冲区中按行读取其中的内容,读取到Httprequest类所定义的对象http_request当中
按行读取,顾名思义就是 遇到 \n或者 \r\n 或者 \r的时候停止读取
在这里插入图片描述
在这里插入图片描述

可是 在区分 \r\n 和 \r 的时候,当读取到\r时,还要继续判断下一个字符是否是\n,如果是那么就将\r\n都读取上来。若只是简单的使用
recv(sock,&ch,1, 0)则会直接将缓冲区中的内容读上来,若读上来的内容是\n那还好,若是其他字符,那么麻烦就大了,就破坏了下一行的数据。因此这里需要将recv函数中的flag标志为设为MSG_PEEK,MSG_PEEK的作用呢,就是数据窥探,不会进行数据的读取。此标志使接收操作从接收队列的开头返回数据,而不从队列中删除该数据。
这样达到了完整的读取到一行的目的。
在这里插入图片描述

2.CutString
由于后续要对http_request请求报文中每一行的字符串进行解析,因此在工具类中实现一个分割字符串的成员方法
成员方法Cutstring的任务呢,是将一个字符串按照指定的分割字符串,将其分成两部分。
在这里插入图片描述

a.读取与解析请求报文

void RcvHttpRequest();
读取请求报文分为五大步骤,分别是
读取请求行、读取请求报头、分析请求行、分析请求报头、读取请求正文
在这里插入图片描述

接下来逐个介绍。

  1. 首先是读取请求行bool RecvHttpRequestLine();第一次读取,读取到的内容肯定是请求行,因此将读取上来的数据放到request_line当中。

在这里插入图片描述
2.读取请求报头 bool RecvHttpRequestHeader();
.读取请求报头时,也是按行读取,每一行都是一种属性。将读取到的内容,存放到应用层中http_request中的request_header请求报头中,读取的结束标志就是读到空行的时候。
在这里插入图片描述
3.分析请求行 void ParseHttpRequestLine();
这个函数中的细节很多
在这里插入图片描述
a.
在这里插入图片描述
由于请求行的构成是
请求方法 url 版本(version)
因此将该行的字符串以空格作为分隔符分别传入到http_request.method http_request.uri http_request.version当中

b. transform函数的任务是将字母全部转化成大写的。将转化的结果放到以第三个参数为起点的对象当中。
std::transform(method.begin(), method.end(), method.begin(), ::toupper);
在这里插入图片描述
4.分析请求报头 void ParseHttpRequestHeader();
在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

请求报头中的每一行表示一种属性,并且以<Key,Value>的形式存储。分析请求报头的每一行的时候,需要注意的地方就是
key与value之间的分隔符是 冒号加空格,将request_header中的每个数据按照冒号加空格分开读取到的内容放进head_kv容器当中
在这里插入图片描述
5.读取请求正文 bool RecvHttpRequestBody();
在这里插入图片描述
在这里插入图片描述
读取请求正文时,需要注意的是请求方法是get还是post,若是get方法那就不用读取了,因为请求方法是get的时候,会分为两种情况,一个是传参,一个是不传参,传参的情况下,参数会直接拼接到url后面,参数与参数之间以&符号隔开,例如
在这里插入图片描述

若是post方法,若request_header中的Content-Length字段的值为0,那么也不需要读取正文,反之,其上传的参数会放在请求报文的正文当中,那么就需要读取正文内容。
因此需要根据content-length来判断是否需要读取
在这里插入图片描述
在这里插入图片描述

b.构建响应报文

浏览器除了从服务器下获得资源(网页,图片,文字等),有时候还有能上传一些东西(提交表单,注册用户之类
的),看看我们目前的http只能进行获得资源,并不能够进行上传资源,所以目前http并不具有交互式。为了使网站能够实现交互式,我们需要使用CGI完成,所以,CGI的所有交互细节,都需要我们来自己完成。
构建响应报文分为如下四个步骤
构建响应行,构建响应报头,构建空行,构建响应正文
接下来一 一介绍
1.构建响应行
响应行中包含 版本 状态码 状态码描述符
因此这里的重点就是状态码的设置。那么我们应该根据什么来设置状态码呢?

(a)请求方法不对 ==> code(状态码)设置成BAD_REQUEST;//400
由于目前编写的服务器仅支持get post方法,因此首先得要对请求报文中的请求方法做判断吧,若是请求方法都不对,那么就没必要分析请求报文中后续的内容了
在这里插入图片描述

请求方法为GET时,有两种情况: 带参数 和不带参数
可以根据uri中是否存在 ? 符号来判断是否有参数。 有 ?符号则带参,反之则不带参
若带参,我们则可以将uri分成两部分,一部分是资源的路径,一部分是参数。
若没有带参,那么资源路径则就是uri
请求方法为POST时,由于POST方法的参数是在正文当中,因此这里的path就等于uri
在这里插入图片描述

好了,我们将path和参数得到了,那么接下就要判断path的合法性了,看看是否存在这个文件
可是这里的path真的就是资源路径吗?我们平时在浏览器上面输入网址的时候,
例如访问百度
在这里插入图片描述
在这里插入图片描述

这里的显示的路径为 / =》web根目录
可是实际上的web根目录的设置是程序员根据我们把所有资源放在了哪一个目录下自己设置的,没有绝对的路径,因此此处的 / 符号并不是真正的根目录,我自己编写的根目录如下图所示
在这里插入图片描述
因此我们还需要在被分割出来的path头部添加wwwroot,例如 /test_cgi 到了底层之后就会被替换成wwwroot/test_cgi,意为在wwwroot目录下面找test_cgi文件。
情况分析完了吗?当然还没有奥
经过上面的拼接wwwroot根目录之后,此时的path的意义有两种
第一种:path所指是一个目录
第二种:path所指是一个文件
两种的区别就是 看path最后一个字符是否是 “/” 若是则path所指是目录,若不是,则是文件
若是第一种情况,我们该如何处理呢?
其实,若是一个目录的时候,我们一般会默认打开该目录下的index.html文件,因此,还需在path后面拼接上 index.html
好了,path的组成终于大功告成了~ 我们是否还记得自己的初衷?没错,就是设置状态码,这里我们需要对path进行检验,检验这个文件是否存在。
若不存在,code状态码设置成 NOT_FOUND (404)

接下来我要就要隆重的将code的设置任务交付给ProcessCgi(),和ProcessNoneCgi().这两个函数来处理了,他们的任务是来处理不同的请求。

6.CGI机制

简述
CGI(Common Gateway Interface)公共网关接口,是外部扩展应用程序与 Web 服务器交互的一个标准接口。它可以使外部程序处理www上客户端送来的表单数据并对此作出反应, 这种反应可以是文件、 图片、 声音、 视频等,可以在浏览器窗体上出现的任何数据 [5] 。服务器端与客户端进行交互的常见方式多,CGI 技术就是其中之一。根据CGI标准,编写外部扩展应用程序,可以对客户端浏览器输入的数据进行处理,完成客户端与服务器的交互操作。CGI规范定义了Web服务器如何向扩展应用程序发送消息,在收到扩展应用程序的信息后又如何进行处理等内容。对于许多静态的HTML网页无法实现的功能,通过 CGI可以实现,比如表单的处理、对数据库的访问、搜索引擎、基于Web的数据库访问等等。

上段话对于CGI的简述,想必看完之后就大概有所了解了,无非就是当客户端有数据要进行处理的时候,即传参数,就需要用到CGI程序,在我们这个项目中,正好HTTP就提供了CGI机制。
因此请目光又重新回到我们的项目中来~
在这里插入图片描述

在我们这个项目中,请求方发要么是get要么是post
如果要进行数据处理,那么就是get带参,或者post带参
否则就是获取网页。

对于int ProcessNoneCgi();他处理的是get不带参的情况,即直接打开path所指文件的文件描述符,然后在发送响应正文那个时候,我们直接可以调用sendfile函数来将文件写入sock文件中,详细介绍请参考发送响应报文部分。此时如果fd打开失败,则将code状态码置为NOT_FOUND(404)
在这里插入图片描述

对于int ProcessCgi();他处理的是get带参/post的情况。
该函数的返回值及详细的函数处理数据的过程,在下面的CGI介绍当中会做以介绍。

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

首先介绍一下具体实现细节:
由于我们要通过httpserver可执行程序去调用目标进程去处理相应的数据,该目标程序被存放在path之中,因此在这里我们将会用到execl程序替换函数去调用目标程序。可是若直接调用execl函数,当cgi子程执行完之后进程会直接退出,因此在这里我们需要fork出一个子进程,让子进程去调用程序替换函数。
那么数据是如何进行通信呢?这里我们采用了匿名管道的方式来传输数据,由于是匿名管道是单向通信,因此我们要创建两个匿名管道,一个负责将数据从父进程流入到子cgi程序,另一个负责将处理结果从子cgi程序流向父进程。

**第一个约定:**我们约定完全站在父进程的角度
父进程从input管道里面读数据,子cgi进程往input管道里面写数据
父进程往output管道里面写数据,子cgi进程从output管道里面读数据
在这里插入图片描述

在这里插入图片描述

POST方法 path:目标程序 body:参数
GET方法 path: 目标程序 query_string: 参数
若是GET方法, 由于url长度有限,这时我们可以使用环境变量来进行传参,(程序替换并不会替换环境变量,只会替换代码和数据)
若是POST方法,正文的长度我们无法估计,因此父进程通过匿名管道的方式来进行传参
但是 若是父进程将数据写到output里面,子cgi程序并不知到要从output管道里面读多少数据,这时我们也要将content-length通过环境变量的方式传递过去。
在这里插入图片描述

在这里插入图片描述

最终由父进程将子cgi程序处理的结果统一从input管道里面读到http_response.response_body中。
在这里插入图片描述

如果是POST请求方法,父进程则直接将http_request_body的内容写入到匿名管道output中。
若是
可是又有一个问题来了,在执行execl函数之后,程序的代码以及数据会被替换,这时,之前打开的两个匿名管道即使未被关闭,可是文件描述符fd去找不到了,怎么解决呢?
我们想到了重定向,在程序替换期间,虽然数据和代码都被替换了,但是并不会替换内核进程相关的数据结构,包括文件描述表,有三个文件描述符却是被大家早已共识的,分别是标准输入,标准输出,和标准错误。他们的文件描述符分别是0,1,2
因此我们可以使用重定向到他们三个中的任意两个就行。
因此我们的第二个约定来啦~
读取管道等价于读取标准输入
写入管道等价于写到标准输出
在这里插入图片描述
这样就完成了httpserver进程与子cgi程序的数据交换与数据处理流程。

子CGI程序

在这里插入图片描述

在这里插入图片描述
对于code的处理:
统一交给void BulidHttpResponseHelper()函数来进行处理

code=200,或者其他错误状态码,我们直接将其的状态码与之映射的状态码描述,组成响应行。
在这里插入图片描述

在这里插入图片描述
然后错误的状态码按照他们各自的处理方式来处理(本质是每个错误码所对应的响应正文不同)
在这里插入图片描述
code=200时构建响应报头有两种情况
一种是cgi程序正常处理
一种是get不带参数的正常处理
子cgi程序的处理结果已经被父进程读出来了并写到了response_body当中,content-length则等于response_body.size()
而get不带参数的情况,则是被保存在了http_request.size()中
在这里插入图片描述

在这里插入图片描述

7.发送响应报文

在这里插入图片描述
a. 发送响应行
在这里插入图片描述
b.发送响应报头
在这里插入图片描述
3.发送空行
在这里插入图片描述
4.发送响应正文
两种情况:

a.经过CGI程序进行数据处理的结果(即响应正文)被放在了匿名管道之中,但是在发送正文ProcessCgi()函数中,父进程最终将匿名管道中的结果已经读到了http_response.response_body之中,因此这里要从应用层的http_response.response_body中读取响应正文,然后send到sock网络文件里面。
b.未被CGI程序处理的,则交由ProcessNoneCgi()函数处理,在ProcessNoneCgi()函数中我们已经将对应文件的fd打开,这种情况下
我们只需将fd对应的文件内容复制到sock文件当中即可。
介绍sendfile函数
sendfile()在一个文件描述符和另一个文件描述符之间复制数据。因为这种复制是在内核中完成的,所以sendfile比读取和写入的组合更高效,因为读取和写入需要在用户空间之间传输数据。
这里我们直接使用sendfile函数减少了内核到用户,用户到内核的拷贝,更高效。
在这里插入图片描述

8.错误处理

对于http服务器而言,错误处理分为逻辑错误处理和读写错误处理。

1.逻辑错误

逻辑错误包含请求的方法不对,资源不存在,创建子进程失败,创建管道失败等。这时我们只需根据响应的错误状态码给客户端(浏览器)返回一个相应的错误页面即可。

2.读写错误

假如http服务器正在读取请求报文的时候,对端将链接关掉了,此时服务器就没有必要继续读下去了,不对相应的请求做数据处理,而是在最后直接关掉相应的sock文件就行。
如果在读取请求报文的请求行或请求报头的时候,对端写端关闭了链接,那我们可以使用短路求值的逻辑处理方式,来判断后续的分析请求行,分析请求报头以及读取请求正文等步骤是否需要进行。
在这里插入图片描述

若读取请求报文失败了,那么后续的构建响应报文和发送响应报文也没必要走下去了,直接跳到最后,关闭sock文件描述符即可。

在这里插入图片描述

但是若是在http服务器往sock文件里面写入响应报文的时候,对方关掉了链接,这时OS操作系统会给进程发送SIGPIPE信号,会导致进程崩溃,因此我们要在SIGPIPE信号的信号处理函数中将其处理方式设置为SIG_IGN
在这里插入图片描述

9.Sock套接字编程

这里我们使用单例模式创建TcpServer对象,并提供一个可供全局的访问接口。使得所有线程共享,简化了复杂环境下的线程管理
在这里插入图片描述
第一步
创建监听套接字
基于IPv4,socket的第一个参数sin_family设置为AF_INET
我们编写的是http服务器,他底层使用的是tcp协议,因此第二个参数设置为SOCK_STREAM

在这里插入图片描述
第二步:bind() 绑定IP和端口号
服务器程序所监听的网络地址和端口号通常是固定不变的,客户端程序得知服务器程序的地址和端口号后就可以向服务器发起连接
bind()的作用是将 listen_sock与local进行绑定,绑定之后,监听套接字就可以监听前来进行与服务器通讯的连接。
在这里插入图片描述
3.int listen(int sockfd, int backlog);
将listen_sock设置为监听状态,并且最多允许拥有backlog + 1个客户端处于ESTABLISHED状态。如果有更多则忽略

在这里插入图片描述
4.TcpServer初始化完毕~
在这里插入图片描述

10.多线程的优化

解决的问题:
1.大量链接过来导致服务器内部进程或者线程暴增,进而导致服务器效率严重降低或者挂掉
2.节省链接请求到来时,创建线程的时间成本
3.让服务器的效率在一个恒定的稳定区间内,(线程个数不增多,CPU调度成本不变),当某一个时间区间申请线程的数量突然暴增,若没有线程池,则线程数量会一直增加,短时间间内内存达到极限,可能导致程序奔溃
适用场景:
单个任务小,任务量巨大,像WEB服务器完成网页请求这样的任务就非常合适。但是一旦有长时间的任务到来时,例如telnet连接请求,此时线程池的优点就不是很明显了。因为telnet的会话时间相比于现成的创建时间长太多了
对于要求服务器能够迅速做出响应给客户端请求的场景,因为减少了线程的创建时间,优化了时间成本。

在这里插入图片描述

我们先将任务队列要做的什么先处理好,通过回调来处理我们的sock网络请求
在这里插入图片描述
在这里插入图片描述

创建线程对象我们使用了单例模式,便于我们来管理。这里使用了双重判定空指针,减少了锁冲突的概率。
在这里插入图片描述
在这里插入图片描述
当有线程来领取任务的时候,先上锁,若队列不为空,则直接直接拿任务,若为空则需要,进入阻塞队列进行等待,当任务队列中有任务时,会被唤醒。注意这里也有可能是伪唤醒,可能并没有新的任务。因此要再继续判断任务队列是否为空,如果为空,则继续进入阻塞队列等待,因此要使用while循环判定防止伪唤醒。。
在这里插入图片描述

11. 项目测试:

抓包测试
分别使用telnet工具和postman进行测试。

telnet工具

(1)GET方法测试,不带参数,返回默认首页信息index.html

在这里插入图片描述

(2)GET方法测试,带参数,返回test_cgi子程序执行结果
在这里插入图片描述
(3)POST方法测试,返回test_cgi子程序执行结果
在这里插入图片描述

postman工具

(1)GET方法测试,不带参数,返回默认首页信息index.html

在这里插入图片描述
在这里插入图片描述

(2)GET方法测试,带参数,返回test_cgi子程序执行结果

在这里插入图片描述

在这里插入图片描述

(3)POST方法测试,返回test_cgi子程序执行结果
在这里插入图片描述

12.遇到的问题

1.
读取协议报头的行分隔符需要做统一处理,在判断行分隔符是\r还是\r\n时,不能够直接调用recv继续读取下一个字符,否则会将接受缓冲区的字符拿
走,从而影响到下一行的数据。这时候将recv函数中的flag标志位设置成MSG_PEEK选项进行窥探下一个字符,使用该选项不会将接受缓冲区的字符拿
走,巧妙地解决了该问题。
2.
发送响应正文时,如果要返回网页资源,最初是想通过read先进行从fd读取文件,读到自己定义的一个buffer缓冲区中,然后调用write系统调用函数将缓
冲区中的内容写入sock文件中。这种方法,感觉效率低。因为从内核到用户再是用户到内核,每次都需要IO,IO意味着要进行等待。后来发现
sendfilesendfile()函数,它的作用是在一个文件描述符和另一个文件描述符之间复制数据。因为这种复制是在内核中完成的,所以sendfile比读取和写入
的组合更高效,因为读取和写入需要在用户空间和内核之间传输数据。,效率很低;
3.
之前有几次服务器莫名的崩溃掉了,后来找到了原因,原来是服务器在写入时,客户端关闭连接会导致服务器崩溃。这是因为操作系统给服务器进程发
送了SIGPIPE信号,后面意识到了将该信号设置为SIG_IGN,将该信号的处理方式设置为SIG_IGN忽略,解决了问题;
  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

倚心

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值