开发过程中有些细节容易被忽略,今天挑几个重点聊一聊。
Linux 网络:HTTP(三)Linux 服务器实现
引言
前两篇已经介绍了 HTTP 报文、办法、状态码和登录状态。现在把这些规则落实到 Linux 程序中:浏览器访问首页时,服务器怎样把收到的字节变成请求对象,再找到文件、组织响应,最后完整地发回去?
实现一个教学服务器,重点不在于把所有 HTTP 功能一次写齐,而在于让收发、解析和业务各自承担明确的职责。下面结合 C++17 代码片段分析这些模块,以及从回显程序扩展到 HTTP 时需要补上的处理。
一、服务器的模块与调用顺序
最小结构可以分为四层:
模块核心职责Socket / TcpServer监听、接入连接、读写字节、管理描述符HttpRequest解析办法、目标、版本、报头和正文HttpProtocol / Router选择业务处理函数或静态资源处理HttpResponse保存处理结果,序列化响应
还缺数据
格式错误
完整
accept 接入连接
累计接收字节
请求是否完整且合法
生成错误响应
按办法和路径选择处理器
读取资源或执行业务
序列化响应
循环发送并关闭连接
这篇这篇采用阻塞套接字,一条连接处理一条请求,响应明确携带 Connection: close。这是为了先把完整流程讲清,后面再扩展连接复用。
解析器先支持 HTTP/1.1 的普通路径形式,以及无正文或使用 Content-Length 定界的请求。未实现的传输编码需要明确拒绝,不能把“浏览器这次恰好发来了简单请求”当成所有请求的共同条件。
二、监听套接字与服务套接字
初始化仍沿用 TCP 的基本步骤:
socket → 设置套接字选项 → bind → listen
↓
循环调用 accept
监听套接字负责接收新连接,accept 返回的服务套接字才用于处理具体请求。浏览器加载首页、图片和其他资源时,可能建立多个连接;服务器应逐条接入,而不是处理完首页就退出。
初始化和接入操作都要检查返回值。设置 SO_REUSEADDR 有助于处理服务重启时的地址复用,但不能搞懂为允许任意多个服务器同时占用同一个监听地址。相关接口含义见 Linux socket 手册。
端口与资源根目录最好作为配置保存。浏览器访问的 /index.html 是协议中的资源路径,监听端口决定连接到哪个服务,两者不该混在同一个初始化逻辑里。
三、累计接收与请求完整性
1. 按字节数追加数据
下面的接收片段适用于阻塞套接字:
char buffer[4096];
ssize_t n = recv(fd, buffer, sizeof(buffer), 0);
if (n > 0) {
input.append(buffer, static_cast(n));
}
这里使用 append(buffer, n),而不是把缓冲区当成以零结尾的字符串。一次接收可以只有半行报头,也可以带上正文;缓冲区大小只是本次读操作的容量。
读到 EINTR 可以重试;对端关闭或发生其他错误时,应收尾本次连接处理。不能把“还没有收齐请求”误认为“请求已经无效”,也不能在下一次读取前清空已有字节。
2. 为解析结果设置三种状态
enum class ParseState {
NeedMore,
Complete,
Error
};
先查找 \r\n\r\n。找不到时,继续接收,直到达到报头大小上限或等待超时。找到后,只能说明报头完整,还要根据报头确定正文长度。
若不存在 Transfer-Encoding 和 Content-Length,请求正文长度按零处理;若存在合法 Content-Length,就继续等待对应字节数。这个判断来自报文定界规则,不能简单写成“GET 没正文,其他方法都必须有长度”。RFC 9112规定了消息长度的判定顺序。
教学实现可以把报头限制为 16 KiB,正文限制为 1 MiB。长度字段要逐位验证为十进制数字,检查上限后再参与加法,避免负数、溢出和过大内存分配。
本例遇到重复 Content-Length 时直接拒绝;Transfer-Encoding 与 Content-Length 同时出现时按错误处理并关闭连接。若没有实现分块解码,也应拒绝相关请求,而不是忽略 Transfer-Encoding 后继续处理。
对于 Expect: 100-continue,还要在等待正文前决定是否支持。未实现时可以返回 417 并关闭连接,否则可能出现客户端等确认、服务器等正文的相互等待。
四、解析请求行与报头
请求对象应分别保存协议字段,而不是一开始就把 URL 改成本地文件名:
using HeaderFields =
std::vector;
struct HttpRequest {
std::string method;
std::string target;
std::string path;
std::string query;
std::string version;
HeaderFields headers;
std::string body;
};
比如 GET /image/th.jpg?v=1 HTTP/1.1,解析后 path 是 /image/th.jpg,query 是 v=1。取后缀、匹配路由和寻找文件,都该基于正确拆分后的字段。
读取报头行时,“没有完整行”和“读到空行”也必须区分:
std::optional TakeLine(std::string& text) {
auto pos = text.find("\r\n");
if (pos == std::string::npos) return std::nullopt;
std::string line = text.substr(0, pos);
text.erase(0, pos + 2);
return line;
}
nullopt 表示还没有完整行,值为空字符串则表示空行。这里建议对已收齐的报头副本调用,原始接收缓冲区在消息完整性确认前保持不变,避免解析到一半后无法从头重试。
请求行需要验证方法、请求目标和版本是否成功提取,并拒绝多余字段。方法名区分大小写;报头字段名则不区分大小写。不要把对 GET 的大小写宽松比较,推广成所有协议字段的统一规则。
报头应按第一个冒号拆分,再处理值两侧允许的空格和制表符。Content-Length:33 并不因为冒号后没有空格就无效,但字段名与冒号之间不能随意插入空白。
使用 HTTP/1.1 时还应验证 Host 字段。解析失败必须向上返回,不能忽略各阶段结果后仍然返回成功;否则错误字段会继续进入路由和文件操作。
五、方法与路径共同决定路由
业务路由可以先注册为:
GET /health → 返回运行状态
POST /api/calc → 解析请求并计算
POST /Login → 校验凭据、创建会话
GET /dashboard → 检查会话后返回内容
路由匹配时使用方法与 path,query 留给具体处理器解释。不要把 wwwroot 拼进业务路由键,也不要让处理函数依赖磁盘目录结构。
若路径存在但方法不允许,应返回 405 并提供 Allow;如果没有匹配的业务路由,再交给静态资源处理器判断。静态处理器通常接收 GET 和 HEAD,其他方法需要按资源规则处理。
查询参数与正文也应分别解析。POST /api/calc?mode=int 同时带有查询信息和正文,并不矛盾。处理器先检查 Content-Type,再选择 JSON 或表单解码方式。
把路由分发与业务执行分开后,登录处理器才有清楚的边界:检查字段、验证凭据、创建有效会话、设置响应。返回一段 Login success! 只是响应东西,不能代替前面的业务步骤。
六、从资源路径到静态文件
假设资源根目录为 wwwroot,基础映射关系是:
请求路径对应资源/默认首页 index.html/index.htmlwwwroot/index.html/image/th.jpgwwwroot/image/th.jpg/missing.html缺失时返回 404
但“根目录 + 用户路径”的字符串拼接还不够。请求可能包含 ..、百分号编码或指向其他目录的符号链接,最终访问位置可能超出资源根目录。
实现时先分离查询字符串,再按既定规则对路径解码一次,拒绝非法编码、零字节及无法处理的编码分隔符;随后按路径组件处理目录关系,确认目标受根目录约束。
不能只判断字符串是否以 wwwroot 开头:wwwroot_backup 也满足这种前缀判断。单纯词法规范化同样不能解决符号链接指向根目录之外的麻烦。
在 Linux 中,可以基于资源根目录的描述符,使用 openat2 配合 RESOLVE_BENEATH 和 RESOLVE_NO_SYMLINKS 限制路径解析。传入的是去掉 URL 前导斜杠后的相对路径;不同策略对符号链接的处理应保持一致。openat2 手册说明了这些约束。
这样的处理把边界检查与实际打开结合起来。若先检查路径,随后再按原字符串重新打开,目录或链接在两次操作之间变化,仍可能使检查失效。
七、按二进制字节读取文件
读取结果必须区分“失败”与“成功读取零字节”。一个合法空文件可以返回 200 和 Content-Length: 0,不能仅因为字符串为空就认定资源不存在。
下面的函数接收已经受控打开、偏移为零的只读文件描述符。它不负责关闭描述符,调用方管理其生命周期:
```
std::optional ReadFile(int fd) {
constexpr std::size_t maxFile = 16 1024 1024;
struct stat st{};
if (fstat(fd, &st) < 0 || !S_ISREG(st.st_mode) ||
st.st_size < 0 ||
st.st_size > static_cast(maxFile)) {
return std::nullopt;
}
std::string body(static_cast(st.st_size), '\0');
std::size_t offset = 0;
while (offset < body.size()) {
ssize_t n = read(fd, body.data() + offset,
body.size() - offset);
if (n < 0 && errno == EINTR) continue;
if (n
以上就是这次整理的全部内容,希望对你有所启发。如果有不同见解,欢迎在评论区交流讨论。
评论 (0)
暂无评论