聊聊Linux 网络:HTTP(三)Linux 服务器实现(学习笔记)

开发过程中有些细节容易被忽略,今天挑几个重点聊一聊。

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)

暂无评论