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

🔥 本文定位:把上篇读懂的报文变成能运行的代码。从接收字节开始,完成请求定界、路由、文件读取和响应发送。
💡 学习目标:理解常见 Header 的职责;区分无状态和持久连接;处理 HTTP/1.1 分片请求;正确返回静态资源、HEAD 与错误响应;建立版本演进的整体认识。
📌 实现范围 :Linux、IPv4、阻塞 TCP、C++17;只支持 HTTP/1.1 的一个教学子集。每条连接处理一个请求,响应明确声明
Connection: close。完整文件与测试在本文同目录code/中。
HTTP 服务器并不需要某个神秘的"网页发送接口"。它依然使用 accept、recv 和 send。区别在于:接收端必须按 HTTP 规则识别完整消息,发送端必须把状态行、头部、空行与正文组织成浏览器能理解的字节。
网页成功显示只是起点。如果换成 HEAD、请求被拆成多个片段、文件恰好为空,程序仍然应该给出一致的行为。
文章目录
- [一、按职责理解常见 Header](#一、按职责理解常见 Header)
- 二、无状态为什么不等于短连接
- 三、先写清楚教学服务器的协议范围
- 四、接收请求必须先头部后正文
- [五、URL 怎样映射到静态文件](#五、URL 怎样映射到静态文件)
- 六、构造响应并处理完整发送
- 七、服务器主循环与资源生命周期
- [八、启动页面并验证 GET 与 POST](#八、启动页面并验证 GET 与 POST)
- 九、测试边界与课堂示例中的细节
- [十、从 HTTP09 到 HTTP3](#十、从 HTTP09 到 HTTP3)
- 十一、从教学服务器继续往前走
一、按职责理解常见 Header
1.1 先问字段解决哪一个问题

面对一长串 Header,先按用途理解比背诵字母顺序更有效。
| 字段 | 常见方向 | 本文需要掌握的含义 |
|---|---|---|
| Host | 请求 | 目标主机名与可选端口,HTTP/1.1 请求必须提供 |
| Content-Type | 请求/响应 | 当前内容的媒体类型 |
| Content-Length | 请求/响应 | 按相关语义解释的字节长度 |
| Accept | 请求 | 客户端接受哪些响应媒体类型 |
| Accept-Encoding | 请求 | 客户端接受哪些内容编码,例如 gzip |
| Accept-Language | 请求 | 客户端的语言偏好 |
| Authorization | 请求 | 按认证方案携带凭据信息 |
| User-Agent | 请求 | 客户端提供的软件标识线索 |
| Referer | 请求 | 按策略提供的来源信息,可能缺省或被裁剪 |
| Location | 响应 | 重定向等场景中的资源位置 |
| Connection | HTTP/1.x | 当前连接的控制选项,例如 close |
| Date | 通常为响应 | 消息生成时间,使用规定的 HTTP 日期格式 |
| Server | 响应 | 服务端软件标识,可省略 |
Content-Type 表达"我发送了什么",Accept 表达"我能接受什么"。客户端声明能接受 JSON,不意味着服务器必然返回 JSON,更不意味着当前请求正文就是 JSON。
User-Agent 可能包含多个历史兼容标记,也可以被客户端修改。它适合做兼容性分析,不能当成可靠的身份凭据。Referer 是规范保留下来的历史拼写;程序不要自行把字段名修正成 Referrer。
1.2 缓存字段要与条件请求一起理解
Cache-Control 描述缓存策略,Expires 表达过期时间,ETag 和 Last-Modified 可作为验证线索。客户端可以发送 If-None-Match,让服务器比较已缓存表示的验证器。
资源未改变时,服务器可能返回 304;客户端继续使用已保存的表示。304 本身不带那份资源正文。ETag 也不必然是文件哈希,它是服务端分配的表示验证器,强弱语义需要按具体规则理解。
另外,no-cache 要求在复用前完成相应验证,并不等于绝不存储;no-store 才表达不要存储的要求。RFC 9111:缓存控制
本例没有实现条件请求或缓存验证。表格用于读懂浏览器和真实站点的头部,不能据此把附带服务器当成已经支持这些功能。
1.3 Cookie 的写入与回传是两个方向
通常是服务器在响应中发送 Set-Cookie,客户端按规则保存;以后满足适用条件时,再在请求中携带 Cookie。所以不能把请求里的 Cookie 简单说成"服务器用它把数据存到浏览器"。
Authorization 则按具体认证方案传递信息。原资料中的 Basic 示例使用 Base64 表示用户名与密码,Base64 可逆,也不提供传输加密,不能把这份编码字符串本身当成密码已被安全保护的证明。
Cookie 可以帮助应用把多次请求关联起来,但服务器如何维护会话、何时过期、怎样保护凭据,属于另一个完整话题,放到后面的加餐文章讲解。
二、无状态为什么不等于短连接
2.1 两个问题发生在不同层面
"无状态"讨论的是 HTTP 请求语义和应用状态关联;"连接是否复用"讨论的是底层通信通道的生命周期。

HTTP/1.1 默认允许持久连接。同一条 TCP 连接可以先完成请求 A 和响应 A,再处理请求 B 和响应 B,不必每次重新握手。
但连接还在,并不自动意味着服务器已经知道"这是刚才登录的用户"。登录身份通常需要应用层会话机制表达;即使连接换了,应用也可能借助同一会话凭据继续识别用户。
PDF 开头把 HTTP 描述为"每次请求都要新建连接",这与后面介绍的 HTTP/1.1 持久连接相矛盾。本文统一为:HTTP 无状态,HTTP/1.1 默认允许复用连接;是否关闭由协议选项与双方实际行为共同决定。
2.2 keep-alive 不是永久在线保证
HTTP/1.0 常见方式是完成一轮后关闭;一些实现通过协商扩展支持持久连接。HTTP/1.1 不需要每次都写 Connection: keep-alive 来"开启默认能力"。
Connection: close 则明确告诉对方当前连接不再继续承载后续请求。即使没有这个字段,服务器也可能因空闲超时、错误或资源策略关闭连接,客户端仍需处理断连。RFC 9112:连接持久性
HTTP 持久连接、TCP keepalive 探测和应用心跳也不是同一种机制。它们的触发条件、作用和配置位置不同,排查时不要只因为名字里都有 keep,就混为一谈。
2.3 本例为什么主动关闭连接
每条连接处理一个请求,可以把注意力集中在请求定界和响应格式上。本例返回 HTTP/1.1,并显式发送 Connection: close,所以浏览器知道它不会继续接收下一轮请求。
如果要支持复用,不能只删除这一行,还必须循环解析下一条请求、保存多读出的尾部、处理空闲超时,并遵守响应顺序。关闭策略是实现选择,不能反过来当成所有 HTTP 的定义。
三、先写清楚教学服务器的协议范围
3.1 文件布局与可观察行为
text
code/
├── Http.hpp # 请求解析、收发、响应、路由与文件读取
├── server.cpp # 监听与连接主循环
├── parser_test.cpp # 纯解析器与编码边界测试
├── verify.py # 实际 Socket 集成测试
├── README.md
├── 验证记录.md
└── wwwroot/
├── index.html
├── logo.svg
└── empty.txt
| 请求 | 行为 |
|---|---|
GET /、GET /index.html |
返回 HTML 页面 |
GET /logo.svg |
返回 SVG,声明正确媒体类型 |
GET /empty.txt |
成功返回 0 字节文件 |
GET /search?... |
返回原始请求目标,观察 query |
POST /echo |
原样返回正文,观察表单编码和二进制字节 |
HEAD 对应 GET 目标 |
返回头部,不发送正文 |
OPTIONS /echo、OPTIONS * |
返回支持方法信息 |
GET /old |
301,Location: / |
| 未映射路径 | 404 |
还有 /status/403、/status/504 两个状态码演示目标。它们只返回相应状态,不构建真实鉴权和网关故障。
3.2 哪些请求会被拒绝
服务器只接受 HTTP/1.1;请求目标是以 / 开始的源站形式,另允许 OPTIONS *。头部上限 16 KiB,正文上限 64 KiB。
本实现不支持分块请求,遇到单独的 Transfer-Encoding 返回 501;同时带传输编码和长度字段返回 400。Expect 返回 417,不尝试实现 100 Continue 握手。
为降低解析规则复杂度,所有重复字段都被拒绝,包括某些真实协议场景下原本允许重复或合并的字段。这里明确采用严格子集;生产实现应按每个字段的规范处理重复值,不能照抄成通用规则。
四、接收请求必须先头部后正文
4.1 从上篇的长度帧迁移思路
HTTP/1.1 的首行与头部结构不同于我们自定义的长度帧,但增量处理思想相同:保留已收到的字节,不完整时继续接收,完整时才交给业务。

解析器 Parse 返回 std::optional<Request>:有值表示已经得到一条请求;空值表示当前数据不足;确定非法时抛出携带状态码的 Error。
这里不使用 bool 同时表示"不完整"和"格式错误"。缺字节应该继续等待,而长度为 -1 或 Host 缺失不应该无限等待。
4.2 先找头部结束标记,再解析字段
Http.hpp 中定界的关键部分如下,完整字段校验见同目录源码:
cpp
const auto end = bytes.find("\r\n\r\n");
if (end == std::string::npos) {
if (bytes.size() >= MaxHeader) throw Error{431};
return std::nullopt;
}
const auto body_begin = end + 4;
if (body_begin > MaxHeader) throw Error{431};
头部完成后,代码检查请求行、字段名 token、字段值控制字符、Host 和重复字段。字段名转成小写用于查找,字段值保留原来的大小写。
例如 Host : x 在字段名后冒号前多了空格,会被拒绝;带折叠续行的旧写法也不会被这份解析器接受。严格校验能让教学行为更确定,同时避免不同组件对同一串头部作出不同解释。
4.3 长度必须逐位校验
cpp
std::size_t length = 0;
for (unsigned char c : value) {
if (c < '0' || c > '9') throw Error{400};
const std::size_t digit = c - '0';
if (length > (MaxBody - digit) / 10) throw Error{413};
length = length * 10 + digit;
}
这一段只展示 value 已非空时的数字累积,完整源码另有空值检查。不能用 atoi 把任意输入悄悄变成零,也不能先允许大整数溢出,再判断结果是否超过上限。
在本例子集中,没有长度字段的请求按零字节正文处理。有字段时,必须等到 bytes.size() - body_begin >= length,才能复制相应范围。正文内部的 CRLF 和 NUL 都只是内容。
4.4 接收循环按实际长度追加
cpp
inline Request ReadRequest(int fd, bool& head) {
std::string pending;
for (;;) {
// Once the first line is available, suppress error bodies for HEAD too.
if (pending.find("\r\n") != std::string::npos)
head = pending.compare(0, 5, "HEAD ") == 0;
if (auto r = Parse(pending)) return *r;
char chunk[4096];
const ssize_t n = ::recv(fd, chunk, sizeof(chunk), 0);
if (n > 0) pending.append(chunk, static_cast<std::size_t>(n));
else if (n == 0) throw Error{400};
else if (errno == EINTR) continue;
else if (errno == EAGAIN || errno == EWOULDBLOCK) throw Error{408};
else throw std::runtime_error("recv failed");
}
}
append(chunk, n) 按返回值保存字节。recv 返回 0 表示当前方向结束;如果还没有得到完整请求,本例返回 400。EINTR 重试,接收超时返回 408,其他读错误交给外层处理。
head 在完整请求行出现后记录是否为 HEAD,即使后续头部校验失败,也尽量避免给 HEAD 错误响应发送正文。
对于超大或非法请求,代码可能在客户端所有字节都读完之前就拒绝连接。因此不能把这一小段当成已经实现了生产服务器的优雅错误关闭流程,相关实测现象在后文单独说明。
五、URL 怎样映射到静态文件
5.1 路径不是操作系统文件名
请求 /index.html 不表示客户端可以控制服务器任意本地路径。本例先从请求目标分离 query,再用固定表映射到资源。

cpp
{
{"/", {"wwwroot/index.html", "text/html; charset=utf-8"}},
{"/index.html", {"wwwroot/index.html", "text/html; charset=utf-8"}},
{"/logo.svg", {"wwwroot/logo.svg", "image/svg+xml"}},
{"/empty.txt", {"wwwroot/empty.txt", "text/plain; charset=utf-8"}}
}
没有匹配项就返回 404。实现没有把用户传来的路径拼到 wwwroot 后面,所以 /../Http.hpp 和百分号编码的类似路径也无法命中资源表。
这是一种适合小实验的映射方式。若扩展成任意静态目录服务,才需要完整处理解码、路径规范化、目录边界和符号链接等问题;不能只删除字符串中的 .. 就宣称安全完成。
5.2 文件读取失败与空文件必须区分
PDF 的辅助函数读取失败返回空字符串,但成功读取空文件也会得到空字符串。调用方仅凭 empty() 就无法区分两种情况。
本例改用 std::optional<std::string>:无值表示打开、大小检查或读取失败;有值但长度为零表示合法空文件。
cpp
inline std::optional<std::string> ReadFile(const std::string& path) {
std::ifstream in(path, std::ios::binary | std::ios::ate);
if (!in) return std::nullopt;
const auto size = in.tellg();
if (size < 0 || size > 1024 * 1024) return std::nullopt;
std::string out(static_cast<std::size_t>(size), '\0');
in.seekg(0);
if (!out.empty() && !in.read(out.data(), static_cast<std::streamsize>(out.size())))
return std::nullopt;
return out;
}
二进制模式避免文本换行转换。std::string::data() 在 C++17 的可变字符串上能提供可写指针,不需要把 c_str() 的只读指针强制转换后再写入。
代码还检查 tellg 和 read,并把静态文件限制在 1 MiB 以内。这与请求正文的 64 KiB 上限是两套不同限制:一个控制读取文件,另一个控制接收请求。
5.3 先构造响应对象,再编码成线上消息
Route 返回 Response,包含状态、媒体类型、正文和受控的附加字段。它不直接调用 send。
这样文件失败可以生成 404,/old 可以附加 Location,POST 可以原样回显正文;最终都由同一个编码函数生成状态行、长度和空行。路由层不必重复拼接容易写错的响应格式。
extra 仅由代码中的固定字符串构造,不能把未经校验的用户输入直接拼进去,否则换行可能改变响应头结构。
六、构造响应并处理完整发送
6.1 Content-Length 来自实际正文
cpp
inline std::string Encode(const Response& r, bool head = false) {
char date[64]{};
const std::time_t now = std::time(nullptr);
std::tm utc{};
gmtime_r(&now, &utc);
std::strftime(date, sizeof(date), "%a, %d %b %Y %H:%M:%S GMT", &utc);
std::string out = "HTTP/1.1 " + std::to_string(r.status) + " " + Reason(r.status) + "\r\n";
out += "Date: " + std::string(date) + "\r\n";
out += "Content-Type: " + r.type + "\r\nContent-Length: " + std::to_string(r.body.size());
out += "\r\nConnection: close\r\n" + r.extra + "\r\n";
if (!head) out += r.body;
return out;
}
编码函数使用 CRLF,把正文的 size() 写进 Content-Length,再插入头部结束空行。Date 使用 UTC 的 HTTP 日期格式。
HEAD 复用同一条路由得到的响应元数据,长度仍描述 GET 会返回的正文;最后一步不追加正文。这个实现为简单起见仍读取静态文件,真实服务器可以根据文件元数据优化。
这份编码器服务于示例实际生成的状态码,没有实现 1xx、204、304 和 CONNECT 等全部特殊规则。以后增加这些分支时,必须同时增加对应的无正文或隧道处理,不能只往状态码列表添加一个数字。
6.2 send 成功也可能只发送了一部分
cpp
inline bool SendAll(int fd, const std::string& bytes) {
std::size_t sent = 0;
while (sent < bytes.size()) {
const ssize_t n = ::send(fd, bytes.data() + sent, bytes.size() - sent, MSG_NOSIGNAL);
if (n > 0) sent += static_cast<std::size_t>(n);
else if (n < 0 && errno == EINTR) continue;
else return false;
}
return true;
}
循环推进偏移,直到全部字节被系统调用接受。遇到 EINTR 继续,其他失败返回 false。Linux 的 MSG_NOSIGNAL 避免本次发送触发 SIGPIPE,错误仍需根据返回值处理。Linux send 手册
发送函数完成只说明数据已交给本机协议栈的相应缓冲处理,不证明浏览器已经渲染页面,更不证明某项业务已经被用户确认。
服务器关闭连接前先完成发送循环。连接 fd 由资源包装对象释放,避免正常路径和错误路径各写一套关闭逻辑,最后漏关或重复关闭。
七、服务器主循环与资源生命周期
7.1 监听 fd 与连接 fd 各有自己的职责

监听 Socket 负责等待连接,accept 返回的连接 Socket 才用于本轮收发。下面是完整 server.cpp,需要同目录的 Http.hpp:
cpp
#include "Http.hpp"
#include <arpa/inet.h>
#include <iostream>
#include <netinet/in.h>
#include <sys/time.h>
int main(int argc, char** argv) {
try {
const std::string port_text = argc == 2 ? argv[1] : "9090";
if (argc > 2 || port_text.empty() || port_text.find_first_not_of("0123456789") != std::string::npos)
throw std::runtime_error("usage: ./http_server [1..65535]");
const auto port = std::stoul(port_text);
if (port == 0 || port > 65535) throw std::runtime_error("invalid port");
http::Fd listener(::socket(AF_INET, SOCK_STREAM, 0));
const int yes = 1;
if (::setsockopt(listener.get(), SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes)) < 0)
throw std::runtime_error("setsockopt failed");
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(static_cast<unsigned short>(port));
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
if (::bind(listener.get(), reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) < 0 ||
::listen(listener.get(), 16) < 0) throw std::runtime_error("bind/listen failed");
std::cout << "http://127.0.0.1:" << port << "/\n" << std::flush;
for (;;) {
const int fd = ::accept(listener.get(), nullptr, nullptr);
if (fd < 0) {
if (errno == EINTR) continue;
throw std::runtime_error("accept failed");
}
http::Fd client(fd);
timeval timeout{3, 0};
if (::setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)) < 0 ||
::setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout)) < 0)
continue;
bool head = false;
try {
const auto req = http::ReadRequest(fd, head);
head = req.method == "HEAD";
const auto wire = http::Encode(http::Route(req), head);
if (!http::SendAll(fd, wire)) std::cerr << "send failed\n";
} catch (http::Error e) {
http::Response out;
out.status = e.status; out.body = http::Reason(e.status) + "\n";
http::SendAll(fd, http::Encode(out, head));
} catch (const std::exception& e) { std::cerr << e.what() << '\n'; }
}
} catch (const std::exception& e) { std::cerr << e.what() << '\n'; return 1; }
}
代码用 sockaddr_in addr{} 清零初始化。只监听回环地址,便于在本机完成实验;端口从命令行读取并校验范围。
accept(fd, nullptr, nullptr) 表示这次不索取对端地址。如果需要地址结构,必须同时初始化 socklen_t 为结构容量,不能像原示例那样把未初始化的长度传进去。
7.2 超时和并发能力要如实理解
每个连接设置 3 秒接收与发送超时,这是阻塞系统调用层面的等待限制。它不是整份请求从开始到结束的总期限:客户端持续缓慢地发送字节,仍可能让每次读取都在超时前获得进展。
主循环一次处理一个客户端。一个慢连接会让后续连接排队,所以这份代码用于观察协议,不能承担高并发服务。添加线程池或事件循环时,还要重新安排 fd 所有权和连接状态,不能仅把 recv 放进另一个线程就算设计完成。
SO_REUSEADDR 用于本例的监听地址复用需求,也不会替程序自动解决任意端口占用。遇到 bind 失败,仍应检查是否有其他进程在监听。
八、启动页面并验证 GET 与 POST
8.1 从 code 目录运行
bash
g++ -std=c++17 -Wall -Wextra -Wpedantic -Werror server.cpp -o http_server
./http_server 9090
终端出现 http://127.0.0.1:9090/ 后,在同一 Linux 环境中执行 curl,或者用能访问该地址的本机浏览器打开页面。
注意工作目录:程序使用相对路径 wwwroot/index.html,从其他目录启动可能导致读取失败并返回 404。代码没有悄悄切换工作目录,README 中也给出相同运行方式。
8.2 GET 和 POST 能在一张页面上对比
配套 HTML 包含两张表单:GET 发向 /search,POST 发向 /echo,输入值均为 C++ 网络。
GET 返回原始请求目标,可看到 query;POST 返回原始正文,可看到表单编码。返回类型为 text/plain,所以回显内容不会被当成 HTML 执行。
bash
curl -i --http1.1 http://127.0.0.1:9090/
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 http://127.0.0.1:9090/empty.txt
curl -i -L --http1.1 http://127.0.0.1:9090/old
主页会再引用一次 /logo.svg。浏览器如果额外请求 /favicon.ico,本例会正常返回 404,主文档不受影响。
8.3 观察 OPTIONS 与 405
bash
curl -i --http1.1 -X OPTIONS http://127.0.0.1:9090/echo
curl -i --http1.1 -X PUT http://127.0.0.1:9090/
前者返回允许的方法信息,后者返回 405 并带 Allow。本例的 OPTIONS 没有实现完整资源发现或 CORS 策略;OPTIONS * 返回服务器整体支持的方法集合。
不要把工具格式化出来的头部展示当成自己真的发送了那些换行。要检查首行和 CRLF,可以读源码构造,或使用附带测试直接检查原始响应字节。
九、测试边界与课堂示例中的细节
9.1 拆分位置应由测试主动控制

解析器测试构造一条含 NUL 和 CRLF 正文的合法请求,在每一个字节位置切成两段。第一段不完整时应返回空值,补齐后应恢复完全相同的正文。
它还逐字节追加,并覆盖缺失 Host、重复字段、非法长度、超限声明、传输编码冲突、空文件与 HEAD 编码。确定性切分验证的是解析器行为,不依赖真实 TCP 恰好怎样分包。
9.2 编译和执行测试
bash
g++ -std=c++17 -Wall -Wextra -Wpedantic -Werror \
-fsanitize=address,undefined -g parser_test.cpp -o parser_test
./parser_test
g++ -std=c++17 -Wall -Wextra -Wpedantic -Werror server.cpp -o http_server
python3 verify.py
实际在本机 WSL Linux 中完成了编译,解析器测试通过 ASan/UBSan 运行,集成脚本通过 32 项检查。集成脚本自动选择本地端口、启动服务器、校验原始响应,并在结束时停止进程。
| 测试方向 | 已检查的结果 |
|---|---|
| 正常路由 | 页面、图片、query、表单、空文件、404 |
| HEAD | 正常目标、不存在目标和头部错误均不发送正文 |
| 正文 | NUL/CRLF 保留、逐字节网络发送、64 KiB 边界 |
| 协议拒绝 | 缺 Host、重复长度、TE/CL 冲突、Expect、超限 |
| 异常结束 | 正文未齐时 EOF、读取超时 |
| 响应语义 | 类型、长度、Allow、Location、curl 跟随 301 |
详细结果在 code/verification-results.json 与 code/验证记录.md。测试不包含并发压测、真实浏览器兼容性、TLS 或完整 RFC 一致性认证。
9.3 提前拒绝后可能看到 TCP 重置
服务器发现头部超限时,可以在尚有对端数据未读取的情况下发送错误响应并关闭。在本次超大头部测试中,客户端收到完整 431 响应之后又观察到连接重置。
验证脚本只在已经拿到可解析的响应头、且响应正文长度与声明一致时接受这个结果;没有收到响应或只收到半份响应,仍然判失败。
这也说明"调用过 send"不足以证明客户端完整收到错误消息。本例如实记录该行为,生产服务器还需要结合读取丢弃、关闭流程与资源上限设计更完整的策略。
9.4 原示例有哪些适合顺手修正的地方
| 原示例做法 | 本文调整 | 原因 |
|---|---|---|
| 用一个足够大的缓冲区读一次 | 缓存并循环读取 | 缓冲区大不能保证请求一次收齐 |
响应使用 \n 换行 |
统一 CRLF | 按 HTTP/1.1 发送格式构造 |
write 一次就结束 |
SendAll 推进偏移 | 处理部分发送 |
| 客户端 fd 未明确关闭 | RAII 管理 | 正常与错误路径都释放 |
| accept 地址长度未初始化 | 不取地址时传空指针 | 避免无效的输入容量 |
| 返回空字符串表示文件失败 | optional 区分状态 | 空文件也应成功返回 |
| 强转 c_str 后写入 | C++17 data() | 使用可写缓冲区接口 |
课堂代码的价值是用最少步骤展示"按格式发字节就能显示页面"。博客补全这些边界,是为了让读者复制代码后也能解释异常行为,而不是把一次成功访问当成完整实现。
十、从 HTTP/0.9 到 HTTP/3
10.1 版本改变的重点是怎样承载消息

| 版本 | 主要变化 | 阅读时的边界 |
|---|---|---|
| HTTP/0.9 | 早期简单 GET 与 HTML 传输 | 没有现代状态行和头部体系 |
| HTTP/1.0 | 状态码、头部、不同内容类型 | 常见短连接,但有持久连接扩展实践 |
| HTTP/1.1 | 默认持久连接、Host、分块传输等 | 仍需清晰定界每条消息 |
| HTTP/2 | 二进制帧、多路复用、HPACK | 多个流共享 TCP,仍受 TCP 传输队头阻塞影响 |
| HTTP/3 | 映射到 QUIC、QPACK | 不同流可更独立地处理传输丢包 |
HTTP/1.0 的规范发布于 1996 年,HTTP/1.1 最初标准化于 1997 年,1999 年又有重要修订;不能把 1999 年直接当作 HTTP/1.1 的首次出现。HTTP/2 于 2015 年标准化,HTTP/3 于 2022 年标准化。版本日期用于建立脉络,不必牵强地把每个特性归因于某一种应用潮流。
其中 HTTP/1.1 的早期时间可直接对照 RFC 2068(1997 年 1 月)。本篇的解析规则以现行 HTTP/1.1 规范为参考,而不把早期 RFC 当成当前全部要求。
10.2 HTTP/2 多路复用没有消除所有等待
HTTP/1.1 管道化可以连续发送多个请求,但响应仍须按规定的顺序对应。HTTP/2 把消息分成二进制帧,以流为单位交错传输,改善了应用层的并发组织。RFC 9113:HTTP/2
不过承载它的 TCP 仍提供一个有序字节流。某段字节丢失时,后续字节向上交付可能受阻,进而影响共享连接的多个流。因此不能把"多路复用"直接等同于"完全没有队头阻塞"。
HTTP/2 规范也定义了服务器推送,但这里把它作为协议能力认识,不对浏览器部署或支持情况作通用承诺。HTTPS 则不是 HTTP/2 才首次出现的功能,HTTP/1.1 同样可以运行于 TLS 保护之上。
10.3 QUIC 使用 UDP,不等于放弃可靠性
HTTP/3 使用 QUIC,QUIC 基于 UDP 数据报构建可靠传输、拥塞控制和多流机制,并结合 TLS 1.3。不能把它理解成"把 HTTP 报文直接交给 UDP,丢了就不管"。RFC 9114:HTTP/3
不同流的数据交付更独立,可以缓解一个流丢包阻塞其他流交付的问题;同一流仍要满足自身顺序,多个流也仍共享部分连接资源。建立连接的开销和恢复行为取决于具体条件,不能承诺所有请求都立即完成或永远没有等待。
方法、资源与状态码这些应用语义大体延续下来,线上帧格式和传输组织却发生了变化。因此学懂本例的语义有长期价值,逐行解析代码则有明确版本边界。
十一、从教学服务器继续往前走
11.1 先确保每条消息都被正确处理
本例的处理链路可以概括为:缓存字节,识别头部,校验长度,收齐正文,路由到处理逻辑,再统一编码并完整发送响应。
无论以后换成线程池、epoll 还是 Reactor,这些协议步骤都不会消失。事件循环改变的是"何时读写、由谁执行",不会自动替应用判断一条 HTTP 消息是否完整。
11.2 再逐步增加能力
需要持久连接时,先补上每条连接独立的解析状态与残留缓存;需要更多客户端时,再安排并发模型;需要完整协议兼容时,优先采用经过维护的 HTTP 库,而不是不断扩张这个教学子集。
Cookie/Session 会回答多次请求怎样关联应用身份,HTTPS 会回答传输怎样受到保护。它们与本文的连接生命周期、消息边界有联系,但各自解决不同的问题,继续按后续 PDF 单独展开更清楚。
到这里,浏览器显示页面已经可以落回熟悉的 C++ 代码:Socket 负责收发,解析器负责完整性,路由负责资源和业务,响应编码器负责格式。每一部分都能单独检查,出现问题时也能定位到具体职责。
🌄 愿每一次阅读、编译与调试,都能让我们离清晰可靠的代码更近一步。如果这篇记录对你有帮助,欢迎点赞、收藏,也欢迎交流你的实现与问题。