Linux 网络:HTTP(三)Linux 服务器实现
引言
前两篇已经介绍了 HTTP 报文、方法、状态码和登录状态。现在把这些规则落实到 Linux 程序中:浏览器访问首页时,服务器怎样把收到的字节变成请求对象,再找到文件、组织响应,最后完整地发回去?
实现一个教学服务器,重点不在于把所有 HTTP 功能一次写齐,而在于让收发、解析和业务各自承担明确的职责。下面结合 C++17 代码片段分析这些模块,以及从回显程序扩展到 HTTP 时需要补上的处理。
一、服务器的模块与调用顺序
最小结构可以分为四层:
| 模块 | 主要职责 |
|---|---|
| Socket / TcpServer | 监听、接入连接、读写字节、管理描述符 |
| HttpRequest | 解析方法、目标、版本、报头和正文 |
| HttpProtocol / Router | 选择业务处理函数或静态资源处理 |
| HttpResponse | 保存处理结果,序列化响应 |
本文采用阻塞套接字,一条连接处理一条请求,响应明确携带 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<std::size_t>(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<std::pair<std::string, std::string>>;
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<std::string> 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.html | wwwroot/index.html |
| /image/th.jpg | wwwroot/image/th.jpg |
| /missing.html | 缺失时返回 404 |
但“根目录 + 用户路径”的字符串拼接还不够。请求可能包含 ..、百分号编码或指向其他目录的符号链接,最终访问位置可能超出资源根目录。
实现时先分离查询字符串,再按既定规则对路径解码一次,拒绝非法编码、零字节及无法处理的编码分隔符;随后按路径组件处理目录关系,确认目标受根目录约束。
不能只判断字符串是否以 wwwroot 开头:wwwroot_backup 也满足这种前缀判断。单纯词法规范化同样不能解决符号链接指向根目录之外的问题。
在 Linux 中,可以基于资源根目录的描述符,使用 openat2 配合 RESOLVE_BENEATH 和 RESOLVE_NO_SYMLINKS 限制路径解析。传入的是去掉 URL 前导斜杠后的相对路径;不同策略对符号链接的处理应保持一致。openat2 手册说明了这些约束。
这样的处理把边界检查与实际打开结合起来。若先检查路径,随后再按原字符串重新打开,目录或链接在两次操作之间变化,仍可能使检查失效。
七、按二进制字节读取文件
读取结果必须区分“失败”与“成功读取零字节”。一个合法空文件可以返回 200 和 Content-Length: 0,不能仅因为字符串为空就认定资源不存在。
下面的函数接收已经受控打开、偏移为零的只读文件描述符。它不负责关闭描述符,调用方管理其生命周期:
std::optional<std::string> 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<off_t>(maxFile)) {
return std::nullopt;
}
std::string body(static_cast<std::size_t>(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) return std::nullopt;
offset += static_cast<std::size_t>(n);
}
return body;
}
这里限制单文件大小为 16 MiB,避免一次把任意大文件装进内存。空文件返回一个有值但内容为空的 optional;nullopt 才表示失败。
POSIX 的 read 按字节读取,不会删掉换行,也不会遇到零字节就停止。如果使用 C++ 文件流,应以二进制方式打开,并使用 C++17 的可写 data(),不要强转 c_str() 后写入。
打开失败还需要保留错误原因:资源缺失可以返回 404,权限问题按策略返回 403,其他内部错误则可能返回 500。错误页本身不存在时,也应有内置的简短正文,避免不断重定向到不存在的错误页。
八、组织并序列化响应
响应报头建议用能保留重复字段的结构保存,例如前面定义的 HeaderFields。多条 Set-Cookie 应分别输出,单值 map 会覆盖同名键。Set-Cookie 文档说明了这种输出形式。
对于 200、400、404、405 等允许正文的响应,可以采用以下序列化方式:
std::string SerializeBodyResponse(
int code, const std::string& reason,
const HeaderFields& fields, const std::string& body,
bool headOnly = false) {
std::string out = "HTTP/1.1 " + std::to_string(code);
out += " " + reason + "\r\n";
for (const auto& field : fields) {
out += field.first + ": " + field.second + "\r\n";
}
out += "Content-Length: " + std::to_string(body.size());
out += "\r\nConnection: close\r\n\r\n";
if (!headOnly) out += body;
return out;
}
这里的 fields 只保存 Content-Type、Allow 等额外字段,不能再次加入 Content-Length 或 Connection。字段名与值由受控逻辑生成,拒绝其中出现回车和换行,避免破坏响应结构。
HEAD 可以沿用对应 GET 的资源信息和长度,但不追加正文;204、304 等无正文状态要使用符合各自规则的分支,不能不加区分地调用这个带正文序列化函数。
静态文件的 Content-Type 可按后缀映射,例如 HTML 使用 text/html,JPEG 使用 image/jpeg。未知类型可采用 application/octet-stream。长度始终按最终发送的字节内容计算,不能拿字符数或文件读取前的猜测值代替。
九、循环发送完整响应
一次 send 不保证发送完全部字节。需要根据返回值推进偏移:
bool SendAll(int fd, const std::string& data) {
std::size_t offset = 0;
while (offset < data.size()) {
ssize_t n = send(fd, data.data() + offset,
data.size() - offset, MSG_NOSIGNAL);
if (n < 0 && errno == EINTR) continue;
if (n <= 0) return false;
offset += static_cast<std::size_t>(n);
}
return true;
}
MSG_NOSIGNAL 避免对已关闭连接写入时产生 SIGPIPE,失败仍通过返回值报告。这个版本适用于阻塞套接字;若改为非阻塞模式,还需保存未发送数据并等待可写事件。send 手册给出了相关语义。
发送完成后,按本例短连接约定关闭服务套接字。如果实际只发出一部分就失败,应结束连接,不能继续向残缺响应后面拼接另一份“错误响应”。
十、fork 与描述符生命周期
多进程模型可以让父进程接入连接,子进程处理请求。接入之后的核心控制流程如下,HandleConnection 代表前面的完整收发与处理过程:
pid_t pid = fork();
if (pid == 0) {
close(listenfd);
HandleConnection(clientfd);
close(clientfd);
_exit(0);
}
if (pid < 0) {
// 记录 fork 失败,当前连接无法交给子进程。
}
close(clientfd); // 父进程不再保留这个连接。
fork 后父子进程都拥有已打开描述符的副本。只有子进程关闭连接、父进程却一直保留,会造成描述符泄漏,还可能让客户端迟迟等不到连接结束。父进程也会把这些旧描述符继续传给后来的子进程。fork 手册说明了这种继承关系。
父进程还需要回收退出的子进程,例如在适当位置循环调用 waitpid(-1, &status, WNOHANG),或按 Linux 的行为显式设置相应的 SIGCHLD 处理策略。不要在接入循环里无条件阻塞等待某个子进程。子进程返回业务处理后必须退出,不能回到父进程的 accept 循环。
智能指针释放的是 C++ 对象,对象只有在析构时执行 close,才会释放对应描述符。Socket 基类若通过基类指针管理派生对象,析构函数应为 virtual;持有描述符的对象还应禁止随意复制,避免重复关闭。初始无效描述符使用 -1,更容易明确所有权。
十一、验证与后续扩展
代码片段分别接入网络层和协议层时,要统一接口、错误返回与关闭责任。常用头文件包括 string、vector、utility、optional、cerrno,以及 Linux 的 sys/socket.h、sys/stat.h、unistd.h、sys/wait.h 等。
在 Linux 项目目录编译后,服务可按既有入口启动:
make
./httpserver 8080
基本检查不能只看首页是否出现,还应包括:
| 场景 | 重点观察 |
|---|---|
| 请求首页与图片 | 正文内容、媒体类型、长度是否一致 |
| 请求不存在的资源 | 直接返回 404,而非无休止重定向 |
| 请求合法空文件 | 返回成功和零长度正文 |
| 分次发送报头与正文 | 收齐前不进入业务处理 |
| 使用 HEAD | 返回资源信息但没有正文 |
| 错误长度或越界路径 | 拒绝请求,正常结束连接 |
| 客户端提前断开 | 不让 SIGPIPE 终止整个服务 |
循环接收还要配合报头、正文和连接等待期限。单次读取超时不能完全代替整份请求的总时限,否则客户端持续缓慢发送字节,仍可能长时间占用处理资源。
后续支持持久连接时,需要保留本次请求之后尚未消费的字节;支持大文件时,可以分段读取和发送;改用线程池或 epoll 时,还要相应调整任务状态和资源所有权。仅把接入方式换成事件等待,不会自动补齐 HTTP 解析规则。
结语
HTTP 服务器把前面的知识连接了起来:TCP 提供字节流,解析器划分并理解请求,路由与文件模块产生结果,响应对象再把结果编码为客户端能够理解的报文。
三篇学习到这里,重点已经从“拼出一个能显示网页的字符串”,推进到“每个字节怎样被接收、解释、处理和发送”。把完整性、错误路径和资源释放一起考虑,程序的行为才会在不同请求条件下保持清楚。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/AbandonForce/article/details/169168537




