🔥 星光编译者 · 个人主页
📚 学习专栏: 《C/C++ 成长笔记》 · 《Linux 实践手册》 · 《数据结构与算法》
🌄 向云端飞扬,编译属于自己的代码星河。
☕ 写在开篇
你好,这里是 星光编译者。
这里记录我在 C/C++、Linux、数据结构与算法 学习中遇到的真实问题、亲手验证过的代码,以及那些容易被忽略的实现细节。
比起简单罗列结论,我更愿意从问题出发,把一个知识点的来由讲清楚,把"为什么会这样"和"应该怎样解决"说明白,让每一次踩坑都沉淀成可以复用的经验。
如果这篇记录能帮你少绕一点路,或让某个模糊的地方忽然变得清晰,那么这次分享便有了意义。愿我们在一次次阅读、编译与调试中稳步向前,慢慢搭起属于自己的技术世界。

🔥 本文定位:接着自定义协议与序列化,看看浏览器和 Web 服务器遵守的通用约定。先读懂线上报文,再理解页面为什么能显示出来。
💡 学习目标:拆解 URL;区分百分号编码与表单编码;识别 HTTP/1.1 请求、响应和正文边界;掌握 GET、POST、HEAD、OPTIONS 与常见状态码。
📌 阅读范围 :以 HTTP/1.1 为主。配套服务器与实验文件在下篇
code/中;Cookie/Session 与 HTTPS 只交代衔接,不合并后面的两份加餐 PDF。
上一篇我们自己规定了长度前缀和 JSON 字段:客户端怎样封装,服务器就怎样解析。浏览器却不可能为每一个网站临时更换一套私有协议。它需要一个双方都熟悉的消息格式,也需要统一的方法、状态码与字段含义。
HTTP 就提供了这样的约定。理解它的第一步,并不是背下几十个请求头,而是把"网址、请求、响应、正文"放回一轮真实通信中。
文章目录
- 一、从浏览器访问页面说起
- [二、把 URL 拆成有意义的部分](#二、把 URL 拆成有意义的部分)
- 三、百分号编码到底编码了什么
- [四、HTTP 请求与响应的报文结构](#四、HTTP 请求与响应的报文结构)
- 五、空行之后怎样判断正文结束
- [六、GET 与 POST 的区别和表单实验](#六、GET 与 POST 的区别和表单实验)
- [七、HEAD、OPTIONS 与其他方法](#七、HEAD、OPTIONS 与其他方法)
- 八、读懂状态码而非只看页面
- 九、重定向为什么会产生第二次请求
- [十、用开发者工具与 curl 串起整条链路](#十、用开发者工具与 curl 串起整条链路)
一、从浏览器访问页面说起
1.1 浏览器收到的不只是一段 HTML
在地址栏输入 http://127.0.0.1:9090/,浏览器会根据 URL 确定连接目标,然后发送 HTTP 请求。服务器读取请求,决定返回什么,再把响应写入连接。

服务器发回的数据大致分成两类:一类是控制信息,例如状态码、正文类型、正文长度;另一类才是浏览器最终处理的内容,例如 HTML、图片、JSON 或普通文本。
浏览器先读控制信息,再决定怎样解释正文。相同的字节,配上 text/plain 可能作为文字显示,配上 text/html 则可能按网页解析。因此"我发送了一串 HTML"与"我返回了正确的 HTTP 响应"是两件事。
1.2 HTTP 与 TCP 各自负责什么
对本文的 HTTP/1.1 实验来说,TCP 负责可靠、有序地传输字节流;HTTP 负责解释这些字节:请求哪个资源、采用什么方法、处理结果如何、正文怎样定界。
TCP 不认识 GET,也不会因为看到 404 就判断连接出了故障。一个完整的 404 响应,完全可以通过正常的 TCP 连接传输。
HTTP 的名称虽然叫"超文本传输协议",正文并不限于超文本。下载压缩包、请求接口 JSON、加载图片,都可以使用 HTTP。媒体类型和应用规则决定内容含义。
1.3 一张网页可能对应多个请求
最初请求 / 得到 HTML;HTML 又引用 /logo.svg,浏览器便继续请求图片。它还可能请求样式、脚本和站点图标 favicon.ico。
所以服务端连续打印几次请求,并不一定是浏览器重复打开了页面。排查时应先比较目标路径,分清主文档和附属资源。浏览器是否请求某个图标,还会受到页面声明、缓存和具体实现影响,不能把一次观察当成固定次数。
二、把 URL 拆成有意义的部分
2.1 用一个完整例子拆解
text
http://example.com:9090/docs/index.html?q=C%2B%2B#intro

| 部分 | 示例 | 作用 |
|---|---|---|
| scheme | http |
说明使用的方案 |
| host | example.com |
目标主机名 |
| port | 9090 |
显式指定端口 |
| path | /docs/index.html |
标识资源路径 |
| query | q=C%2B%2B |
查询部分,其内部字段格式由应用约定 |
| fragment | intro |
片段标识,常用于页面内定位 |
主机名和可选端口属于 authority。本例没有用户名、密码等 userinfo;日常 HTTP URL 也不应拿 userinfo 作为网页登录方案。
? 开始查询,# 开始片段。q=...&page=... 是常见的查询参数写法,但通用 URI 语法本身并没有规定所有 query 都必须是键值表。RFC 3986:URI 语法
2.2 请求行中通常不是完整 URL
浏览器直接向源站请求时,常见的请求行是:
http
GET /docs/index.html?q=C%2B%2B HTTP/1.1
Host: example.com:9090
路径和 query 放在请求目标里,目标主机由 Host 表达。片段 #intro 留在客户端处理,不随这个请求目标发送给服务器。
因此,不能把请求行里的第二项永远称为"完整网址"。更准确的名字是 request-target,请求目标 。经过正向代理时,还可能使用包含方案与主机的 absolute-form;OPTIONS * 又是另一种形式。本文服务器只实现面向源站的路径形式,以及 OPTIONS *。
2.3 80 是默认端口,不是唯一端口
http 的默认端口是 80,https 的默认端口是 443。实验选择 9090,只要客户端 URL 明确写出 :9090,仍然能使用 HTTP。
端口号帮助操作系统区分服务端点,协议格式由通信双方使用的规则决定。把服务改绑到 9090,不会自动变成另一种应用层协议。
同理,看到端口 443 也不能只凭数字证明当前字节一定是 TLS;协议识别还要结合实际通信。学习阶段把"默认约定"和"技术上只能如此"分开,会减少很多误解。
三、百分号编码到底编码了什么
3.1 编码的单位是字节
URL 中的某些字符具有分隔作用。如果一个查询参数的值本来就包含 & 或 +,必须避免它被误当成结构的一部分。
百分号编码的基本形式是 %HH:一个百分号,加上表示一个字节的两位十六进制数字。对于非 ASCII 字符,先按选定的字符编码得到字节,再逐字节编码。本文网页与表单使用 UTF-8。
| 原始内容 | 字节 | 百分号形式 |
|---|---|---|
+ |
2B |
%2B |
| 空格 | 20 |
%20 |
网 |
UTF-8:E7 BD 91 |
%E7%BD%91 |
络 |
UTF-8:E7 BB 9C |
%E7%BB%9C |

这里不能简单理解为"把中文字符的 Unicode 码点转成十六进制,再每两位加百分号"。字符编码与百分号表示是两个步骤。换一种字符编码,得到的字节序列也可能不同。
3.2 加号什么时候表示空格
对 application/x-www-form-urlencoded 表单,空格通常编码为 +,字面量加号则编码为 %2B。所以字段值 C++ 网络 可以变成:
text
q=C%2B%2B+%E7%BD%91%E7%BB%9C
但在通用 URL 语法中,不能见到任何 + 都改成空格。是否应用这条规则,取决于正在处理的组件与编码约定。WHATWG URL:表单编码
解码时也要小心顺序。表单解码应把原始 + 转为空格,再处理百分号字节;如果先把 %2B 解成 +,然后又把所有 + 变成空格,就会把字面量加号弄丢。
3.3 为什么不要对整个 URL 盲目编码
/ 可以分隔路径段,? 可以引出 query,& 可以分隔表单字段。对单个参数值编码时,通常要保护其中具有结构含义的字符;对整个 URL 直接套用同一规则,却可能把必要的分隔符也一起转义。
实际工作中应使用对应组件的编码函数,而不是把字符串到处做同一种"URL 编码"。反向解码也只执行约定次数;%252F 解一次是 %2F,解两次才是 /,两者可能改变路径解释。
百分号编码让字节能按约定出现在 URL 或表单中,它不是加密。知道编码规则的人可以恢复原始内容。
四、HTTP 请求与响应的报文结构
4.1 请求由请求行、头部、空行与可选正文组成
下面把控制字符写成可见的 \r\n,方便检查。真正发送时,它们应当是 CR、LF 两个实际字节。
text
POST /echo HTTP/1.1\r\n
Host: 127.0.0.1:9090\r\n
Content-Type: text/plain\r\n
Content-Length: 7\r\n
\r\n
hello!!
第一行包含方法、请求目标和版本,三者之间使用空格。后续字段按 字段名: 字段值 表达,每行以 CRLF 结束。最后一个头部行的 CRLF,加上空行本身的 CRLF,形成 \r\n\r\n。

字段名不区分大小写,例如 Content-Length 和 content-length 表达同一个字段。字段值则需要遵循各字段自己的规则,不能把整份头部随便转成小写。方法名也区分大小写,标准方法使用 GET、POST 等写法。
4.2 响应第一行换成处理结果
text
HTTP/1.1 200 OK\r\n
Content-Type: text/plain; charset=utf-8\r\n
Content-Length: 7\r\n
Connection: close\r\n
\r\n
hello!!
状态行包含版本、三位状态码和原因短语。200 是程序判断语义的关键;OK 是辅助说明。不能靠某个英文短语的拼写判断是否成功。
请求中的 Host 说明访问目标;响应中的 Content-Type 说明返回内容类型。并非所有字段都适合同时放在两个方向。请求头和响应头可以共享一部分字段,但各自有使用条件。
4.3 HTTP/1.1 是文本头部,不代表正文只能是文本
我们可以在终端直接读懂首行与大部分头部,所以常说 HTTP/1.1 报文具有文本形式。但正文可以是 PNG、压缩包,也可以包含 \0。
接收正文时必须根据实际字节数保存,不能使用遇到 NUL 就结束的 C 字符串操作。std::string 能保存 NUL;strlen(body.c_str()) 却只能算到第一个 NUL。这与上一篇 TCP 自定义协议里的问题完全一致。
同样,HTTP/2 和 HTTP/3 使用不同的线上帧格式。本文的逐行解析规则只针对 HTTP/1.x,不能因为它们同样叫 HTTP,就直接用 \r\n\r\n 去拆所有版本。
五、空行之后怎样判断正文结束
5.1 找到空行只完成了一半
假设请求头已经声明 Content-Length: 100,当前缓存里却只有头部和 20 字节正文。虽然找到了头部结束标记,仍然缺少 80 字节。
程序应先识别首行与头部,再根据消息定界规则收齐正文。一次 recv 可能只读到半个头,也可能同时读到正文甚至下一条请求。这些读取分组不会替 HTTP 划分消息。
5.2 Content-Length 计算字节,不计算汉字个数
如果正文是 UTF-8 的 你好,它占 6 字节,不能填写 Content-Length: 2。长度不包含状态行、头部、分隔空行,也不包含 C 字符串为方便存储而保留的终止符。
cpp
std::string body = "hello!!";
std::string length = std::to_string(body.size()); // 7
只要 body 保存的是实际要发送的字节,size() 就能用于本例的长度计算。如果正文经过压缩,应针对实际传送的编码后内容确定对应长度,不能仍拿原始文本长度填报头。
5.3 有正文不一定有 Content-Length
PDF 用 Content-Length 解释正文边界,适合入门,但不是完整规则。HTTP/1.1 还可以使用分块传输编码;某些响应也可以通过关闭连接定界。
更要先识别特殊语义:HEAD 响应不发送正文,1xx、204 和 304 响应也不能按普通正文去读取。请求在没有适用的传输编码和长度字段时,通常按没有消息体处理,不能一律读到 EOF。RFC 9112:消息体长度
本文配套服务器只接受 Content-Length 定界的请求,对 Transfer-Encoding 明确返回错误。如果两者同时出现,直接拒绝并关闭连接。这样可以把教学范围写清楚,避免假装实现了所有 HTTP/1.1 解析规则。
六、GET 与 POST 的区别和表单实验
6.1 GET 请求资源的表示
http
GET /search?q=linux HTTP/1.1
Host: 127.0.0.1:9090
GET 的语义是获取目标资源的表示。读取文章、搜索页面、查询接口都可能使用它。搜索参数常放在 query,但不能据此把 GET 定义成"参数一定在 URL 的请求"。
GET 请求内容没有通用定义的语义,部分实现会拒绝,所以不要把 GET 正文当成通用查询接口的约定。需要查询参数时,先按照接口约定构造 URL。
6.2 POST 提交内容供资源处理
POST 常用于提交表单或创建业务对象。正文可以是表单数据、JSON 或其他内容;服务器根据目标资源和 Content-Type 处理。

| 比较点 | GET | POST |
|---|---|---|
| 典型目的 | 获取表示 | 提交内容供处理 |
| HTML 表单字段位置 | 通常进入 query | 通常进入正文 |
| 安全方法 | 是 | 否 |
| 幂等方法 | 是 | 不保证 |
| 缓存 | 常用 | 在满足相应条件时也可能被缓存 |
| 机密性 | 依赖 HTTPS 等保护 | 同样依赖 HTTPS 等保护 |
"安全方法"指客户端请求的语义不要求修改资源状态,服务器记访问日志不因此违反 GET 的安全语义。"幂等"指相同请求重复执行的预期效果,与执行一次相同;它不保证响应字节完全一致,也不表示永远不会失败。RFC 9110:方法属性
6.3 一个字段,两种提交方式
下篇附带的页面使用这两张表单:
html
<form action="/search" method="get">
<input name="q" value="C++ 网络">
<button>查询</button>
</form>
<form action="/echo" method="post">
<input name="q" value="C++ 网络">
<button>提交</button>
</form>
输入框的 name 决定字段名;只有 value 而没有合适的名字,不会按这里的预期产生 q=... 字段。页面声明 UTF-8,浏览器按表单规则序列化字段。
GET 表单的目标会类似 /search?q=C%2B%2B+...;POST 表单把编码后的字段放进正文,并使用 application/x-www-form-urlencoded。配套 /echo 返回的是原始编码正文,方便观察,并没有把百分号编码恢复成中文。
6.4 两个流传很广的误区
第一,"POST 数据不在地址栏,所以安全"。HTTP 明文连接里的 URL、头部和正文都可能被观察;POST 只是换了消息位置。即便用了 HTTPS,也不应把敏感信息随意放进可能进入历史记录、日志或分享链接的 URL。
第二,"GET 只能传几 KB,POST 没有大小限制"。URL、请求头、正文大小都可能受到浏览器、服务器、代理和业务配置的限制。HTTP 方法本身不能替我们保证无限容量;服务器应明确声明并执行自己的上限。
七、HEAD、OPTIONS 与其他方法
7.1 HEAD 的响应没有正文
HEAD 与 GET 的语义相近,但服务器不能发送响应正文。它适合检查资源元数据,例如类型、长度和修改信息。
bash
curl -I --http1.1 http://127.0.0.1:9090/
如果响应带有 Content-Length,这个值可以描述相应 GET 响应的正文长度。它不是让客户端在 HEAD 后继续等待这么多字节。实际实现中,某些只有生成正文时才能计算的字段也允许省略。
因此,不能用"响应声明长度大于零"反推一定要读取正文;请求方法同样参与响应定界。
7.2 OPTIONS 可以查询通信选项
bash
curl -i --http1.1 -X OPTIONS http://127.0.0.1:9090/echo
配套实验会返回 Allow。OPTIONS /echo 面向具体目标,而 OPTIONS * 面向服务器整体,两者作用范围不同。
浏览器的 CORS 预检也使用 OPTIONS,但跨域许可还需要 Origin、Access-Control-Request-Method 及相应响应字段。返回一个 Allow,不等于已经配置好了跨域访问。
PDF 的 nginx 实验得到 405,说明该目标在当时配置下没有支持这个方法。它不意味着"HTTP 不支持 OPTIONS",也不能保证换一台 nginx 得到完全相同结果。
7.3 PUT 与 DELETE 不是固定的文件系统操作
PUT 通常表达创建或替换目标资源的状态;DELETE 请求移除目标资源与当前功能之间的关联。服务器可以把资源映射到数据库、对象存储或其他业务系统,并不一定直接执行磁盘文件覆盖、删除。
PUT、DELETE 按规范属于幂等方法。某次 DELETE 第一次返回成功、第二次返回 404,仍可能满足"最终删除效果相同"的幂等含义。接口设计应遵循方法语义,而不是只拿方法名当任意标签。
原资料方法表还列到 TRACE、CONNECT 等名称。TRACE 用于消息回环诊断,CONNECT 用于建立隧道,它们有各自的处理约定,本例未实现。表中出现某个方法名,不代表所有 Web 资源都必须允许它;实际能力应通过规范、服务配置与目标资源共同判断。
八、读懂状态码而非只看页面
8.1 第一位先表达类别

| 类别 | 主要含义 | 例子 |
|---|---|---|
| 1xx | 信息性响应 | 100 Continue |
| 2xx | 请求成功处理 | 200、201、204 |
| 3xx | 完成请求可能涉及后续动作 | 301、302、304 |
| 4xx | 请求侧条件或内容有问题 | 400、401、403、404、405 |
| 5xx | 服务端或上游处理失败 | 500、502、503、504 |
1xx 通常还不是最终响应;204 没有正文;304 用于条件请求中的缓存验证,也没有响应正文。不能把所有 3xx 都理解成"浏览器跳去另一个页面"。
8.2 常见错误码应该怎样区分
| 状态码 | 名称 | 重点 |
|---|---|---|
| 400 | Bad Request | 请求格式等有问题 |
| 401 | Unauthorized | 缺少适用的认证凭据,通常伴随认证挑战 |
| 403 | Forbidden | 服务端理解请求,但拒绝执行 |
| 404 | Not Found | 当前未找到资源,或服务端不愿公开其存在 |
| 405 | Method Not Allowed | 当前目标不支持该方法,响应应提供 Allow |
| 500 | Internal Server Error | 服务端发生意外处理错误 |
| 502 | Bad Gateway | 网关或代理从上游得到无效响应 |
| 503 | Service Unavailable | 服务暂不可用,例如过载或维护 |
| 504 | Gateway Timeout | 网关或代理等待上游响应超时 |
PDF 一处把 504 写成 Bad Gateway,这里修正为 Gateway Timeout。Bad Gateway 对应 502。
8.3 404 页面漂亮,也仍然可以是 404
服务器可以在 404 响应正文中放一份完整 HTML,浏览器照样能显示标题、图片和按钮。页面"看起来正常",不能证明状态码是 200。
反过来,接口也可能在 HTTP 200 正文中放业务失败信息。排查时要同时看 HTTP 状态和应用协议,不能只看一个成功图标。好的接口设计会让二者的职责清楚,客户端也应按约定分别处理。
下篇的 /status/403、/status/504 用于观察状态行与页面表现,属于人为演示。它们并没有真的实现权限系统或等待上游,因此不能用演示结果证明对应故障已发生。
九、重定向为什么会产生第二次请求
9.1 Location 告诉客户端接下来去哪里
text
HTTP/1.1 301 Moved Permanently\r\n
Location: /\r\n
Content-Length: 0\r\n
Connection: close\r\n
\r\n

客户端先请求旧地址,收到重定向响应后,再向新目标发出请求。服务器返回 301 并不是在连接内部替浏览器把页面内容"搬过来"。
Location 可以是完整 URL,也可以是像 / 这样的相对引用。没有可用的位置,客户端便无法按这条响应找到后续目标,所以正常重定向实现应当一并构造它。
9.2 永久、临时和方法保留要分别考虑
| 状态 | 名称 | 是否永久 | 方法处理重点 |
|---|---|---|---|
| 301 | Moved Permanently | 是 | 历史兼容允许把 POST 改成 GET |
| 302 | Found | 否 | 同样存在 POST 改为 GET 的兼容行为 |
| 303 | See Other | 否 | 引导获取另一个资源,常用于提交后转到结果页 |
| 307 | Temporary Redirect | 否 | 自动重定向时保持方法 |
| 308 | Permanent Redirect | 是 | 自动重定向时保持方法 |
原稿把 302 的说明写成 "Found 或 See Other",这里需要区分:302 是 Found,303 才是 See Other。如果重定向后必须继续保留 POST 等方法,应考虑 307 或 308 的对应语义。
临时重定向也不等于"绝对不会缓存"。缓存行为还受状态定义和响应缓存控制影响,不能单凭"临时"二字下结论。RFC 9111:缓存存储条件
9.3 curl 默认只展示第一轮
bash
# 只看旧地址返回的 301
curl -i --http1.1 http://127.0.0.1:9090/old
# 跟随 Location,再获取新地址
curl -i -L --http1.1 http://127.0.0.1:9090/old
第二条命令可能展示两组响应头:前一组对应重定向,后一组对应最终资源。不要把它们误当成"一个响应里出现两个状态行"。
十、用开发者工具与 curl 串起整条链路
10.1 先启动配套实验
进入下篇的 code 目录,执行:
bash
g++ -std=c++17 -Wall -Wextra -Wpedantic -Werror server.cpp -o http_server
./http_server 9090
服务器工作目录需要包含 wwwroot/。它只监听本机 127.0.0.1;在 Linux 环境内用 curl 测试最直接。使用 WSL 时,宿主浏览器能否通过 localhost 访问,还取决于本机网络转发配置。
10.2 把浏览器操作翻译成报文观察
浏览器开发者工具打开 Network 后,访问主页、提交 GET 表单、再提交 POST 表单。分别查看 Request URL、Method、Status、Headers 与 Payload。
POST 表单的数据显示界面可能已经解码,想比较线上字节,需要切换到源数据或对应原始视图;本例回显响应也能帮助观察百分号编码。开发者工具的呈现方式不等于网络上的原始文本。
bash
curl -i --http1.1 'http://127.0.0.1:9090/search?q=C%2B%2B'
curl -i --http1.1 --data 'q=C%2B%2B+network' http://127.0.0.1:9090/echo
curl -I --http1.1 http://127.0.0.1:9090/
curl -i --http1.1 -X OPTIONS http://127.0.0.1:9090/echo
curl -i --http1.1 http://127.0.0.1:9090/missing
Linux Shell 中 URL 带 & 时尤其应使用引号,否则它可能被 Shell 解释为后台执行符。发请求之前,先确保命令行本身没有改变你准备传递的字符串。
10.3 建立正确的排查顺序
先确认连接是否成功,再检查请求目标与方法,然后看响应状态和头部,最后分析正文。连接被拒绝、404、正文乱码和表单值没解码,分别发生在不同阶段,解决方法也不同。
本篇把 HTTP 当作一套可以读懂的消息规则;下篇会把这些规则落实到 C++:先累积头部,按长度收正文,再映射资源、构造响应并完整发送。
🌄 愿每一次阅读、编译与调试,都能让我们离清晰可靠的代码更近一步。如果这篇记录对你有帮助,欢迎点赞、收藏,也欢迎交流你的实现与问题。