头像


🎬 个人主页艾莉丝努力练剑

专栏传送门:《C语言》《数据结构与算法》《C/C++干货分享&学习过程记录
Linux操作系统编程详解》《笔试/面试常见算法:从基础到进阶》《Python干货分享

⭐️为天地立心,为生民立命,为往圣继绝学,为万世开太平

🎬 艾莉丝的简介:

在这里插入图片描述



在这里插入图片描述


本文大纲

HTTP协议原理与C++实现
├─ HTTP协议基础结构
│  ├─ HTTP请求报文
│  │  ├─ 请求行(方法+URI+版本)
│  │  ├─ 请求报头(Key-Value键值对)
│  │  ├─ 空行(分隔报头与正文)
│  │  └─ 请求正文(可选,与方法相关)
│  └─ HTTP响应报文
│     ├─ 状态行(版本+状态码+描述)
│     ├─ 响应报头(Key-Value键值对)
│     ├─ 空行(分隔报头与正文)
│     └─ 响应正文(资源内容)
├─ 静态资源服务器实现
│  ├─ Web根目录与路径处理
│  │  ├─ Web根目录概念(隔离服务器文件系统)
│  │  ├─ 根目录请求处理(自动拼接index.html)
│  │  └─ URI到本地路径的映射
│  ├─ 二进制文件读取
│  │  ├─ 文本读取的问题(\0截断)
│  │  ├─ seekg/tellg获取文件大小
│  │  └─ string作为二进制缓冲区
│  ├─ 文件类型识别
│  │  ├─ Content-Type报头作用
│  │  ├─ 文件后缀映射MIME类型
│  │  └─ 魔数与浏览器自动识别
│  └─ 静态资源特性
│     ├─ 一个网页对应多个HTTP请求
│     ├─ 图床与CDN的作用
│     └─ 浏览器缓存机制
├─ HTTP状态码详解
│  ├─ 状态码分类
│  │  ├─ 1XX:信息性状态码
│  │  ├─ 2XX:成功状态码
│  │  ├─ 3XX:重定向状态码
│  │  ├─ 4XX:客户端错误状态码
│  │  └─ 5XX:服务器错误状态码
│  └─ 常见状态码详解
│     ├─ 200 OK:请求成功
│     ├─ 404 Not Found:资源不存在
│     ├─ 403 Forbidden:无权限访问
│     ├─ 500 Internal Server Error:服务器内部错误
│     └─ 502 Bad Gateway:网关错误
├─ 重定向机制
│  ├─ 重定向本质:状态码+Location报头
│  ├─ 301永久重定向
│  │  ├─ 含义:资源永久移动
│  │  ├─ 特性:浏览器/搜索引擎缓存
│  │  └─ 场景:域名变更、URL重构
│  ├─ 302临时重定向
│  │  ├─ 含义:资源临时移动
│  │  ├─ 特性:每次请求原地址
│  │  └─ 场景:登录跳转、临时维护
│  └─ 常见问题:重定向次数过多
├─ HTTP请求方法
│  ├─ 常见方法概览
│  │  ├─ GET:获取资源
│  │  ├─ POST:传输实体主体
│  │  ├─ PUT:传输文件
│  │  ├─ HEAD:获取报文首部
│  │  └─ DELETE:删除文件
│  ├─ GET方法传参
│  │  ├─ 参数拼接在URI中(?key=value&key2=value2)
│  │  ├─ 参数可见,长度有限制
│  │  └─ 服务器端参数解析实现
│  └─ POST方法传参
│     ├─ 参数放在请求正文中
│     ├─ 参数不可见,长度无限制
│     └─ Content-Length与正文读取
└─ 动态交互实现
   ├─ 前后端交互本质
   │  ├─ 静态HTML无法处理数据
   │  ├─ 数据提交给后端程序处理
   │  └─ form表单的action与method属性
   ├─ 服务注册与路由机制
   │  ├─ 定义路由处理函数类型
   │  ├─ 用unordered_map存储路由映射
   │  └─ Register接口注册服务
   └─ 完整请求处理流程
      ├─ 反序列化HTTP请求
      ├─ 判断是否为动态路由
      ├─ 调用对应处理函数
      └─ 序列化HTTP响应返回

在这里插入图片描述


导入语

HTTP(超文本传输协议)是互联网上应用最广泛的应用层协议,是整个 Web 世界的基石。我们每天浏览网页、看视频、刷社交媒体,背后都离不开 HTTP 协议的工作。很多uu学习 HTTP 时只是死记硬背概念,比如请求方法、状态码,但却不理解它们在实际代码中是如何实现的。本文中,艾莉丝将通过用 C++ 编写一个完整的 HTTP 服务器的过程,带你深入理解 HTTP 协议的核心原理。我们会从最基础的请求响应报文结构讲起,一步步实现静态资源的读取与返回、状态码的处理、重定向功能,最终实现支持 GET 和 POST 方法的动态交互服务器。通过代码层面的实现,我们将真正明白为什么 HTTP 报文是那样设计的,每个字段有什么作用,以及前后端是如何通过 HTTP 协议进行交互的。


1 ~> HTTP 协议基础结构

HTTP 是一个基于请求 - 响应模式的无状态协议,所有的通信都由客户端发起请求,服务器返回响应。无论是请求还是响应,都遵循严格的报文格式,以行为单位进行解析。

1.1 HTTP 请求报文结构

HTTP 请求报文由四部分组成:请求行、请求报头、空行和请求正文

  • 请求行:是请求报文的第一行,包含三个字段,用空格分隔:请求方法、URI(统一资源标识符)和 HTTP 版本。例如GET /index.html HTTP/1.1表示使用 GET 方法请求服务器上的/index.html资源,使用 HTTP/1.1 版本协议。
  • 请求报头:由多行 Key-Value 键值对组成,每行格式为Key: Value,用于传递请求的附加信息,比如客户端的浏览器类型、支持的编码方式、连接状态等。常见的请求报头有Host(指定服务器的主机名和端口)、User-Agent(客户端标识)、Accept(客户端能接收的媒体类型)等。
  • 空行:是一个单独的换行符,用于分隔请求报头和请求正文,告诉服务器报头已经结束。
  • 请求正文:可选部分,用于向服务器提交数据,是否存在与请求方法有关。例如 GET 方法通常没有请求正文,而 POST 方法会把提交的数据放在正文中。

在这里插入图片描述

1.2 HTTP 响应报文结构

HTTP 响应报文也由四部分组成:状态行、响应报头、空行和响应正文。

  • 状态行:是响应报文的第一行,包含三个字段,用空格分隔:HTTP 版本、状态码和状态码描述。例如HTTP/1.1 200 OK表示服务器使用 HTTP/1.1 版本协议,请求成功处理。
  • 响应报头:同样由多行 Key-Value 键值对组成,用于传递响应的附加信息,比如响应正文的长度、类型、服务器信息等。常见的响应报头有Content-Length(响应正文的字节数)、Content-Type(响应正文的媒体类型)、Server(服务器软件标识)等。
  • 空行:分隔响应报头和响应正文。
  • 响应正文:服务器返回的资源内容,可以是 HTML 文本、图片、视频、JSON 数据等任何类型的字节流。

在这里插入图片描述


2 ~> 静态资源服务器实现

静态资源服务器是 HTTP 服务器最基础的功能,它的作用是将服务器上的文件(如 HTML、CSS、JS、图片、视频等)返回给客户端。实现一个静态资源服务器需要解决路径处理、文件读取、内容类型识别等核心问题。

2.1 Web 根目录与路径处理

HTTP 协议的核心是传输资源,这些资源在服务器上以文件的形式存在。为了安全起见,我们不能让客户端访问服务器上的任意文件,因此需要设置一个Web 根目录(通常命名为wwwrootwebroot,不过命名这个自拟即可,艾莉丝演示的时候使用的是tuturoot),所有客户端能访问的资源都必须放在这个目录下。

客户端请求中的 URI 会被映射到 Web 根目录下的对应路径。例如,当客户端请求/index.html时,服务器会去读取wwwroot/index.html(或者tuturoot/index.html,这个Web根目录的名字无所谓,自己定就行)文件的内容。需要特别处理根目录请求(即 URI 为/根目录),此时服务器应该返回网站的首页,通常是index.html,因此需要将路径拼接为wwwroot/index.html(或者tuturoot/index.html)。

2.2 二进制文件读取实现

服务器需要读取各种类型的文件,包括文本文件(HTML、CSS、JS)和二进制文件(图片、视频、音频)。如果使用普通的文本方式读取文件,会遇到一个严重的问题:二进制文件中可能包含\0字符,而 C++ 的字符串默认以\0作为结束标志,这会导致文件内容被截断。

因此,必须使用二进制方式读取文件。具体实现步骤如下:

1、使用std::ifstream以std::ios::binary模式打开文件。
2、使用seekg(0,std::ios::end)将文件指针移动到文件末尾。
3、使用tellg()获取当前文件指针的位置,也就是文件的大小。
4、使用seekg(0, std::ios::beg)将文件指针移动回文件开头。
5、创建一个大小等于文件大小的std::string作为缓冲区。
6、使用read()方法将文件内容一次性读取到缓冲区中。
7、关闭文件并返回缓冲区内容。

这种方式可以保证无论是文本文件还是二进制文件,都能被完整地读取,不会出现内容丢失的问题。

2.3 Content-Type 与文件类型识别(魔数)

当服务器返回资源给客户端时,需要告诉客户端资源的类型,这样客户端才能正确地处理和显示资源。这个信息通过Content-Type响应报头传递,它的值是一个 MIME 类型(Multipurpose Internet Mail Extensions)。

服务器可以通过请求资源的文件后缀名来判断资源类型,并映射到对应的 MIME 类型。例如:

  • .html.htm对应text/html
  • .css对应text/css
  • .js对应application/javascript
  • .jpg.jpeg对应image/jpeg
  • .png对应image/png
    我们可以在服务器中维护一个后缀名到 MIME 类型的映射表,当处理请求时,提取资源路径的后缀名,然后查找映射表得到对应的Content-Type值,添加到响应报头中。

现代浏览器为了兼容不规范的服务器,会通过读取文件开头的魔数(Magic Number)来自动识别文件类型。魔数是文件开头的特定字节序列,用于唯一标识文件格式。例如 JPEG 文件的魔数是FF D8 FF,PNG 文件的魔数是89 50 4E 47。但为了保证兼容性,服务器仍然应该正确设置Content-Type报头。

2.4 静态资源的多请求特性

很多人会误以为一个网页对应一个 HTTP 请求,实际上并非如此。一个 HTML 网页中通常会包含图片、CSS 文件、JS 文件、视频等多个外部资源,浏览器在解析 HTML 时,会自动为每个外部资源发起一个单独的 HTTP 请求。

例如,当我们访问一个包含两张图片的网页时,浏览器会先发起一个请求获取 HTML 文件,然后解析 HTML 发现有两个<img>标签,再分别发起两个请求获取这两张图片。因此,一个简单的网页可能会对应十几个甚至几十个 HTTP 请求。

为了减轻服务器的带宽压力,我们可以使用图床来存储图片资源。图床是专门用于存储和提供图片访问的服务,我们只需要在 HTML 中使用图床提供的图片链接,浏览器就会直接从图床服务器获取图片,而不需要经过我们自己的服务器。

更通用的解决方案是使用CDN(内容分发网络)。CDN 通过在全球部署大量的服务器节点,将网站的静态资源缓存到离用户最近的节点上,用户访问时直接从最近的节点获取资源,从而大幅提升访问速度,同时减轻源服务器的压力。


3 ~> HTTP 状态码详解

HTTP 状态码是服务器在响应报文中返回的一个 3 位数字,用于告诉客户端请求的处理结果。状态码的第一位数字定义了响应的类别,后两位数字没有分类作用,用于表示具体的状态。

3.1 状态码分类与作用

在这里插入图片描述

HTTP 状态码分为五大类:

  • 1XX(信息性状态码):表示服务器已经接收到请求,正在处理。这类状态码比较少见,例如 100 Continue 表示客户端可以继续发送请求正文。
  • 2XX(成功状态码):表示请求已经被成功接收、理解和处理。最常见的是 200 OK,表示请求成功,服务器返回了请求的资源。
  • 3XX(重定向状态码):表示需要客户端进行附加操作才能完成请求,通常是跳转到另一个 URL。
  • 4XX(客户端错误状态码):表示客户端发送的请求有错误,服务器无法处理。最常见的是 404 Not Found,表示请求的资源不存在。
  • 5XX(服务器错误状态码):表示服务器在处理请求时发生了错误。最常见的是 500 Internal Server Error,表示服务器内部发生了未预期的错误。

状态码和状态码描述是一一对应的,状态码是给浏览器看的,用于自动处理响应;状态码描述是给人看的,方便开发者调试。

3.2 常见状态码详解

  • 200 OK:请求成功,服务器返回了请求的资源。这是最常见的状态码,当我们成功访问一个网页时,服务器就会返回 200 状态码。
  • 201 Created:请求成功,并且服务器创建了一个新的资源。通常用于 POST 请求提交数据后,服务器创建了新的记录。
  • 204 No Content:请求成功,但服务器没有返回任何响应正文。通常用于 DELETE 请求删除资源后,不需要返回任何内容。
  • 400 Bad Request:请求格式错误,服务器无法理解。通常是客户端提交的参数格式不正确。
  • 401 Unauthorized:请求需要用户认证。如果用户未登录,服务器会返回 401 状态码,要求用户登录。
  • 403 Forbidden:服务器拒绝请求,客户端没有权限访问该资源。即使登录了也无法访问。
  • 404 Not Found:请求的资源不存在。这是最常见的错误状态码,当我们输入了一个错误的 URL 时,服务器就会返回 404。
  • 500 Internal Server Error:服务器内部错误。通常是服务器代码有 bug,或者数据库连接失败等原因导致的。
  • 502 Bad Gateway:网关错误。当服务器作为代理或网关时,无法从上游服务器获取有效的响应。
  • 503 Service Unavailable:服务器暂时无法处理请求。通常是服务器过载或者正在维护。

3.3 仅包含重定向相关的状态码

在这里插入图片描述
308比较少用。


4 ~> 重定向机制

重定向是 HTTP 协议中一个非常重要的功能,它允许服务器告诉客户端:“你请求的资源不在这个地址,请到另一个地址去获取”。客户端收到重定向响应后,会自动向新的地址发起请求。

4.1 重定向的本质与实现

重定向的实现非常简单,只需要两个条件:

1、返回一个 3XX 系列的状态码;
2、在响应报头中添加一个Location字段,指定新的 URL 地址。

例如,要将所有请求重定向到腾讯网,只需要在响应中设置状态码为 302,并添加Location: https://www.qq.com/报头即可。客户端收到这个响应后,会自动跳转到腾讯网。

重定向可以分为站内重定向和站外重定向。站内重定向是指跳转到同一个网站的其他页面,例如将不存在的页面重定向到 404 页面;站外重定向是指跳转到其他网站的页面。

4.2 301 永久重定向与 302 临时重定向的区别

最常用的重定向状态码是 301 和 302,它们的核心区别在于语义和缓存行为:

  • 301 Moved Permanently(永久重定向):表示请求的资源已经被永久移动到了新的 URL 地址。浏览器和搜索引擎会缓存这个重定向结果,下次再访问原地址时,会直接跳转到新地址,而不会再向原地址发起请求。301 重定向适用于网站域名变更、页面 URL 重构等场景,它可以将原地址的搜索引擎权重转移到新地址。

  • 302 Found(临时重定向):表示请求的资源暂时被移动到了新的 URL 地址。客户端下次再访问原地址时,仍然会向原地址发起请求,服务器再决定是否重定向。302 重定向适用于用户登录成功后跳转到首页、网站临时维护等场景。

4.2.1 小故事理解

我们可以用一个生活中的例子来理解这个区别:

临时重定向就像你去一家火锅店吃饭,发现门口贴了个通知:“因施工临时搬到西门”。你这次会去西门吃饭,但下次还是会先去东门看看,如果通知还在,再去西门。

永久重定向就像火锅店门口贴了个通知:“因生意更好,永久搬到西门”。你下次就会直接去西门吃饭,不会再去东门了。

在实现重定向时,需要注意一个常见的问题:重定向次数过多。如果重定向的目标地址又会重定向回原地址,就会形成一个无限循环,浏览器会在重定向一定次数后(通常是 20 次)自动停止,并显示错误页面。


5 ~> HTTP 请求方法

HTTP 请求方法定义了客户端对服务器上的资源执行的操作类型。HTTP/1.1 协议定义了多种请求方法,但在实际开发中,99% 的场景都只使用 GET 和 POST 方法。

5.1 常见 HTTP 方法概览

  • GET:获取资源。这是最常用的方法,用于从服务器获取指定的资源。GET 方法应该是安全的和幂等的,即多次执行相同的 GET 请求,结果应该是一样的,并且不会对服务器上的资源产生任何修改。
  • POST:传输实体主体。用于向服务器提交数据,通常用于创建新的资源或者提交表单数据。POST 方法不是安全的,也不是幂等的,多次执行相同的 POST 请求可能会产生不同的结果。
  • PUT:传输文件。用于将请求正文中的文件保存到指定的 URL 位置。由于安全原因,大多数服务器都不会开启 PUT 方法。
  • HEAD:获得报文首部。与 GET 方法类似,但服务器只返回响应报头,不返回响应正文。用于确认 URL 的有效性以及资源的更新时间等信息。
  • DELETE:删除文件。用于删除指定 URL 位置的资源。同样由于安全原因,大多数服务器都不会开启 DELETE 方法。
  • OPTIONS:询问支持的方法。用于查询服务器对指定资源支持的 HTTP 方法。

补充:除了GET和POST方法,其它的我们大概率用不到。

5.2 GET 方法与 URI 传参

GET 方法不仅可以用于获取资源,还可以向服务器传递参数。GET 方法的参数是拼接在 URI 中的,格式为?key1=value1&key2=value2。例如/login?username=zhangsan&passwd=123456表示向/login地址提交了两个参数:username的值为zhangsanpasswd的值为123456

服务器端需要解析 URI 中的参数。具体实现步骤如下:

1、在解析请求行时,检查 URI 中是否包含?字符。
2、如果包含?,则将?左边的部分作为资源路径,右边的部分作为参数字符串。
3、将参数字符串按照&分割成多个键值对。
4、将每个键值对按照=分割成键和值,存储起来供后续处理使用。

GET 方法传参的优点是简单、方便,缺点是参数会暴露在 URL 中,不安全,并且 URL 的长度有限制(不同浏览器的限制不同,通常在 2KB 到 8KB 之间),不适合传递大量数据。

5.3 POST 方法与正文传参

POST 方法将参数放在请求正文中传递,而不是拼接在 URI 中。POST 方法的参数格式与 GET 方法类似,也是key1=value1&key2=value2,但需要在请求报头中设置Content-Type: application/x-www-form-urlencoded,告诉服务器请求正文的格式。

服务器端需要读取请求正文来获取参数。具体实现步骤如下:

1、检查请求报头中是否包含Content-Length字段,该字段的值表示请求正文的字节数。
2、如果包含Content-Length字段,则从请求报文中读取对应字节数的内容作为请求正文。
3、将请求正文按照&分割成多个键值对。
4、将每个键值对按照=分割成键和值,存储起来供后续处理使用。

POST 方法传参的优点是参数不会暴露在 URL 中,相对安全,并且没有长度限制,可以传递大量数据。缺点是实现稍微复杂一些。

在这里插入图片描述


6 ~> 动态交互实现

前面实现的静态资源服务器只能返回服务器上已经存在的文件,无法处理用户提交的数据,也无法实现动态内容。要实现动态交互,我们需要让服务器能够根据用户提交的数据执行相应的逻辑,并返回动态生成的内容。

6.1 前后端交互的本质

很多初学者会误以为前端提交的数据是交给 HTML 页面处理的,这是一个常见的误解。静态的 HTML 文件只是一个文本文件,它没有能力处理数据,也无法与数据库交互。

真正处理用户数据的是运行在服务器上的后端程序。前端通过 form 表单将数据提交给后端程序,后端程序接收到数据后,进行验证、处理、与数据库交互等操作,然后将处理结果返回给前端。

我们可以用寄快递的例子来理解这个过程:

  • form 表单就像是一个信封
  • input 标签中的数据就像是信件的内容
  • action 属性就像是收件人的地址
  • 真正的收件人是后端程序,而不是 HTML 页面

在实际开发中,action 属性通常填写的是一个后端接口的地址,例如/login/api/v1/auth等,而不是一个 HTML 文件的地址。

6.2 服务注册与路由机制

为了实现动态交互,我们需要在服务器中实现一个服务注册与路由机制。这个机制的核心思想是:将不同的 URL 路径映射到不同的处理函数,当客户端请求某个路径时,服务器自动调用对应的处理函数来处理请求。

具体实现步骤如下:

1、定义一个路由处理函数的类型,该函数接收两个参数:一个 HttpRequest 对象(包含请求的所有信息)和一个 HttpResponse 对象(用于构建响应)。

2、在 HttpServer 类中添加一个 unordered_map 成员变量,用于存储 URL 路径到处理函数的映射关系。

3、提供一个 Register 接口,允许上层代码将处理函数注册到指定的 URL 路径。

4、在处理请求时,先检查请求的路径是否已经注册了处理函数。如果已经注册,则调用对应的处理函数;如果没有注册,则按照静态资源的方式处理。

这种机制的优点是非常灵活,我们可以很方便地添加新的功能,只需要编写一个处理函数,然后注册到对应的 URL 路径即可,不需要修改服务器的核心代码。

6.3 完整的请求处理流程

现在,我们可以总结出一个完整的 HTTP 请求处理流程:

1、服务器接收到客户端发送的 HTTP 请求报文。

2、对请求报文进行反序列化,解析出请求行、请求报头和请求正文,构建一个 HttpRequest 对象。
检查请求的路径是否已经注册了处理函数:

3、如果已经注册,则调用对应的处理函数,将 HttpRequest 对象和一个空的 HttpResponse 对象传递给处理函数。处理函数执行相应的业务逻辑,填充 HttpResponse 对象的内容。

  • 如果没有注册,则按照静态资源的方式处理:读取对应路径的文件内容,如果文件存在,则构建一个 200 OK 的响应,将文件内容作为响应正文;
  • 如果文件不存在,则构建一个 404 Not Found 的响应,或者重定向到 404 页面。

4、对 HttpResponse 对象进行序列化,生成 HTTP 响应报文。

5、将响应报文发送给客户端。


7 ~> 实验与测试:理论与实践相结合

写代码不是目的,实践才是目的。

7.1 HttpProtocol.hpp

#ifndef __HTTP_PROTOCOL_HPP
#define __HTTP_PROTOCOL_HPP

#include <iostream>
#include <string>
#include <unordered_map>   // key_value
#include <sstream>
// 日志
#include "Logger.hpp"

// 怎么提取?分隔符 --> 我要把分隔符暴露出来
const std::string linesep = "\r\n";     // 空行
// 我需要一个报头的分隔符 
const std::string headersep = ": ";
// tuturoot
const std::string webroot = "tuturoot";
// 家目录
const std::string homepage = "index.html";
// 版本号
const std::string gdefault_version = "HTTP/1.0";    // 这个也可以配置文件化
// 如何把一个应答序列化?来一个空格描述符
const std::string gspace = " ";
// 资源后缀的分隔符
const std::string suffixsep = ".";
// GET请求参数分隔符 --> 在Request里面,把参数和方法分开
const std::string argsep = "?";

// 命名空间
using namespace LogModule;

// 示例:协议格式
// 前3行:带长度前缀的自定义协议格式("长度"\r\nJSON内容)
// "40"\r\n{"float":3.14, "int": 100, "s1": "helloworld", "s2": "hello bit"}
// "40"\r\n{"float":3.14, "int": 100, "s1": "helloworld", "s2": "hello bit"}
// "40"\r\n{"float":3.14, "int": 100, "s1": "helloworld", "s2": "hello bit"}
// 最后1行:纯JSON格式,没有长度前缀
// {"float":3.14, "int": 100, "s1": "helloworld", "s2": "hello bit"}

// HTTP协议底层就是TCP协议
// 也需要请求和应答
class HttpRequest
{
private:
    // 把请求行读出来
    int ReadOneLine(std::string &streamstr,std::string *line)   // \r\n
    {
        // 1. 在输入字节流中查找行分隔符 "\r\n" 的位置
        auto pos = streamstr.find(linesep);
        // 2. 如果没找到行分隔符,说明数据不完整,返回空字符串
        if(pos == std::string::npos)    // 整个字符串里没有分隔符
            // return std::string();   // 返回值字符串是空串
            return -1;  // 参数要匹配
        // 3. 从字节流开头截取到分隔符之前,得到一行完整内容
        *line = streamstr.substr(0,pos); // 截止到pos位置
        // 4. 从字节流中删除已经读取的这一行(包括 "\r\n" 分隔符),
        //    方便下次调用时读取下一行
        streamstr.erase(0,pos + linesep.size());    // 删的时候删到换行符
        //  // 5. 返回读取到的这一行
        // return line;
        return line->size();    // >=(表示截取成功;等于0的时候说明读到空串)
    }

    // 把stringstream以文件的形式写到网络
    void ParseReqLine(std::string &status_line)
    {
        std::stringstream ss(status_line);
        ss >> _method >> _uri >> _http_version;

        // 比如说资源是:/a/b/c.html or /
        // 这个资源会被拼接到http当中
        if(_uri == "/") // 请求根目录就返回一个首页
        {
            _path = webroot + _uri + homepage;  // tuturoot + / + index.html 
        }
        else
        {
            _path = webroot + _uri;    // tuturoot/a/b/c.html _uri = / + index.html(Web自己设置的根目录)
        }
        // /content.html?username=zhangsan&passwd=123456
        // 把method统一转大写什么的也可以,这里就直接大小写都写了
        if(_method == "GET" || _method == "get")
        {
            // ?:就是argsep,?右边就是我要的参数(带参了)
            // URI提取出来,我用的是path,直接在path里面找
            auto pos = _path.find(argsep);
            if(pos != std::string::npos)    // 双重否定表肯定,这里就是带?
            {
                // 参数和请求分开了
                _args = _path.substr(pos + argsep.size());
                _path = _path.substr(0, pos);   // 这样_path只是单纯保存了我需要的路径
            }
        }
        // 分析请求的资源的后缀!   // tuturoot + / + index.html tuturoot/image/Alice.png tuturoot/css/XXX.css
        // .是资源后缀分隔符
        // 字符串里要倒着查找,从右向左找
        auto pos = _path.rfind(suffixsep);
        if(pos == std::string::npos)    // 等于npos说明没找到
            _suffix = ".html";
        else    // 找到了。截取
            _suffix = _path.substr(pos);    // .css .html .jpg --> 得到后缀

        // 出问题了,登录页面一直404,打印检查一下 --> 有可能是没保存Login.html
        std::cout << "=======: PATH: " << _path << std::endl;
    }

    // 打印
    void PrintDebug()
    {
        std::cout << "reqline: " << _method << "#" << _uri << "#" << _http_version << std::endl;
        // 遍历headerkv
        for(auto &item : _header_kv)
        {
            std::cout << item.first << "->" << item.second << std::endl;
        }

        // 把空行打印出来
        std::cout << "blankline: " << _blankline << std::endl;  // 空行这里再加个换行
        // 我的body现在没什么东西,加点东西
        std::cout << "body: " << _body << std::endl; 
        // 打印资源后缀名
        std::cout << "suffix: " << _suffix << std::endl;
    }
    
    // 单独设计一个分割字符串的接口
    void SplitString(std::string &line,std::string *key,std::string *value,std::string sep = headersep)
    {
        // headersep设置为“:”,就是以:为分隔符
        auto pos = line.find(sep);
        if(pos == std::string::npos)    // 没有返回空串
            return;
        *key = line.substr(0,pos);
        *value = line.substr(pos + sep.size());
    }

public:
    HttpRequest()
    {}

    // 提供一个反序列化的接口
    void Deserialize(std::string &streamstr)
    {
        // 从网络当中传递一个字节流信息,然后转换成结构化信息
        // 1.读取第一行
        std::string status_line;
        int n = ReadOneLine(streamstr,&status_line);
        (void)n;

        // GET /a/b/c/d/e.html HTTP/1.1
        // GET / HTTP/1.1
        // 2.解析请求行
        ParseReqLine(status_line);
        // PrintDebug();

        // 3.提取请求报头
        n = 0;  // 第一行已经被移除了
        do
        {
            std::string line;
            n = ReadOneLine(streamstr,&line);
            if(n > 0)
            {
                std::string key,value;
                SplitString(line,&key,&value,headersep);
                // 都不为空说明取到了合法的key_value值
                if(!key.empty() && !value.empty())  
                {
                    _header_kv[key] = value;
                }
            }
            else if(n == 0)
            {
                _blankline = "\r\n";
                break;
            }
            else
            {
                LOG(LogLevel::DEBUG) << "bug???";
                break;
            }
        }while(n > 0);

        // 请求的正文是否存在
        if(_header_kv.find("Content-Length") != _header_kv.end())
        {
            // 不存在就什么都不做
            int len = std::stoi(_header_kv["Content-Length"]);
            // 前面都已经去掉了,这里就读到body了
            _body = streamstr.substr(0,len);
            // 请求只要有body,必然是参数 --> 稳妥一点,考虑大小写
            if(_method == "POST" || _method == "post")
            {
                _args = _body;
            }
            // _body = streamstr;
        }

        PrintDebug();
    }

    // 在Request里面做一个运算符重载(只读),重载一个中括号,因为原来获取URI的方式不太优雅
    // 这里引用不能引用常量字符串,把const和&去掉
    std::string operator [](const std::string &key) const   // 未来提供一个key就行
    {
        // 把一堆GET方法,封装从接口,以只读方式访问
        if(key == "method")
            return _method;
        else if(key == "uri")
            return _uri;
        else if(key == "httpversion")
            return _http_version;
        else if(key == "body")
            return _body;
        // else if(key == "uri")
        // return _uri;
        // 不要请求uri了,在当前代码中还存在像/a/b/c.html这样的特殊情况
        else if(key == "path")
            return _path;
        else if(key == "suffix")
            return _suffix;
        // 经过序列化之后
        else if(key == "args")
            return _args;
        else
        {
            auto iter = _header_kv.find(key);
            if(iter != _header_kv.end())
                return iter->second;
        }
        return std::string();   // 返回一个空串
    }

    ~HttpRequest()
    {}
private:
    // 请求方法、URI、HTTP版本、Key:[空格]Value,还有空行
    std::string _method;
    std::string _uri;   // /a/b/c.html   /(特殊情况)
    std::string _http_version;
    std::unordered_map<std::string,std::string> _header_kv;
    std::string _blankline;
    // 除了请求还要有一个请求的正文
    std::string _body;
private:
    std::string _path;
    std::string _args;
    std::string _suffix;
};

// 处理请求,构建应答
class HttpResponse
{
public:
    HttpResponse()  : _http_version(gdefault_version),_blankline(linesep)   // 版本和空行都有了
    {}
    ~HttpResponse() {}
    void Serialize(std::string *outstr)
    {
        // 状态行:空格为分隔符
        std::string status_line = _http_version + gspace + std::to_string(_status_code) + gspace + _status_code_desc + linesep;
        // 响应报头
        std::string headers;
        for(auto &item : _resp_headerkv)
        {
            std::string header = item.first + headersep + item.second + linesep;
            headers += header;
        }
        *outstr = status_line + headers + _blankline + _body;   // 状态行 + 响应报头 + 空行 + 正文
    }
    // 把正文内容拿进来
    void SetBody(const std::string &content)
    {
        _body = content;
    }
    // 状态码
    void SetCode(int code)
    {
        _status_code = code;
        // code和描述是一对一的
        _status_code_desc = Code2Desc(code);
        // // 文件长度字段可以不放在外面,因为添加_body的时候就可以这样
        // SetHeader("Content-Length"); // -->这里我还是放在外面吧
    }
    // 我需要一个设置报头的接口
    void SetHeader(const std::string &key, int value)
    {
        _resp_headerkv[key] = std::to_string(value);
    }
    // 报头当中的字段如果想去外部加可以这样
    void SetHeader(const std::string &key, const std::string &value)
    {
        _resp_headerkv[key] = value;
    }
private:
    std::string Code2Desc(int code)
    {
        switch(code)
        {
            // 直接映射
            // case 200:
            // return "OK";

        // 1xx: 信息响应
        case 100:
            return "Continue";
        case 101:
            return "Switching Protocols";
        case 102:
            return "Processing"; // WebDAV
        case 103:
            return "Early Hints";

        // 2xx: 成功
        case 200:
            return "OK";
        case 201:
            return "Created";
        case 202:
            return "Accepted";
        case 203:
            return "Non-Authoritative Information";
        case 204:
            return "No Content";
        case 205:
            return "Reset Content";
        case 206:
            return "Partial Content";
        case 207:
            return "Multi-Status"; // WebDAV
        case 208:
            return "Already Reported";
        case 226:
            return "IM Used";

        // 3xx: 重定向
        case 300:
            return "Multiple Choices";
        case 301:
            return "Moved Permanently";
        case 302:
            return "Found"; // 如果是302,就会自动拼接这个
        case 303:
            return "See Other";
        case 304:
            return "Not Modified";
        case 305:
            return "Use Proxy";
        case 306:
            return "Switch Proxy"; // 已废弃,但仍保留
        case 307:
            return "Temporary Redirect";
        case 308:
            return "Permanent Redirect";

        // 4xx: 客户端错误
        case 400:
            return "Bad Request";
        case 401:
            return "Unauthorized";
        case 402:
            return "Payment Required";
        case 403:
            return "Forbidden";
        case 404:
            return "Not Found";
        case 405:
            return "Method Not Allowed";
        case 406:
            return "Not Acceptable";
        case 407:
            return "Proxy Authentication Required";
        case 408:
            return "Request Timeout";
        case 409:
            return "Conflict";
        case 410:
            return "Gone";
        case 411:
            return "Length Required";
        case 412:
            return "Precondition Failed";
        case 413:
            return "Payload Too Large";
        case 414:
            return "URI Too Long";
        case 415:
            return "Unsupported Media Type";
        case 416:
            return "Range Not Satisfiable";
        case 417:
            return "Expectation Failed";
        case 418:
            return "I'm a teapot"; // 愚人节笑话,但常被实现
        case 421:
            return "Misdirected Request";
        case 422:
            return "Unprocessable Entity"; // WebDAV
        case 423:
            return "Locked";
        case 424:
            return "Failed Dependency";
        case 425:
            return "Too Early";
        case 426:
            return "Upgrade Required";
        case 428:
            return "Precondition Required";
        case 429:
            return "Too Many Requests";
        case 431:
            return "Request Header Fields Too Large";
        case 451:
            return "Unavailable For Legal Reasons";

        // 5xx: 服务端错误
        case 500:
            return "Internal Server Error";
        case 501:
            return "Not Implemented";
        case 502:
            return "Bad Gateway";
        case 503:
            return "Service Unavailable";
        case 504:
            return "Gateway Timeout";
        case 505:
            return "HTTP Version Not Supported";
        case 506:
            return "Variant Also Negotiates";
        case 507:
            return "Insufficient Storage"; // WebDAV
        case 508:
            return "Loop Detected";
        case 510:
            return "Not Extended";
        case 511:
            return "Network Authentication Required";

        // 未知状态码
        default:
            return "Unknown";
        }
    }

private:
    // 结构化字段
    std::string _http_version;                // HTTP协议版本,如 "HTTP/1.1"
    int _status_code;                         // 状态码,如 200(成功)、404(未找到)、500(服务器错误)
    std::string _status_code_desc;            // 状态码描述,如 "OK"、"Not Found"、"Internal Server Error"
    std::unordered_map<std::string,std::string> _resp_headerkv; // 响应头键值对,如 Content-Type、Content-Length
    std::string _blankline;                   // 空行,用于分隔响应头和响应体
    std::string _body;                        // 正文:响应体,如网页内容、JSON数据等
};

#endif

7.2 HttpServer.hpp

#ifndef __HTTPSERVER_HPP
#define __HTTPSERVER_HPP

// Http就不用写客户端了,因为是和浏览器连接,浏览器就是客户端
#include <iostream>
#include <string>
#include <memory>
#include <fstream>
#include <functional>
#include <unordered_map>
#include "TcpServer.hpp"
#include "Logger.hpp"
#include "HttpProtocol.hpp"

// 命名空间包一下
using namespace LogModule;

using route_t = std::function<void(const HttpRequest &req,HttpResponse &resp)>;

class HttpServer
{
public:
    // ===== 原来的代码(已注释)=====
    HttpServer(uint16_t port) 
    : _port(port),
     _tsvr(std::make_unique<TcpServer>(port))
    {}

    // 不想这样写就在类里面写这样一个接口
    std::string HandlerHttpRequest(std::string &streamstr)
    {
        // 1.报文完整性这里不管啦,不是不存在粘包问题,粘包问题是个小概率事件,这里先不考虑,默认报文是完整的
        // 2.反序列化
        HttpRequest httpreq;    // 服务端接收到的是一个请求
        httpreq.Deserialize(streamstr);

        // 3. 处理请求,构建应答:httpreq --> httpresp
        HttpResponse httpresp;

        if(NeedHandler(httpreq["uri"]))    // NeedHandler -> 处理了动态资源,用uri匹配路由!
        {
            _route[httpreq["uri"]](httpreq,httpresp);  // 用原始uri做路由key!
        }
        else
        {
            // 处理静态资源请求,构建应答:httpreq --> httpresp
            std::string filecontent = GetFileContentHelper(httpreq["path"]);

            std::string suffix = httpreq["suffix"]; // 得到请求资源的后缀
            if(filecontent.empty())
            {
                // httpresp.SetCode(302);
                // httpresp.SetHeader("Location", "/404.html");    // 要从根目录开始请求
            }
            else
            {
                httpresp.SetCode(200);
                httpresp.SetHeader("Content-Length",filecontent.size());    // 长度就是文件内容的大小
                httpresp.SetHeader("Content-Type",Suffix2Type(suffix));   // 资源后缀转成type
                httpresp.SetBody(filecontent);
            }
        }

        // 4.应答序列化:把结构化的字段变成字符串
        std::string httprespstr;
        httpresp.Serialize(&httprespstr);

        // 5.返回:返回应答的序列化字段
        return httprespstr;
    }

    // 回调有了
    void Run()
    {
        // _tsvr->Run();
        // 写一个lambda
        _tsvr->Run([this](std::string &streamstr)->std::string{
            return this->HandlerHttpRequest(streamstr);
        });
    }

    // route_t:方法路由
    void Register(std::string key,route_t handler)
    {
        _route[key] = handler;
    }

    ~HttpServer()
    {}
private:
    bool NeedHandler(const std::string &key)
    {
        return _route.find(key) != _route.end();
    }
    std::string GetFileContentHelper(const std::string &filename)
    {
        std::ifstream in(filename);
        if(!in.is_open())
        {
            return std::string();
        }
        in.seekg(0,in.end);
        int filesize = in.tellg();
        in.seekg(0,in.beg);

        std::string content;
        content.resize(filesize);
        in.read((char*)content.c_str(),filesize);
        in.close();

        return content;
    }
    // suffix: .html
    // return: text/html
    std::string Suffix2Type(const std::string &suffix)  // 可以设置到内部,但是暴露出来比较有仪式感
    {
        // if(suffix == ".html")
        //     return "text/html";

        // 使用静态映射表提高效率,避免每次调用都重新创建
        // static静态成员,只会初始化一次
        static const std::unordered_map<std::string, std::string> mime_map = {  
            // 一样才冲突,不一样不冲突
            // 文本类型
            {".html", "text/html"},
            {".htm", "text/html"},
            {".css", "text/css"},
            {".js", "application/javascript"},
            {".mjs", "application/javascript"},
            {".json", "application/json"},
            {".xml", "application/xml"},
            {".txt", "text/plain"},
            {".csv", "text/csv"},
            {".md", "text/markdown"},

            // 图片类型
            {".jpg", "image/jpeg"},
            {".jpeg", "image/jpeg"},
            {".png", "image/png"},
            {".gif", "image/gif"},
            {".bmp", "image/bmp"},
            {".webp", "image/webp"},
            {".svg", "image/svg+xml"},
            {".ico", "image/x-icon"},
            {".tiff", "image/tiff"},
            {".tif", "image/tiff"},

            // 字体类型
            {".woff", "font/woff"},
            {".woff2", "font/woff2"},
            {".ttf", "font/ttf"},
            {".otf", "font/otf"},

            // 视频类型
            {".mp4", "video/mp4"},
            {".webm", "video/webm"},
            {".ogv", "video/ogg"},
            {".avi", "video/x-msvideo"},
            {".mov", "video/quicktime"},
            {".mpeg", "video/mpeg"},
            {".mpg", "video/mpeg"},

            // 音频类型
            {".mp3", "audio/mpeg"},
            {".wav", "audio/wav"},
            {".ogg", "audio/ogg"},
            {".flac", "audio/flac"},
            {".m4a", "audio/mp4"},
            {".aac", "audio/aac"},

            // 应用类型
            {".pdf", "application/pdf"},
            {".zip", "application/zip"},
            {".rar", "application/vnd.rar"},
            {".7z", "application/x-7z-compressed"},
            {".tar", "application/x-tar"},
            {".gz", "application/gzip"},
            {".exe", "application/vnd.microsoft.portable-executable"},
            {".dll", "application/x-msdownload"},
            {".msi", "application/x-msi"},
            {".doc", "application/msword"},
            {".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"},
            {".xls", "application/vnd.ms-excel"},
            {".xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"},
            {".ppt", "application/vnd.ms-powerpoint"},
            {".pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation"},
            {".wasm", "application/wasm"},

            // 其他常见类型
            {".jsonld", "application/ld+json"},
            {".rss", "application/rss+xml"},
            {".atom", "application/atom+xml"},
            {".manifest", "text/cache-manifest"},
            {".map", "application/json"}, // source maps
            {".ts", "video/mp2t"},        // MPEG transport stream
            {".m3u8", "application/vnd.apple.mpegurl"}
        };

        auto it = mime_map.find(suffix);
        if (it != mime_map.end())
        {
            return it->second;
        }

        // 默认返回二进制流,或根据需求返回其他默认值
        return "application/octet-stream";
    }
private:
    // 端口号
    uint16_t _port;
    // 这里要有一个协议字段
    // std::unique_ptr<HttpProtocol> _protocol; // 不想这样写就在类里面写一个接口
    std::unique_ptr<TcpServer> _tsvr;
    // 注册服务
    std::unordered_map<std::string, route_t> _route; // 注册服务的容器
};

#endif

7.3 目录结构

在这里插入图片描述

7.4 效果演示

7.4.1 个人博客网站首页

在这里插入图片描述

7.4.2 404页面

在这里插入图片描述

7.4.3 a标签

在这里插入图片描述

7.4.4 视频(MP4格式)

在这里插入图片描述

7.4.5 登录和注册

在这里插入图片描述


总结

本文通过从零实现一个 C++ HTTP 服务器的过程,深入讲解了 HTTP 协议的核心原理。我们从最基础的请求响应报文结构开始,一步步实现了静态资源的读取与返回、状态码的处理、重定向功能,最终实现了支持 GET 和 POST 方法的动态交互服务器。

通过本文的学习,我们应该掌握以下重点内容:

1、HTTP 报文结构:HTTP 请求和响应都由四部分组成:请求行 / 状态行、报头、空行和正文。所有的通信都遵循这个严格的格式。

2、静态资源处理:Web 根目录用于隔离服务器文件系统,保证安全;必须使用二进制方式读取文件,避免内容被截断;通过文件后缀名映射 MIME 类型,设置正确的 Content-Type 报头;一个网页对应多个 HTTP 请求,可以使用图床和 CDN 减轻服务器压力。

3、HTTP 状态码:状态码分为五大类,分别表示不同的处理结果。常见的状态码有 200(成功)、404(资源不存在)、500(服务器内部错误)等。状态码和状态码描述是一一对应的。

4、重定向机制:重定向通过 3XX 状态码 + Location 报头实现。301 是永久重定向,会被浏览器和搜索引擎缓存;302 是临时重定向,每次都会请求原地址。

5、HTTP 请求方法:GET 方法通过 URI 传参,参数可见,长度有限制;POST 方法通过正文传参,参数不可见,长度无限制。99% 的 Web 场景都只使用这两个方法。

6、动态交互实现:静态 HTML 无法处理数据,真正处理数据的是后端程序。通过服务注册与路由机制,可以将不同的 URL 路径映射到不同的处理函数,实现灵活的动态交互功能。

HTTP 协议是 Web 开发的基础,只有深入理解了 HTTP 协议的工作原理,才能写出高质量的 Web 应用。本文实现的 HTTP 服务器虽然简单,但已经涵盖了 HTTP 协议的核心功能,你可以在此基础上继续扩展,例如添加 Cookie 和 Session 支持、HTTPS 支持、并发处理等功能,进一步加深对 HTTP 协议的理解。


结尾

uu们,本文的内容到这里就全部结束了,艾莉丝在这里再次感谢您的阅读!

艾莉丝努力练剑

C/C++ & Linux 底层探索者 | 一个正在努力练剑的技术博主


👀 【关注】 跟随我一起深耕技术领域,见证每一次成长。
❤️ 【点赞】 让优质内容被更多人看见,让知识传递更有力量。
【收藏】 把核心知识点存好,在需要时随时查、随时用。
💬 【评论】 分享你的经验或疑问,评论区一起交流避坑!

不要忘记给博主“一键四连”哦!

“今日练剑达成!”

“技术之路难免有困惑,但同行的人会让前进更有方向。”

结语:希望对学习Linux相关内容的uu有所帮助,不要忘记给博主“一键四连”哦!

往期回顾

【Linux网络】Linux 网络编程:HTTP(二)HTTP协议请求应答宏观格式(附代码演示)

🗡博主在这里放了一只小狗,大家看完了摸摸小狗放松一下吧!🗡
૮₍ ˶ ˊ ᴥ ˋ˶₎ა

在这里插入图片描述

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐