
💡Yupureki:个人主页
✨个人专栏:《C++》 《算法》《Linux系统编程》《高并发内存池》《MySQL数据库》
《个人在线OJ平台》《Linux网络编程》《CMake自动化构建工具》
🌸Yupureki🌸的简介:

目录
[1. 初识HTTP协议](#1. 初识HTTP协议)
[1.1 什么是HTTP协议?](#1.1 什么是HTTP协议?)
[1.2 认识URL](#1.2 认识URL)
[1.2.1 URL 和 HTTP 的关系](#1.2.1 URL 和 HTTP 的关系)
[1.2.2 URL的结构](#1.2.2 URL的结构)
[1.2.3 URL编码](#1.2.3 URL编码)
[1.2.3.1 核心规则](#1.2.3.1 核心规则)
[1.2.3.2 哪些字符需要编码?](#1.2.3.2 哪些字符需要编码?)
[1.2.3.3 具体编码流程](#1.2.3.3 具体编码流程)
[1.3 HTTP常见方法](#1.3 HTTP常见方法)
[1.3.1 GET方法](#1.3.1 GET方法)
[1.3.2 POST方法](#1.3.2 POST方法)
[1.3.3 PUT方法](#1.3.3 PUT方法)
[1.3.4 PATCH方法](#1.3.4 PATCH方法)
[1.3.5 DELETE方法](#1.3.5 DELETE方法)
[1.4 HTTP状态码](#1.4 HTTP状态码)
[1.4.1 2xx 成功类](#1.4.1 2xx 成功类)
[1.4.2 3xx 重定向类](#1.4.2 3xx 重定向类)
[1.4.3 4xx 客户端错误](#1.4.3 4xx 客户端错误)
[1.4.4 5xx 服务器错误](#1.4.4 5xx 服务器错误)
[1.5 HTTP版本](#1.5 HTTP版本)
[1.6 HTTP报头](#1.6 HTTP报头)
[1.6.1 通用报头](#1.6.1 通用报头)
[1.6.2 请求报头(客户端 → 服务器)](#1.6.2 请求报头(客户端 → 服务器))
[1.6.3 响应报头(服务器 → 客户端)](#1.6.3 响应报头(服务器 → 客户端))
[1.7 HTTP正文](#1.7 HTTP正文)
[1.7.1 正文可以是什么内容?](#1.7.1 正文可以是什么内容?)
[1.7.2 如何设置正文?](#1.7.2 如何设置正文?)
[1.7.3 如何处理正文?](#1.7.3 如何处理正文?)
[1.7.4 常见"坑"与注意事项](#1.7.4 常见“坑”与注意事项)
[1.8 完整的HTTP传输](#1.8 完整的HTTP传输)
[第一步:尝试直接访问个人资料页(无 Cookie)](#第一步:尝试直接访问个人资料页(无 Cookie))
[第四步:重定向回个人资料页(带 Cookie)](#第四步:重定向回个人资料页(带 Cookie))
[2. 实现简单的HTTP服务器](#2. 实现简单的HTTP服务器)
[2.1 总体规划](#2.1 总体规划)
[2.2 com.hpp](#2.2 com.hpp)
[2.3 socket.hpp](#2.3 socket.hpp)
[2.4 TcpServer.hpp](#2.4 TcpServer.hpp)
[2.5 http.hpp](#2.5 http.hpp)
[2.6 测试](#2.6 测试)
1. 初识HTTP协议
1.1 什么是HTTP协议?
虽然我们说,应用层协议是我们程序猿自己定的,但实际上,已经有大佬们定义了一些现成的,又非常好用的应用层协议,供我们直接参考使用,HTTP(超文本传输协议)就是其中之一。
在互联网世界中,HTTP是一个至关重要的协议。
HTTP 的全称是 超文本传输协议 (HyperText Transfer Protocol),它是互联网上应用最广泛的一种应用层协议 。简单说,HTTP 就是规定了浏览器(客户端)与服务器之间如何"对话"、如何传输数据的一套规则。
还是听不懂怎么办?没事,一图秒懂

我们手机,电脑上的浏览器,访问网页,用的就是HTTP协议
简单了解HTTP报文:

后面我们将一一介绍HTTP报文各结构的详细信息
1.2 认识URL
URL 的全称是 统一资源定位符 (Uniform Resource Locator),通俗说就是网址 。它是互联网上用来唯一标识和定位一个资源 的地址,就像每家每户的门牌号,你告诉浏览器这个"地址",它才知道去哪找并取回你想要的内容。
这就是一个URL
1.2.1 URL 和 HTTP 的关系
结合刚才的 HTTP:HTTP 负责"怎么传",而 URL 负责"找谁"和"传什么"。
当你在浏览器输入一个网址,比如 http://example.com/index.html,浏览器会:
-
从 URL 中拆出 协议 (HTTP)、服务器地址 (example.com)、资源路径(/index.html)
-
构建一个 HTTP 请求发过去,就像:
GET /index.html HTTP/1.1并带上Host: example.com
1.2.2 URL的结构
一个完整的 URL 可以拆成以下几个部分

-
协议(Scheme)
告诉客户端用什么方式访问资源。
常见:
http、https、ftp、mailto等。互联网上绝大部分是
http或https。 -
域名(Host)
服务器的"门牌号",可以是域名(如
example.com)或 IP 地址(如192.168.1.1)。域名会被 DNS 解析成 IP,才能找到物理服务器。
-
端口(Port)
服务器上具体"房间"的编号,用来区分不同的服务。
HTTP 默认端口是 80 ,HTTPS 是 443 。如果用了默认端口,浏览器会自动补上,地址栏通常不显示。一般正规的网址不会采用原始的IP:Port的方式显示在互联网上,而是将整个IP:Port替换成一个域名,例如www.baidu.com,原来可能是192.168.1.1:8080
-
路径(Path)
服务器上资源的具体位置,类似文件路径。
比如
/articles/2024/hello.html。如果没有写具体文件,服务器通常会返回默认页面(如index.html)。 -
查询参数(Query Parameters)
提供给服务器的额外信息,通常用来筛选、搜索、分页等。
以
?开头,多个参数用&分隔,格式为key=value。例子:
?search=HTTP&lang=zh代表告诉服务器"我搜索的关键词是 HTTP,语言是中文"。 -
片段标识符(Fragment)
用来定位资源内部的某个位置,以
#开头。比如
#section-1能让浏览器直接滚动到页面的该锚点。注意:
#后面的内容不会被发送到服务器,只在浏览器本地使用。
1.2.3 URL编码
URL编码 ,也叫百分号编码 ,本质就是把不符合URL安全要求的字符转义成允许传输的形式。因为URL在设计时只能用一小部分ASCII字符 ,而你又想在地址里传中文 、空格 或保留符号 (如 ?、&),就必须通过编码把它们"包裹"起来。
1.2.3.1 核心规则
规则非常直接:
-
将字符按一定字符编码 (几乎都是UTF-8)转换成字节序列。
-
每个字节前加一个百分号
%,后跟该字节的两位十六进制大写表示。
例如:
-
空格字符:UTF-8编码为
0x20→%20 -
中文字符"中":UTF-8编码为
E4 B8 AD→%E4%B8%AD
1.2.3.2 哪些字符需要编码?
URL字符分为几类,编码策略不同:
| 类别 | 包含字符 | 是否需要编码 | 说明 |
|---|---|---|---|
| 非保留字符 | A-Z a-z 0-9 - _ . ~ |
不需要 | 天生安全,直接写入URL |
| 保留字符 | : / ? # [ ] @ ! $ & ' ( ) * + , ; = |
视位置而定 | 作为分隔符时不编码 (如 ?key=value&a=b 中的 ? = &);作为普通数据时必须编码 |
| 其他字符 | 中文、空格、emoji、" < > \ ^ ````` { ` |
}` 等 | 必须编码 | 要么不合法,要么易引起歧义 | |
注意 :即使是保留字符,一旦它不承担"结构分隔"职能,就一定要编码。
比如你传的标题是 A & B,在查询参数里就必须写成 title=A%20%26%20B,这里的 & 被编码为 %26,否则会被误认为是参数间隔符。
1.2.3.3 具体编码流程
以在查询参数中传递一个中文标题 你好?&name=test 为例。
1. 客户端生成URL
-
原始参数值:
你好?&name=test -
确定字符编码(UTF-8)
-
对每个字符判断:
-
你→ 非ASCII → 转UTF-8字节E4 BD A0→%E4%BD%A0 -
好→%E5%A5%BD -
?和&是保留字符,但在这里是值的一部分,必须转 →%3F和%26 -
=在被包含的&name=test里(这里属于值的内容,但name=test的=怎么办?其实我们需要整体看这个值的语义:原始数据就是整个字符串你好?&name=test,其中=也属于字面量,必须编码为%3D)
-
-
最终编码结果:
%E4%BD%A0%E5%A5%BD%3F%26name%3Dtest
2. 拼接到URL中
假设参数名为 q,URL为:
https://example.com/search?q=%E4%BD%A0%E5%A5%BD%3F%26name%3Dtest
此时的 ? 和后面的 &、= 都被正确编码,不会破坏URL结构。
3. 浏览器/客户端发送HTTP请求
请求行不会变,依然是这个编码后的URL。
4. 服务器接收与解码
服务器拿到查询字符串部分,会:
-
识别出参数
q的原始编码值:%E4%BD%A0%E5%A5%BD%3F%26name%3Dtest -
进行百分号解码 :把每个
%XX转换回对应的字节,再用UTF-8解码为字符串。 -
得到原始值:
你好?&name=test
1.3 HTTP常见方法
HTTP 定义了一组请求方法 (也叫"动作"或"谓词"),用来告诉服务器客户端想对资源做什么。
结合URL 和编码,一个完整的请求就像:
POST /api/users HTTP/1.1
Host: example.com
这里的 POST 就是方法,它和 URL 共同决定了操作的对象和动作。
目前开发中最常接触的是这 5 个,分别对应"增删改查":
| 方法 | 语义 | 典型场景 | 是否有请求体 | 安全 | 幂等 |
|---|---|---|---|---|---|
| GET | 获取资源 | 访问网页、查询数据、请求图片/JSON | 无(参数在 URL 里) | ✅ | ✅ |
| POST | 创建资源 / 提交数据 | 提交表单、登录、上传文件、创建新订单 | 有 | ❌ | ❌ |
| PUT | 整体更新(替换)资源 | 更新用户所有信息、上传文件到固定路径 | 有 | ❌ | ✅ |
| PATCH | 部分修改资源 | 只改密码、只改昵称 | 有 | ❌ | ❌(通常不是幂等) |
| DELETE | 删除资源 | 删除一篇文章、删除用户 | 一般无(也可能有) | ❌ | ✅ |
补充概念:
-
安全:指不会改变服务器状态,纯粹读取。GET 是安全的,POST/PUT/DELETE 不是。
-
幂等:重复执行多次,结果和执行一次相同。GET、PUT、DELETE 是幂等的;POST 不是(重复提交可能创建多个资源)。
1.3.1 GET方法
语义 :请求获取指定的资源 。
本质 :纯粹的查询操作,不应产生任何副作用(服务器状态不能变)。
典型请求示例:
GET /api/articles/123 HTTP/1.1
Host: blog.example.com
Accept: application/json
常见成功响应:
-
200 OK,响应体包含资源数据 -
304 Not Modified,配合协商缓存,告诉客户端可以直接用本地缓存
1.3.2 POST方法
语义 :向指定资源提交数据 ,请求服务器进行处理(通常是创建新资源)。
本质 :它不是幂等的,意味着多次执行同一请求会产生多个资源(比如提交两次订单,就会生成两笔)。
典型请求(创建用户):
POST /api/users HTTP/1.1
Host: api.example.com
Content-Type: application/json
{"name": "张三", "email": "zhang@example.com"}
常见成功响应:
-
201 Created是最符合语义的,并通常带上Location头指向新资源的 URL,如:
Location: /api/users/321 -
200 OK也常见,特别是处理结果直接返回时。 -
202 Accepted表示请求已接受,但处理还没完成(异步任务)。
1.3.3 PUT方法
语义 :用请求体完整地替换指定资源 (如果不存在,则创建)。
本质 :幂等,你发一次和多次,最终资源状态都是一样的(因为每次都整量覆盖)。
典型请求(更新整个用户):
PUT /api/users/123 HTTP/1.1
Host: api.example.com
Content-Type: application/json
{"name": "李四", "email": "lisi@example.com", "age": 30}
常见响应:
-
200 OK(返回更新后的资源) -
204 No Content(更新成功,不返回内容) -
如果创建了新资源(比如不存在,服务器允许创建),返回
201 Created
1.3.4 PATCH方法
语义 :对资源进行部分更新 ,请求体中只包含要改动的内容。
本质 :通常不是幂等的(但可以设计为幂等,取决于补丁格式)。
两种常见补丁格式:
-
JSON Merge Patch (RFC 7396)
直接发送要合并的 JSON 对象,例如只发送
{"email": "new@ex.com"},后端合并到已有对象。简单但容易混淆"设为空"与"不修改"。 -
JSON Patch (RFC 6902)
通过一组操作指令描述修改,更精确:
PATCH /api/users/123 HTTP/1.1
Content-Type: application/json-patch+json[
{ "op": "replace", "path": "/email", "value": "new@ex.com" },
{ "op": "remove", "path": "/age" }
]
常见响应 :200 OK、204 No Content。
1.3.5 DELETE方法
语义 :请求服务器删除指定资源。
本质 :幂等,因为删一次它就不在了,再删仍然是不在(虽然第二次返回 404,但资源状态结果一致)。
典型请求:
DELETE /api/users/123 HTTP/1.1
Host: api.example.com
常见响应:
-
200 OK(附带删除成功的信息) -
204 No Content(删除成功,无返回内容) -
202 Accepted(已接受,异步处理删除) -
404 Not Found(资源不存在,可视为幂等的体现)
1.4 HTTP状态码
HTTP 状态码是服务器在响应中返回的一个 三位数字 ,用来简明扼要地告诉客户端请求的结果。
它配合之前了解的请求方法(GET、POST 等),构成了"请求-处理-结果"的完整闭环。不论你是打开网页、调用 API 还是抓取数据,读懂状态码是排查问题的第一步。
状态码的分类(一共五类)
| 类别 | 范围 | 含义 | 典型场景 |
|---|---|---|---|
| 1xx | 100 -- 199 | 信息响应 | 请求已接收,继续处理 |
| 2xx | 200 -- 299 | 成功 | 请求被成功接收、理解、处理 |
| 3xx | 300 -- 399 | 重定向 | 需要进一步操作才能完成请求 |
| 4xx | 400 -- 499 | 客户端错误 | 请求包含语法错误或无法完成(你发的不对) |
| 5xx | 500 -- 599 | 服务器错误 | 服务器在处理请求时失败了(服务器内部问题) |
1.4.1 2xx 成功类
200 OK
-
含义:请求成功,服务器返回了请求的数据。
-
适用:GET 拿到内容,PUT 更新成功返回资源,PATCH 修改成功等。
-
注意:POST 创建资源有时返回 201 更符合语义,但 200 也没错。
201 Created
-
含义 :请求成功且创建了新资源。
-
典型 :POST 创建用户、文章后,响应应携带
Location头部,指向新资源的 URL。 -
示例 :
HTTP/1.1 201 Created
Location: /api/users/456
204 No Content
-
含义 :请求成功,但响应主体没有内容(空 Body)。
-
典型:DELETE 删除成功、PUT 更新成功且无需返回数据。
-
后果:浏览器收到 204 会停留在原页面,不会跳转或刷新,常用于 AJAX 操作。
206 Partial Content
-
含义 :服务器只返回了请求的部分内容(常用于分块下载 / 断点续传)。
-
条件 :客户端发送
Range: bytes=1000-头部,服务器响应 206,并提供Content-Range头部。
1.4.2 3xx 重定向类
301 Moved Permanently
-
含义 :资源永久移动 到了新的 URL(响应里
Location指明)。 -
浏览器行为 :GET 方法会自动跳转,并且搜索引擎会更新索引,替换掉旧 URL;POST 转为 GET 跳转(多数浏览器如此)。
-
场景:网站迁移、HTTP 升级 HTTPS、规范化 URL。
302 Found (之前叫临时重定向)
-
含义 :资源临时放到另一个 URL。
-
浏览器行为:每次仍然用原 URL 请求,但 POST 可能被改为 GET(历史上不安全的实现),容易造成链路混乱。
-
已不推荐用 302 处理 POST 重定向,现在有更明确的 307/308。
303 See Other
-
含义:请求的响应应该由另一个 URL 的 GET 请求获取。
-
典型场景:PRG 模式 ------ POST 创建资源后,避免刷新时重复提交,服务器返回 303 重定向到详情页。
-
强制行为 :不论原请求是 POST 还是 GET,客户端必须用 GET 方法请求新地址。
304 Not Modified
-
含义 :客户端已经有缓存,资源未修改,直接使用本地缓存。
-
条件 :客户端发起了条件请求 (带
If-None-Match或If-Modified-Since)。 -
响应体:无(没有消息体),只返回头,节省流量。
-
性质:归类在 3xx,但更像"成功-使用缓存"的优化指令。
307 Temporary Redirect
-
含义 :临时重定向,且不允许更改请求方法。
-
区别 :对于 POST 请求,301/302 可能被浏览器改成 GET 跳转;307 保证方法不变,POST 仍然以 POST 跳转(但需要用户确认安全)。
-
场景:临时维护,POST 请求严格保持。
308 Permanent Redirect
-
含义 :永久重定向,且方法不允许更改,类似 307(但永久)。
-
场景:PUT 更新接口迁移到新地址,仍然保持 PUT 方法跳转。
总结记忆:
-
永久:301 / 308(前者可能改方法,后者不改变方法)
-
临时:302 / 307(同上)
-
303 强制改成 GET
1.4.3 4xx 客户端错误
400 Bad Request
-
含义:请求存在语法错误,服务器无法理解。
-
常见原因:请求格式错误(如 JSON 解析失败)、参数校验不通过、URL 编码异常、头部过长等。
-
排查:检查发出去的报文,特别是 Content-Type 和 Body 是否匹配。
401 Unauthorized
-
含义 :当前请求需要用户认证(没登录或令牌无效)。
-
响应头 :必须包含
WWW-Authenticate字段,告诉客户端怎样认证。 -
混淆:不要与 403 搞混 ------ 401 是"你没验证身份",403 是"验证过了,但没权限"。
403 Forbidden
-
含义 :服务器理解了请求,但拒绝执行,你无权访问(即使登录了)。
-
场景:普通用户访问管理员后台、IP 被封禁、权限不够。
404 Not Found
-
含义 :服务器上找不到请求的资源。
-
经验:有时服务器为了安全故意返回 404 而不是 403,以隐藏资源存在性。
405 Method Not Allowed
-
含义:请求方法对目标资源不被支持(如用 GET 调用了一个只接受 POST 的接口)。
-
响应头 :
Allow: GET, POST会明确指出支持的方法。
409 Conflict
-
含义 :请求与当前资源状态冲突,常出现在资源竞态场景。
-
示例:多个请求同时修改同一文件产生版本冲突;创建用户时用户名已存在。
422 Unprocessable Entity (常用在 WebDAV / REST API)
-
含义 :格式正确但语义有错,通常指数据校验失败。
-
示例:注册时密码太短、邮箱格式错误;POST 的字段缺少必填项。
-
与 400 区别:400 是请求本身错误(如 JSON 不合法),422 是请求内容合法但不满足业务规则。
429 Too Many Requests
-
含义 :客户端在给定时间内发送了太多请求,触发限流。
-
响应 :可能携带
Retry-After头,说明多少秒后可以重试。
1.4.4 5xx 服务器错误
500 Internal Server Error
-
含义:服务器遇到了意外情况,无法完成请求。
-
原因:代码抛出未处理异常、配置错误、权限问题等。
-
唯一信息:这是个"万能错误",开发者必须查服务器日志才能定位。
502 Bad Gateway
-
含义 :作为网关或代理的服务器,从上游服务器收到了无效响应。
-
场景:Nginx 反向代理的后端应用挂了,返回空内容;或者 PHP-FPM 超时无输出。
503 Service Unavailable
-
含义:服务器暂时无法处理请求(过载或停机维护)。
-
对比 500:503 是知道问题且是暂时的。
-
响应头 :通常会加
Retry-After告诉多久后恢复。
504 Gateway Timeout
-
含义 :网关/代理等待上游服务器响应超时。
-
场景:后端请求数据库或外部 API 太慢,代理主动断开。
-
排查方向:后端性能问题、慢查询、外部依赖超时。
但服务器即便炸了,一般也不会返回5xx错误码。
因为你告诉用户自家服务器炸了,这不是把自家丑事告诉别人了吗?并且遇到一些心怀不轨的人看见你服务器炸了,会继续攻击你的服务器,导致情况更严重
因此服务器炸了,一般也返回4xx错误码,告诉是你用户的问题
1.5 HTTP版本
HTTP 协议从诞生至今经历了多个版本的演进,每一次大版本更新都为了解决上一代的核心痛点------尤其是速度 和连接效率。
目前共有五个主要版本:0.9、1.0、1.1、2、3(HTTP/1.1 是统治时间最长的经典版本,HTTP/2 和 HTTP/3 已是现代主流)。
一般如今使用最广泛的仍是HTTP1.1版本,我们处理最多的也是这个版本
1.6 HTTP报头
HTTP 报头(Headers)是请求和响应中的元数据,用来传递额外信息:告诉服务器你是谁、要什么格式、怎么缓存、有什么认证凭据;或者告诉浏览器返回的是什么、怎么处理。
1.6.1 通用报头
1. Connection
控制当前连接的选项,最常见:keep-alive(维持连接)和 close(不再重用)。
-
在 HTTP/1.1 默认 keep-alive,HTTP/1.0 默认 close。
-
还用于声明升级协议,如
Upgrade: h2c。
2. Transfer-Encoding
告知消息体使用了什么传输编码。
最重要的取值 :chunked ------ 分块传输,常用于动态生成的响应,服务器可以先返回一部分内容。
Date
表示消息生成的日期时间。
所有服务器响应都应包含,格式必须是 HTTP-date,如 Date: Tue, 15 Nov 2023 08:12:31 GMT。
1.6.2 请求报头(客户端 → 服务器)
1. Host(HTTP/1.1 必须)
指定服务器的域名和端口(若为非默认)。
因为一个 IP 可托管多个站点,必须用 Host 区分。
例:Host: www.example.com:8080
2. User-Agent
标识客户端软件和版本,如浏览器、爬虫。
例:User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0
3. Accept 系列(内容协商)
-
Accept:客户端能处理的 MIME 类型,如text/html,application/json。 -
Accept-Encoding:支持的压缩算法,如gzip, deflate, br(Brotli)。 -
Accept-Language:期望的语言,如zh-CN,zh;q=0.9,en;q=0.8。
4. Referer
来源页面的 URL。当从 A 页面点链接到 B 时,B 会收到 Referer: A 的网址。
常用于防盗链、溯源分析,注意拼写错误(Referer 而非 Referrer)。
5. Authorization
向服务器传递认证信息。
-
基础认证:
Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==(Base64 编码的用户名:密码) -
Bearer Token:
Authorization: Bearer eyJhbGciOiJIUzI1...
6. Cookie(重要)
将之前服务器 Set-Cookie 保存的 Cookie 回传,用于保持会话状态。
例:Cookie: session_id=abc123; theme=dark
为什么有时候你登陆了某些网站,你退出再次打开,会保持登陆状态?这就是你的浏览器保存了Cookie

当你首次登陆时,服务器会传递一个特定的Cookie给你的浏览器,浏览器会保存下来
这个Cookie一般包含:账户和密码
你的浏览器访问服务器时,会顺便传递Cookie,服务器获取Cookie中的账户和密码,自动登陆成功
当然Cookie一般也有过期时间,过了一段时间,你会发现登陆网站时不会自动登陆,这是因为Cookie过期了
7. Cache-Control
发出缓存指令,控制缓存行为。常见取值:
-
no-cache:强制验证缓存新鲜度后才能用。 -
no-store:完全不缓存。 -
max-age=3600:缓存有效秒数。
1.6.3 响应报头(服务器 → 客户端)
1. Content-Type
正文 MIME 类型及字符集。
例如:Content-Type: application/json; charset=utf-8
绝对重要,否则浏览器可能乱码或错误解析(比如把 JSON 当文本)。
常见Mime类型:MIME 类型 | 菜鸟教程
2. Content-Length
正文的字节长度(未压缩前)。
用于响应接收方知道何时读完数据,也是 HTTP/1.1 持久连接依赖的界定方式之一(除非分块传输)。
3. Content-Encoding
报体使用的压缩编码,告知客户端需要解压。
如 Content-Encoding: gzip。注意与 Transfer-Encoding 的区别:前者是"内容本身的编码",后者是"传输方式的编码"。
4. Set-Cookie
服务器向客户端写入 Cookie。可携带过期时间、域、路径、安全标志等:
Set-Cookie: sid=abc123; HttpOnly; Secure; SameSite=Lax; Path=/
5. Location
用于 3xx 重定向或 201 Created,指定新资源的 URL。
例:Location: /users/456
1.7 HTTP正文
HTTP 的正文(Message Body)就是请求或响应中真正传输的数据 。如果说请求行、状态行和头部像是快递信封上的地址和说明,那正文就是信封里装的货物。它可以承载任意形式的内容:网页、JSON、表单数据、图片、视频、二进制文件等。
1.7.1 正文可以是什么内容?
HTTP 正文本身只是一串字节流 ,并不限定类型。它的具体含义由 Content-Type 头部来声明,客户端/服务器根据这个头部才知道怎么去解析它。
以下是最常见的几种正文类型:
| 类别 | Content-Type 示例 | 用途 |
|---|---|---|
| 纯文本 | text/plain |
简单文本消息 |
| HTML | text/html |
网页主体 |
| JSON | application/json |
API 数据交互(最常见的 REST 格式) |
| XML | application/xml 或 text/xml |
SOAP、RSS、配置数据 |
| 表单数据(URL 编码) | application/x-www-form-urlencoded |
传统表单提交,格式类似 URL 查询串 key1=val1&key2=val2 |
| 表单数据(多部分) | multipart/form-data |
文件上传,可能包含多个部分,每部分有独立头部 |
| 二进制数据 | application/octet-stream |
通用的二进制流,常用于文件下载 |
| 图片/视频/音频 | image/png, video/mp4, audio/mpeg |
媒体资源 |
注意:并不是所有请求/响应都有正文:
-
GET 请求没有正文(参数全在 URL)。
-
HEAD 请求的响应没有正文。
-
204 No Content 或 304 Not Modified 响应也没有正文。
1.7.2 如何设置正文?
处理和解析正文的关键在于理解几个重要头部。
1. Content-Type ------ 决定内容格式
它告诉接收方:"我发送的是什么类型的数据,字符集是什么。"
例如:
cpp
Content-Type: application/json; charset=utf-8
接收方据此选择解析器:JSON 解析器、XML 解析器、HTML 渲染引擎等。
如果缺失,浏览器可能会尝试猜测(MIME sniffing),但这是不安全的,现代浏览器会尽力避免,最好明确指定。
2. Content-Length ------ 标记正文大小
声明正文的字节数 (未压缩时)。
接收方可以据此判断数据是否接收完整,尤其在持久连接中,必须知道一个响应的边界才能复用连接。
如果正文是动态生成的,长度事先未知,可以采用分块传输。
3. Transfer-Encoding: chunked ------ 分块传输
当无法提前知道总长度时,服务器会将正文分成多个"块"发送,每个块前标有本块的十六进制大小,最后以一个大小为 0 的块结束。
接收方需要边收边拼,等收到零长块才认为传输完成。这与 Content-Length 是两种互斥的界定方式。
4. Content-Encoding ------ 压缩标记
正文可能经过压缩(gzip、deflate、brotli),头会标明:
cpp
Content-Encoding: gzip
处理流程 :先根据 Content-Encoding 解压,再根据 Content-Type 解析。
注意,这里的压缩是"内容编码",不是分块传输编码。
1.7.3 如何处理正文?
1. 客户端处理响应正文
浏览器会:
-
根据响应的
Content-Type决定行为:
text/html→ 渲染页面;image/png→ 显示图片;application/json→ 不直接显示,可能在调试工具中查看。 -
如果响应是下载文件,浏览器会看
Content-Disposition头(如attachment; filename="data.pdf")来触发下载。 -
对于 JavaScript,使用
fetch或XMLHttpRequest时,我们会调用相应方法:javascriptconst response = await fetch('/api/data'); const data = await response.json(); // 自动根据 Content-Type 解析 JSON const text = await response.text(); // 读取为纯文本 const blob = await response.blob(); // 读取为二进制 Blob
2. 服务器处理请求正文
服务器接收到 POST/PUT/PATCH 等请求时,需要从请求流中读出正文,并根据 Content-Type 解析:
-
URL 编码表单 :读取字节流,按
&拆分成键值对,再做 URL 解码。 -
JSON:合并字节流成字符串,用 JSON 解析器转换为对象。
-
多部分表单:解析 boundary 分隔的各个部分,处理文件字段和普通字段。
-
原始二进制:可原样保存为文件或交给进一步处理。
现代 Web 框架通常有中间件 或内建功能 自动解析,例如 Node.js 的 express.json(),express.urlencoded(),或 Python Flask 的 request.get_json()。我们只需在代码中访问解析后的对象,不必手动处理流。
1.7.4 常见"坑"与注意事项
-
客户端忘记设置
Content-Type如果 POST 提交 JSON 而不加
Content-Type: application/json,服务器不知道如何解析,可能报 400 或 415(Unsupported Media Type)。 -
Content-Type与实际内容不匹配声称发
application/json却送了纯文本,服务器解析 JSON 会失败,返回 400 Bad Request。 -
压缩链条
某些代理或 CDN 可能会自动添加
Content-Encoding: gzip,而你读取时没解压,会导致乱码。好在浏览器和fetch等会自动处理透明解压。 -
大文件上传与超大正文
放在内存会爆,必须使用流式处理,后端按块读写磁盘。
multipart/form-data能够有效处理文件边界。 -
安全考虑
-
验证内容长度:防止恶意超大请求耗尽内存(设置请求体大小限制)。
-
检查 Content-Type :不要仅凭扩展名或
Content-Type信任文件,上传后应读取魔数检查真实类型。 -
避免 XSS :如果响应
Content-Type不当,如将用户上传的 HTML 显示为text/html,可能导致 XSS。
-
1.8 完整的HTTP传输
我们用一个受保护的网站个人资料页的场景,把 HTTP 方法、状态码、头部、Cookie、正文、重定向、持久连接等串联起来,展示一次完整的、有上下文的 HTTP 交互。
假设环境 :用户使用浏览器,网站
https://example.com(已建立 TCP + TLS 连接,HTTP/1.1 持久连接)。
第一步:尝试直接访问个人资料页(无 Cookie)
用户在地址栏输入 https://example.com/profile,回车。
浏览器构造请求:
javascript
GET /profile HTTP/1.1
Host: example.com
User-Agent: Mozilla/5.0 ... Chrome/120
Accept: text/html,application/xhtml+xml
Accept-Language: zh-CN,zh;q=0.9
注意:因为没登录,没有 Cookie 头部。
服务器处理:检测到用户未携带有效的会话 Cookie,需要鉴权。
服务器响应:
javascript
HTTP/1.1 302 Found
Location: /login
Set-Cookie: session=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/
Date: Sun, 03 May 2026 10:00:00 GMT
Content-Length: 0
-
302 Found:临时重定向到登录页。
-
Set-Cookie 清除了可能残留的过期
session。 -
Content-Length: 0:302 通常没有正文。
浏览器收到 302,自动跟随重定向,发起新请求。
第二步:获取登录页面
javascript
GET /login HTTP/1.1
Host: example.com
User-Agent: ... (同上)
Accept: text/html,...
由于上一步清除了 Cookie,且 /login 不需要认证,服务器直接返回登录页面 HTML。
服务器响应:
javascript
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 1524
Cache-Control: no-store
Date: Sun, 03 May 2026 10:00:01 GMT
<!DOCTYPE html>
<html lang="zh">
<head><title>登录</title></head>
<body>
<form method="post" action="/login">
<input name="username" type="text" />
<input name="password" type="password" />
<button type="submit">登录</button>
</form>
</body>
</html>
-
Content-Type:告诉浏览器这是 HTML,用 UTF-8 解码。
-
Cache-Control: no-store:安全起见,登录页不缓存。
此时,浏览器的 TCP 连接仍保持打开(默认定 Connection: keep-alive)。
第三步:用户提交登录表单
用户输入凭据(用户名 alice,密码 secret123),点击提交。浏览器构造一个 POST 请求,注意因为一开始没有设置 Content-Type,HTML 表单默认使用 application/x-www-form-urlencoded 编码。
请求:
javascript
POST /login HTTP/1.1
Host: example.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 32
Origin: https://example.com
Referer: https://example.com/login
Accept: text/html,...
Cookie: (无)
username=alice&password=secret123
-
正文采用了 URL 编码(特殊字符已编码,这里只是示例)。
-
Content-Length: 32:标记正文长度。
-
Origin 头用于 CORS,这里同站,但会发。
服务器处理 :解析正文(自动 URL 解码),得到用户名和密码。验证通过后,服务器生成一个会话标识符(比如 sess_abc123xyz),并在存储中关联用户 alice。
服务器响应:
javascript
HTTP/1.1 303 See Other
Location: /profile
Set-Cookie: session=sess_abc123xyz; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=3600
Date: Sun, 03 May 2026 10:00:05 GMT
Content-Length: 0
-
303 See Other :强制浏览器用 GET 方法请求
Location提供的地址,避免刷新时重复提交表单(PRG 模式)。 -
Set-Cookie :将
sessionCookie 写入浏览器,标记HttpOnly(防 XSS 读取)、Secure(仅 HTTPS)、SameSite=Lax(防 CSRF)、Max-Age=3600秒(持久 Cookie)。 -
正文为空。
第四步:重定向回个人资料页(带 Cookie)
浏览器收到 303,自动用 GET 向 /profile 发起请求,因为 SameSite=Lax 且它是顶级导航,浏览器会自动携带刚才写入的 Cookie。
请求:
javascript
GET /profile HTTP/1.1
Host: example.com
Cookie: session=sess_abc123xyz
Accept: text/html,...
(在请求里,只有名称和值,没有属性。)
服务器处理 :收到请求,读取 Cookie 头并找到 session=sess_abc123xyz,到会话存储中验证,确认属于用户 alice,登录有效。生成个人资料页面 HTML。
服务器响应:
javascript
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 1820
Cache-Control: private, no-cache
ETag: "h7d93jf8"
Date: Sun, 03 May 2026 10:00:05 GMT
<!DOCTYPE html>
<html lang="zh">
<head><title>个人资料</title></head>
<body>
<h1>欢迎,alice</h1>
<p>您的邮箱:alice@example.com</p>
...
</body>
</html>
-
Cache-Control: private, no-cache 表示内容仅供此用户,且每次必须验证(因为要确保登录状态)。
-
ETag 可用于后续条件请求。
-
浏览器收到
200 OK和 HTML,开始渲染页面,并可能继续请求图片、CSS 等静态资源,这些请求都会自动携带 Cookie,持续认证。
2. 实现简单的HTTP服务器
2.1 总体规划

一个HTTP服务器需要包含以下模块
- 底层TCP服务器:TCP服务器接收到浏览器发送的请求后,需要执行特定的处理,这个处理就是对HTTP协议的处理
- HTTP Request:TCP服务器接受到的数据,我们当作Request请求处理,一个HTTP Request包含以下结构,我们需要从字符串中提取这些结构
- 请求方法
- URL
- HTTP版本
- 报头
- 正文
- HTTP Response:服务器解析完Request后,根据请求数据做出相应的处理,并制作Response返回给客户端,一个Response包含以下结构
- HTTP版本
- 状态码
- 状态码描述
- 报头
- 正文
因此我们需要用C++的类来封装这些方法
2.2 com.hpp
com.hpp是一个公共头文件,其他的模块都需要包含这个头文件
cpp
#pragma once
#include "logstrategy.hpp"
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <cstring>
#include "InetAddr.hpp"
#include <cstdlib>
#include <functional>
#include <unistd.h>
#include <string>
#include <jsoncpp/json/json.h>
#include <memory>
#include <sys/wait.h>
#define CONV(x) ((struct sockaddr*)(&x))
#define DEFAULT_BACKLOG 8
#define MAXNUM 1024
#define DEFAULT_PORT 8080
#define DEFAULT_SOCKFD -1
#define DEFAULT_IP "127.0.0.1"
enum ExitCode
{
NORMAL = 0,
SOCKET,
BIND,
LISTEN,
ACCEPT,
FORMAT,
CONNECT,
FORK
};
enum ResultCode
{
OK = 0,
};
class nocopy{
public:
nocopy()
{}
~nocopy()
{}
nocopy(const nocopy&) = delete;
const nocopy& operator=(const nocopy&) = delete;
};
2.3 socket.hpp
socket.hpp是一个封装socket套接字的头文件
cpp
#pragma once
#include "com.hpp"
class Socket
{
public:
Socket()
{}
~Socket()
{}
virtual void create_socket() = 0;
virtual void Bind(uint16_t) = 0;
virtual void Listen(int) = 0;
virtual std::shared_ptr<Socket> Accept(InetAddr&) = 0;
virtual bool Connect(const std::string&,const uint16_t&) = 0;
virtual int Recv(std::string&) = 0;
virtual void Send(const std::string&) = 0;
virtual int get_sockfd() = 0;
void InitTcpServer(uint16_t port = DEFAULT_PORT,int backlog = DEFAULT_BACKLOG)
{
create_socket();
Bind(port);
Listen(backlog);
}
void InitTcpClient(std::string ip = DEFAULT_IP,uint16_t port = DEFAULT_PORT)
{
create_socket();
Connect(ip,port);
}
};
class TcpSocket : public Socket
{
private:
using func_t = std::function<void()>;
public:
TcpSocket(int sockfd = DEFAULT_SOCKFD)
:_sockfd(sockfd)
{}
void create_socket() override
{
_sockfd = socket(AF_INET,SOCK_STREAM,0);
if(_sockfd < 0)
{
logger(LogLevel::FATAL)<<"socket error";
exit(ExitCode::SOCKET);
}
logger(LogLevel::INFO)<<"sockect create success : "<<_sockfd;
}
void Bind(uint16_t port)override
{
InetAddr addr(port);
int n = bind(_sockfd,CONV(addr.get_addr()),sizeof(addr.get_addr()));
if(n < 0)
{
logger(LogLevel::FATAL)<<"bind error";
exit(ExitCode::BIND);
}
logger(LogLevel::INFO)<<"bind success";
}
void Listen(int backlog)override
{
int n = listen(_sockfd,backlog);
if(n < 0)
{
logger(LogLevel::FATAL)<<"listen error";
exit(ExitCode::LISTEN);
}
logger(LogLevel::INFO)<<"listen success";
}
std::shared_ptr<Socket> Accept(InetAddr& addr)override
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int sockfd = accept(_sockfd,CONV(peer),&len);
if(sockfd < 0)
{
//logger(LogLevel::FATAL,__LINE__,__FILE__)<<"accept error";
return nullptr;
}
InetAddr tmp(peer);
addr = tmp;
logger(LogLevel::INFO)<<"accpet success";
std::shared_ptr<Socket> p = std::make_shared<TcpSocket>(sockfd);
return p;
}
bool Connect(const std::string& ip,const uint16_t& port)override
{
InetAddr addr(ip,port);
int n = connect(_sockfd,CONV(addr.get_addr()),sizeof(addr.get_addr()));
if(n < 0)
{
logger(LogLevel::FATAL)<<"connect error";
return false;
}
logger(LogLevel::INFO)<<"connect success";
return true;
}
int Recv(std::string& out)override
{
char buffer[MAXNUM];
ssize_t n = recv(_sockfd,buffer,sizeof(buffer) - 1,0);
if(n > 0)
{
buffer[n] = '\0';
out += buffer;
return n;
}
else if(n == 0)
return 0;
else
return -1;
}
void Send(const std::string& out)override
{
//logger(LogLevel::INFO)<<"send "<<std::to_string(_sockfd)<<" "<<out;
send(_sockfd,out.c_str(),out.size(),0);
//logger(LogLevel::INFO)<<"success";
}
void Start()
{
}
int get_sockfd()override
{
return _sockfd;
}
private:
int _sockfd;
};
2.4 TcpServer.hpp
TcpServer.hpp在socket.hpp的基础上,封装成TCP服务器
cpp
#pragma once
#include "socket.hpp"
class TcpServer : public nocopy
{
private:
using func_t = std::function<void(std::shared_ptr<Socket>&,InetAddr&)>;
public:
TcpServer(uint16_t port,func_t func)
:_port(port),_ioserver(func),_listensock(std::make_shared<TcpSocket>())
{}
void init(int backlog = DEFAULT_BACKLOG)
{
_listensock->InitTcpServer(_port,backlog);
_listensockfd = _listensock->get_sockfd();
}
void run()
{
_isrunning = true;
while(_isrunning)
{
InetAddr addr;
std::shared_ptr<Socket> sock = _listensock->Accept(addr);
if(sock == nullptr)
continue;
pid_t pid = fork();
if(pid > 0)
{
close(sock->get_sockfd());
waitpid(pid,nullptr,0);
}
else if(pid == 0)
{
if(fork() > 0)
exit(ExitCode::NORMAL);
close(_listensock->get_sockfd());
_ioserver(sock,addr);
}
else
{
logger(LogLevel::FATAL)<<"fork error";
exit(ExitCode::FORK);
}
}
}
private:
uint16_t _port;
int _listensockfd;
bool _isrunning = false;
std::shared_ptr<Socket> _listensock;
func_t _ioserver;
};
2.5 http.hpp
http.hpp是一个专门封装http处理方法的头文件,其中包含:HttpServer,HttpRequest,HttpResponse
HttpServer:
HttpServer需要提供TCP服务器接收到数据后,对数据的处理方法
具体处理方法:将数据解析成Request,经过处理后返还Response
cpp
class HttpServer
{
public:
HttpServer(uint16_t port)
: _server(std::make_unique<TcpServer>(port, [this](std::shared_ptr<Socket> &sock, InetAddr &addr)
{ this->handler_http_request(sock, addr); }))
{
}
void init()
{
_server->init();
}
void run()
{
_server->run();
}
static void handler_http_request(std::shared_ptr<Socket> &sock, InetAddr &addr)
{
std::string in;
sock->Recv(in);
logger(LogLevel::INFO)<<"get a request from "<<addr.get_string();
HttpRequest req;
req.Deserialize(in);
HttpResponse rep;
rep.MakeReponse(req.get_uri());
std::string out = rep.Serialize();
sock->Send(out);
}
private:
std::unique_ptr<TcpServer> _server;
};
HttpRequest:
HttpRequest需要将数据解析成对应的结构
cpp
struct Util
{
static bool ReadOneLine(std::string &line, std::string &out, const std::string &sep)
{
int pos = line.find(sep);
if (pos == std::string::npos)
return false;
out = line.substr(0, pos);
line.erase(0, pos + sep.size());
return true;
}
static int FileSize(const std::string& file)
{
std::ifstream f(file, std::ios::binary);
if(!f.is_open())
return -1;
f.seekg(0,f.end);
int filesize = f.tellg();
f.seekg(0,f.beg);
f.close();
return filesize;
}
static bool ReadFile(const std::string& file,std::string& out)
{
int filesize = FileSize(file);
if(filesize > 0)
{
std::ifstream f(file);
if(!f.is_open())
return false;
out.resize(filesize);
f.read((char*)out.c_str(),filesize);
f.close();
return true;
}
else
return false;
}
};
class HttpRequest
{
public:
bool Deserialize(std::string &reqstr)
{
std::string req = reqstr;
#ifdef Debug
logger(LogLevel::DEBUG) << "get a http request: " << req;
#endif
if (!prase_request_line(req) || !prase_request_head(req) || !prase_blank_line(req) || !prase_text(req))
return false;
return true;
}
std::string get_uri()
{
return _uri;
}
private:
bool prase_request_line(std::string &req)
{
std::string reqline;
if (!Util::ReadOneLine(req, reqline, glinespace))
return false;
std::stringstream s(reqline);
s >> _method >> _uri >> _version;
if (_uri == "/")
_uri = webroot + _uri + homepage;
else
_uri = webroot + _uri;
#ifdef Debug
logger(LogLevel::DEBUG) << "_method: " << _method;
logger(LogLevel::DEBUG) << "_uri: " << _uri;
logger(LogLevel::DEBUG) << "_version: " << _version;
#endif
return true;
}
bool prase_request_head(std::string &req)
{
std::string header;
int pos = req.find(glinespace + glinespace);
if (pos == std::string::npos)
return false;
header = req.substr(0, pos + glinespace.size());
req.erase(0, pos + glinespace.size());
std::string line;
while (Util::ReadOneLine(header, line, glinespace))
{
int pos = line.find(glinesep);
if (pos == std::string::npos)
break;
_headers[line.substr(0, pos)] = line.substr(pos + glinesep.size());
}
#ifdef Debug
logger(LogLevel::DEBUG) << "headers:";
for (auto &it : _headers)
{
logger(LogLevel::DEBUG) << it.first << glinesep << it.second;
}
#endif
return true;
}
bool prase_blank_line(std::string &req)
{
int pos = req.find(glinespace);
if (pos == std::string::npos)
return false;
_blankline = req.substr(0, pos);
req.erase(0, pos + glinespace.size());
#ifdef Debug
logger(LogLevel::INFO) << "blankline: " << _blankline;
#endif
return true;
}
bool prase_text(std::string &req)
{
_text = req;
#ifdef Debug
logger(LogLevel::DEBUG) << "text: " << _text;
#endif
return true;
}
std::string _method;
std::string _uri;
std::string _version;
std::unordered_map<std::string, std::string> _headers;
std::string _blankline;
std::string _text;
};
HttpResponse:
HttpResonse需要返回给客户端
cpp
class HttpResponse
{
public:
std::string Serialize()
{
std::string status_line = _version + gspace + std::to_string(_code) + gspace + _desc + glinespace;
std::string headers;
for(auto & it : _headers)
{
headers = headers + it.first + glinesep + it.second + glinespace;
}
#ifdef Debug
logger(LogLevel::DEBUG)<<"response : "<< status_line + headers + _blankline + _text;
#endif
return status_line + headers + _blankline + _text;
}
void MakeReponse(std::string uri)
{
int filesize = 0;
if(Util::ReadFile(uri,_text))
{
_targetfile = uri;
_desc = "OK";
set_code(200);
}
else
{
_desc = "FATAL";
uri = webroot + "/404.html";
_targetfile = uri;
set_code(404);
Util::ReadFile(uri,_text);
}
filesize = Util::FileSize(uri);
set_header("Content-Type",get_suffix(uri));
set_header("Content-Length",std::to_string(filesize));
}
private:
std::string get_suffix(std::string targetfile)
{
auto pos = targetfile.rfind(".");
if(pos == std::string::npos)
return "text/html";
std::string suffix = targetfile.substr(pos);
if(suffix == ".html" || suffix == ".htm")
return "text/html";
else if(suffix == ".jpg")
return "image/jpeg";
else if(suffix == "png")
return "image/png";
else
return "";
}
void set_header(const std::string &key, const std::string &value)
{
if (_headers.find(key) != _headers.end())
return;
_headers[key] = value;
}
void set_code(int code)
{
_code = code;
}
std::string _version = "HTTP/1.0";
int _code;
std::string _desc;
std::unordered_map<std::string, std::string> _headers;
std::string _blankline = glinespace;
std::string _text;
std::string _targetfile;
};
index.html:
我们假设浏览器只访问wwwroot/index.html这个网页
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<p>hello world</p>
</body>
</html>
2.6 测试
启动http服务器后,在本地浏览器上输入:127.0.0.1:8080/index.html
