
🔥海棠蚀omo:个人主页
❄️个人专栏:《初识数据结构》,《C++:从入门到实践》,《Linux:从零基础到实践》,《Linux网络:从不懂到不会》
✨追光的人,终会光芒万丈
博主简介:

目录
一.HTTP协议的背景
二.快速实现一个HTTP的效果
三.建立HTTP协议的宏观认识
3.1HTTP请求报文和响应报文的具体结构
3.2URL和URI
3.3URL的编码
3.4重谈HTTP协议请求和响应格式
前言:
在网络通信的众多协议中,HTTP 是最常被接触、却又最容易被忽略的一个。我们每天通过浏览器、客户端或各类应用程序不断地向服务器发起 HTTP 请求,并接收对应的响应结果,但对这一过程的具体细节却往往缺乏系统性的认识。
当一次 HTTP 请求被发出时,请求报文是如何组织的?服务器又是依据什么规则返回响应?连接在其中承担了怎样的角色?本文将从最基础的概念入手,围绕 HTTP 协议的核心组成和工作方式进行介绍,帮助大家建立对 HTTP 协议的初步理解,为后续深入学习相关内容奠定基础,下面跟我一起来看看吧。
一.HTTP协议的背景
在讲解后面的知识前,我们先来来了解一下HTTP协议的相关背景。
虽然我们说, 应⽤层协议是我们程序猿⾃⼰定的。但实际上, 已经有⼤佬们定义了⼀些现成的, ⼜⾮常好⽤的应⽤层协议, 供我们直接参考使⽤。
HTTP(超⽂本传输协议)就是其中之⼀。
在互联⽹世界中,
HTTP(HyperText Transfer Protocol,超⽂本传输协议)是⼀个⾄关重要的协议。 它定义了客⼾端(如浏览器)与服务器之间如何通信,以交换或传输超⽂本(如HTML⽂档)。
HTTP协议是客⼾端与服务器之间通信的基础。客⼾端通过HTTP协议向服务器发送请求,服务器收到 请求后处理并返回响应。
HTTP协议是⼀个⽆连接、⽆状态的协议,即每次请求都需要建⽴新的连接, 且服务器不会保存客⼾端的状态信息。
二.快速实现一个HTTP的效果
有了对我们前面章节的认识,这里我们直接来实现一个简单的HTTP的效果,而在这之前我们先做一些准备工作:

那么首先我们要先准备这几个文件,这里面的代码都是我们之前实现过的,在前面的章节中都是可以找到的,这里就不再演示。
而在这里面我们要调整一下TcpServer中的一些内容:



再上一篇文章中,我们在实现TcpServer的时候HandlerRequest中实现的是长连接,这里我们还没有正式接触http协议,所以这里我们先不使用长连接,使用短连接。
并且我们还将TcpServer将来要调用的函数简化一下,转为去调用http函数,打印出接收到的字符串,便于我们后面进行演示。
下面我们将运行一下我们的Main.cc,通过http来看一下效果:
![]()

我们会看到这样的结果,并且与此同时我们在我们的终端中还会看到这样的内容:

当建立起新连接后,我们可以看到这样的字符串,这就是http发给我们的报文,那么我们第一眼看到可能认为是发了多行的字符串,但其实它们是一个字符串。
这种现象我们在上一章中就已经讲过了,我们自己发送的报文看到的时候也是多行的,但并不是发了多行的字符串,只是在中间通过" \\r\\n "等特殊字符实现了换行的效果。
那么我们可能想知道HTTP协议是怎样进行序列化和反序列化的呢?
答案就是HTTP协议序列化和反序列化的过程,就是一个字符串的拼接和拆分的过程,这个过程中http协议没有用任何第三方的库,就比如:jsoncpp库,全部是自己手搓的!!!
而上面我们所看到的是http连接的报文,那么下面我们来看一个连接成功的报文:

这是http连接成功后的响应报文,上面我们看到的是报头,下面就是有效载荷,它们中间通过空行隔开。
这个结构我们应该很熟悉,没错,就是我们上一章在实现网络版计算器的时候采用的方式,为了能够拿到下面完整的有效载荷,和我们之前实现的思路是完全一样的,只不过http的报头设计的更复杂,包含更多的信息,我们所实现的网络版计算器报头只包含了后面有效载荷的长度。
而下面的有效载荷就是具体的浏览器网页信息,会以字符串的形式给我们返回。
三.建立HTTP协议的宏观认识
3.1HTTP请求报文和响应报文的具体结构


这是HTTP的请求报文,结合上面的报文信息,可以看到和上面我们看到的结构是一样的,第一行是请求行,包含请求方法,URI以及HTTP的版本。
而下面的多行字符串,它们的结构都是key:[空格]Value的形式,这样设计也是为了便于能够未来将字符串进行拼接和拆分,这里面的各种信息我们现在还不知道它们分别表示什么意思,不过下面我会讲解。
在下面就是一个空行,这个空行就是为了区分上面的报头和正文的。而最后就是正文部分了,而请求正文可能会有,也可能没有,这要结合具体的使用场景,不过大多数场景下是不包含请求正文的。
那么下面我们就来看看HTTP的响应报文的结构是什么样子的:

上图就是响应报文的完整结构,和请求报文的结构是非常相似的,主要是状态行内容的不同,在响应报文的状态行中,第一个属性是HTTP版本,后面跟的是状态码。
这个状态码我们其实在生活中也是见过的,我们访问某些网站的时候,有时候会显示" 404 ", 这就是状态码。
而最后的状态码描述就是指明前面状态码产生的原因,即为什么会产生这个描述码,就如上面如果页面出现404,下面还会跟一行NotFound,这就是对404的解释。
剩下的和请求报文就是一样的了,而最后的响应正文上面我们也看到了,可以是html形式的网页信息,并且不止这一种形式,还可以是:css,js,图片,视频,音频等等各种内容。
那么我们也不能光说不练,我们还是要和实操结合起来的,后面我们也是要自己实现一个http协议的,而讲到这里我们就可以先写一些基本的框架:


那么和之前的方式是一样的,我们依旧是通过定义HttpRequest和HttpResponse类来表示HTTP协议的请求报文和响应报文。
并且通过两种报文的结构,我们可以先把未来要用到的属性先一一列举出来,那么我们的工作先到此为止。
3.2URL和URI
那么下面我们就来介绍一下URL和URI的相关知识,我们通过一个例子来讲解:
![]()
看到这个我们的第一反应是这是一个网址,那么今天我告诉你," 网址 "只是我们的口语说法,其实它的真正名字叫做:URL(Uniform Resource Locator),即统一资源定位符。
那么下面我们就来解析一下URL,也就是看看这个" 网址 "中都有些什么东西:

首先就是最前方的https,这就是在指明要用的协议是什么,这里我们先将其认为是http,https我们后面再讲。
而紧接着后面的部分叫做域名,而域名在http协议发送请求之前,会通过DNS转化为公网IP地址,也就是我们所熟知的" 点分十进制 "的IP地址。
而讲到这里我们先暂停一下,我们先来思考一个问题:我们通过访问" 网址 "是想来获取信息,或者资源,那么当我们没有得到我们想要的资源的时候,资源在哪里呢?
答案就是在服务器中,在我们没有访问" 网址 "的时候," 网址 "所对应的资源通常已经存放在服务器上,只是没有被发送给客户端而已,并且互联网中的大多数服务器都是Linux系统,那么又有问题了:那我们如何找到对应的服务器呢?
上面刚讲过,域名转化为公网IP地址,我们通过IP地址就能找到相应的服务器了,那么既然是Linux系统的服务器,而Linux下" 一切皆文件 ",那么我们的资源在服务器上是以什么样形式来保存的呢?
很明显,是以文件的形式来保存的,但是Linux下的文件很多,我们该如何找到自己想要的资源呢?
没错,通过路径嘛,通过路径我们就能找到Linux下的任何一个文件,那么这个路径是什么呢?
那么答案就是域名后面的那一砣,那就是我们要找的指定路径下的文件!!!

解决了上面的各种问题,那么下面我们再来思考一个问题:未来谁将文件内容发给客户端呢?
答案就是我们所写的软件server,换句话说,也就是通过进程发送过去的,那么相应的客户端也会有相应的进程来接收,和我们之前再讲Socket编程时候的说法是一样的,也就是我们可以认为是通过进程间通信的方式来实现通信的。
而我们之前也讲了,要想在网络中定位一个进程,只有IP地址是不够的,还需要有端口号port,那么我们在上面并没有看到端口号,那么端口号在哪儿呢?
我们早在初识网络的时候就说过,0~1023是知名端口号的范围,那么作为这么出名的应用层协议:HTTP,怎么会没有自己的默认端口号呢?
HTTP协议的默认端口号就是:80,在上面我们通过http协议访问自己写的的server软件时,是要加上8080的端口号的,而当我们访问一些网址的时候,即浏览器在解析URL的时候,如果没有显示指定端口号,浏览器就会使用http协议的默认端口号80,所以这里端口号是省略了,但不是不用端口号!!!
有了上面的认识后,我们就知道了URL为什么叫做统一资源定位符了,定位二字就体现在它能标识互联互联网中唯一的一个文件,且能让我们找到它。
那么不止有URL,还有URI(Uniform Resource Identifier),它在上面http的请求报文中也出现了,URI又叫统一资源标识符,URL就是一种特殊的URI,URI只能够标识网络中的唯一的一个文件,但是并不能让我们找到它,而URL不仅可以标识,还可以让我们找到它。
这里我在提一个小知识:

问大家一个问题:上面我们说域名后面的就是指定文件的路径,那么位于最前面的" / "是根目录吗?
其实这个" / "并不是我们所认为的根目录,它其实是web根目录,它可以是根目录,它也可以是Linux下的任意一个目录,但是这样说太干了,后面我们会通过代码来展示,让大家更深刻的理解这个知识。
3.3URL的编码


当我们在网页端搜索数据的时候,比如:我们搜索helloworld,之后就会跳转到一个新的页面,并且页面上方会有相应的URL。
而在这个URL中,里面wd的内容就是我们在搜索时所填写的内容,但是我想说的并不是这些,那么下面我们再来看一种现象:


而当我们输入的内容是一些特殊字符时,我们可以看到wd此时的内容就变为了另一种形式,出现这种现象的原因是:URL中不允许出现特殊字符,在生成URL的时候这些特殊字符就会进行" URL转码 ",转码后的内容就如上图所示。
那么这里就会衍生出一些问题,例如:为什么要进行转码呢?又是如何进行转码的呢?
那么下面我们就来回答这两个问题:
1.为什么要进行转码?
我们要知道,我们输入的一些特殊字符在URL中特殊含义的,比如:?表示查询参数开始,&表示参数分割,=表示键值分割,/表示路径分隔,#表示锚点等等,所以转码的原因就是因为这些特殊字符会影响浏览器解析URL。
2.如何进行转码?
转码的规则就是:将需要转码的字符转为16进制,然后从右到左,取4位(不足4位直接处理),每2位做一位,前面加上%,编码成%XY格式。
我们可以看上面的例子,前面两个" / "是英文字符,只有一个字节,所以转码后也只有一个,而" ? "我在输入时使用中文输入的,所以它占3个字节,每个字节经过转码后就变为如上图所示的三个,后面的c++,c不是特殊字符,所以不需要转码,而后面的两个++和前面的" / "一样会被转码,所以将它们转码后的结果拼接在一起就变成了上面我们所看到的结果。
对于上面的转码,我们还可以将这种行为称为:url encode,那么与转码相反的就是解码,即:url decode,这种行为就是将转码后的结果重新转回原本的字符。
3.4重谈HTTP协议请求和响应格式
那么在讲解了上面的知识后,我们重新回到HTTP协议的请求和响应格式上面:


我们先说它们两个中的第一行,也就是请求行和状态行,而在这里面我们先谈一下HTTP版本。
随着时代的发展,Http协议也有了很多种版本,例如:http/1.0,http/1.1,http/2.0,http/3.0等多个版本。
那么我们来思考一个问题:为什么协议里面要带上http的版本呢?
这就和我们软件的版本很相似,软件厂商为当前的软件推出了新的版本,但是用户可以选择不更新,也可以选择更新,所以该软件在市面上是新旧版本共存的。
那么我们打个比方,一部手机上的微信是最初的版本,那时候的微信上连朋友圈的功能都没有,但是另一个手机上的微信是最新版微信,各种功能都齐全,那么两部手机上现在是同一个账号,那么两部手机都能看到别人发的朋友圈吗?
肯定是不能的,最新版的微信当然能看到,而最初版本的微信是看不了的,那么举上面的例子我想表达什么呢?
我想表达的是协议中加上http版本的原因:本质上是为了让客户端和服务端进行协议能力协商,从而在保持向后兼容的前提下,安全地启用或禁用某些特性。
说人话就是通过确认客户端,服务端双方http协议的版本来判断是否可以使用新特性,就和上面微信能否使用朋友圈功能是一样的道理!!!
而请求报文中的请求方法和URI我放在后面再讲,而响应报文中的状态码和状态符描述,我们上面已经说过了,这里就不再赘述。
那么下面在请求行和状态行下面就是请求报文和响应报文了,我已请求报文为例,下面我们先提前渗透一些里面的字段的含义:

1.HOST
该字段就表示我们要访问的资源是在哪台机器上面,即用于指明客户端请求的目标主机。
2.Content-Length
该字段表示正文部分的长度,就和我们上一篇实现网络版计算器中报头中设置的字段是一样的,通过该字段我们就能保证如果有正文,我们能够把正文部分一字不差的读取上来!!!
3.User-Agent
该字段用于标识发起 HTTP 请求的客户端软件及其运行环境,让服务器能够识别“是谁、用什么方式”在访问资源。
这里举个例子来帮助大家理解:我们在手机上搜索微信,那么我们找到的就是安卓版或者IOS版的微信,而如果我们用电脑去搜索微信的话,我们搜索到的就是PC端的微信,那么这些搜索引擎是如何知道我们的客户端平台是什么呢?
就是根据User-Agent中的信息,我们来看:
![]()
该字段里面的这部分信息就是我们的的操作系统/架构的相关信息,通过这部分信息搜索引擎就知道我们是PC端还是移动端了,那么它在搜索时就会根据客户端平台不同来推出信息了。
最后再补充一点:像Content-Length和User-Agent这类的字段我们称为自描述字段!!!
那么有了上面知识的铺垫后,下面我们来完善一下我们的http协议:


// 提取报文的每一行字符串
string ReadOneLine(string &reqstr, bool *status)
{
int pos = reqstr.find(linesep);
if (pos == string::npos)
{
*status = false;
return string();
}
string line = reqstr.substr(0, pos);
reqstr.erase(0, pos + linesep.size());
return line;
}
// 对请求行的数据进行反序列化
void ParseReqLine(string &linestr)
{
stringstream ss(linestr);
ss >> _method >> _uri >> _http_version;
}
// 对请求报文的内容进行反序列化
void BuildKV(string &linestr, string &key, string &val)
{
int pos = linestr.find(innersep2);
if (pos == string::npos)
{
key = val = string();
return;
}
key = linestr.substr(0, pos);
val = linestr.substr(pos + innersep2.size());
}
那么首先我们先对请求报文进行实现,对于服务端我们不实现序列化的工作,这是客户端要做的,我们只实现反序列化的工作,将客户端的请求报文进行反序列化。
而在上面的代码中,对每个部分我已经做了解释,所以这里我只说其中的实现思路:
因为我们所看到的报文的信息是以每行出现的,所以首先我们要有一个能够提取每一行字符串的函数,将每一行的信息给提取出来,并将已经提取出来的字符串从原字符串中剔除掉,便于后面的操作,ReadOneLine函数就是来完成该工作的。
之后因为请求行和下面的请求报头格式不同,所以我们要单独对请求行进行处理,通过ParseReqLine函数就可以完成该工作,在这个函数中我使用了stringstream的方式,当然你也可以遍历该字符串,以空格为间隔符,来提取其中的请求方法,URI和HTTP版本的信息。
然后就要处理最多的请求报头部分了,这里我们通过while死循环的方式来提取请求报头的的每一行的信息,BuildKV函数就是将提取到的字符串中的信息进行反序列化,将其存入到_req_headers中。
而如果读到空行,linestr就为空串,但是status依旧是true,所以就会进入另一个判断条件中,进而对_blank_line进行赋值。
那么最后就只剩下正文部分了,因为上面我们一直在将得到的每行字符串从原字符串中进行剔除,所以原字符串reqstr最后就只剩下正文部分了,所以我们直接让_req_body等于reqstr即可。
那么完成了请求报文的反序列化后,下面我们就可以具体来谈谈请求行里面的uri属性了:
![]()
![]()
我们上面说了,上图中的" /a/b/c/html "表示返回指定目录下的文件,而当我们什么也没有指定时,我们可以从请求报文中看到uri是" / ",而uri:/其实是表明请求目标服务器的首页,准确来说是" /index.html ",是服务端将" / "解析成为" /index.html ",即访问服务器的首页。
既然是服务端来做的这部分的工作,我们实现的就是服务端,所以我们就可以从这入手,自己来指定路径,我们来看:

我们可以将wwwroot作为web根目录,在该目录下,我们可以创建css,js,picture,video等目录来保存不同的资源,包括服务器的页面index.html,后面我们给客户端返回的就可以是该目录下的文件资源,那么具体该如何操作呢?下面我们就来看看:


![]()
我们可以定义一个变量_path来记录我们真正要访问的资源的路径,并将web根目录和页面文件定义成全局变量,而我们在操作时,直接让_path等于webroot,然后再让其加上_uri,之后我们需要对_uri进行判断,如果_uri等于" / ",那么就说明没有指定路径,那么我们就将" / "解析为" ./wwwroot/index.html ",未来给客户端返回的就是该路径下文件的内容。
那么资源的路径问题解决了,那么既然已经收到了客户端发来的请求报文,那么下面我们要做的就是找到客户端要访问的文件,并将文件中的内容存放在缓冲区中,最后通过序列化将文件内容发给客户端。
那么下面我们就先来实现离我们最近的工作:
bool GetFileContentHelper(const string &path)
{
// 1.以二进制的形式读取文件
ifstream in(path, std::ios::binary);
if (!in.is_open())
{
LOG(LogLevel::WARNING) << path << " 资源不存在!";
return false;
}
// 2.获取文件的大小
in.seekg(0, in.end);
int filesize = in.tellg();
in.seekg(0, in.beg);
// 3.将文件中的内容写入到_res_body中
_res_body.resize(filesize);
in.read((char *)_res_body.c_str(), filesize);
in.close();
return true;
}
我们可以定义一个GetFileContentHelper函数,将我们上面定义的_path传进来,通过文件操作函数来读取该文件中的内容,并将其保存在我们所定义的正文部分_res_body中。
而从上图我们可以看到我们是以二进制的方式来读取文件内容的,为什么要以二进制的形式来读取呢?
因为我们未来要打开的文件可能不是我们平常所看到的普通文件,它们可能是音频文件,视频文件等各种文件,这些文件我们是不能用平常的方式来读取去文件中的内容的,所以我们选择用二进制的方式来读取这些文件中的内容。
那么指定路径下的文件内容保存工作已经完成了中了,那么下面我们的工作就是通过序列化将缓冲区的内容发给客户端,下面我们一步一步来完成:



对于服务端而言,我们和上面一样,响应报文不需要实现反序列化的过程,这是客户端要做的。
那么下面就是来完成响应报文的序列化工作了,上面我们既然说了http协议不借助任何的第三方库,它的序列化和反序列化的工作就是字符串拼接和拆分的过程,上面拆分的过程我们做了,那么这里就是字符串拼接的工作,拼接相较于拆分好写多了。
我们只需要按照格式把每个部分拼接起来即可,很简单,这里面我们可以先对_http_version,_blank_line,_code和_desc做一个简单的初始化,剩下的如_resp_headers中的属性我们现在还没有,但是目前并不影响我们下面的操作。
既然所有的工作都已经完成了,那么下面我们就来实验下吧:



这里我们定义一个Http类,通过里面的HandlerRequest函数将序列化与反序列化的过程给整合起来,后面我们只需要调用该函数即可完成序列化与反序列化的工作,将完成的响应报文发给客户端。
上面客户端既然默认请求服务端的页面,那我们就来写个简单的页面让它返回回去看看效果:

这里我们可以直接使用ai来给我们生成一个精美的画面,什么都可以,我们只是用来观察效果罢了,而从上面的结果中我们可以看到我们确实将wwwroot目录下的index.html文件内容返回给客户端了,最终看到了页面的效果。
以上就是写给初学者的 HTTP 协议入门指南:从原理到实践的全面认识的全部内容。



