AbandonForce头像
Linux 网络:HTTP(三)Linux 服务器实现封面图

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<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.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<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

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--