仿 muduo 高并发服务器项目:实现 HttpServer、静态资源与正则路由

前言

前面已经完成了 TcpServerHttpRequestHttpResponseHttpContext:网络层能够接收 TCP 字节流,协议层也能够把字节流增量解析成 HTTP 请求。今天继续把这些模块串起来,实现真正面向业务使用的 HttpServer

HttpServer 自己不负责 acceptrecvepoll,而是站在网络层和业务层之间,完成下面这条链路:

text 复制代码
TcpServer 建立连接
        ↓
Connection 保存 HttpContext
        ↓
Buffer 接收字节并增量解析
        ↓
静态资源或正则路由处理
        ↓
填写 HttpResponse
        ↓
组织响应并发送

这部分代码函数较多,但真正需要理解的是三件事:一条连接如何持续解析请求、静态资源和动态路由如何分流,以及响应边界如何与长短连接配合。

一、HttpServer 的职责与接口

动态路由由"路径正则表达式"和"业务处理函数"组成:

cpp 复制代码
using Handler =
    std::function<void(const HttpRequest &, HttpResponse *)>;
using Handlers =
    std::vector<std::pair<std::regex, Handler>>;

Handlers _get_route;
Handlers _post_route;
Handlers _put_route;
Handlers _delete_route;

Handler 把请求作为只读输入,再通过 HttpResponse * 让业务函数填写本次响应。按照请求方法拆成四张路由表,可以避免每次请求都遍历无关方法。Get()Post()Put()Delete() 在注册时就把字符串构造成 std::regex,真正收到请求时只负责匹配和调用。

SetBaseDir() 设置静态资源根目录,SetThreadCount() 配置底层线程数量,Listen() 最终启动 TcpServer。这些配置都应该在 Listen() 之前完成;服务器启动后,多个 IO 线程可能同时读取路由表,此时再修改路由会产生数据竞争。

二、核心流程:每条连接保存一个 HttpContext

连接建立时,OnConnected() 会通过 conn->SetContext(HttpContext()),在这条 ConnectionAny 上下文中保存一个独立的 HttpContext

这里不能让整个服务器共用一个解析器。不同客户端可能同时处于"请求行只收到一半""请求头已经完成"或"正文还差几十字节"等不同状态,只有一条连接对应一个上下文,解析进度才不会互相污染。

三个对象的分工可以这样理解:

  • Buffer 保存还没有处理的网络字节;
  • HttpContext 保存当前解析到了哪个阶段;
  • HttpRequest 保存已经解析出的请求字段。

OnMessage() 是整个类的核心:

cpp 复制代码
while (buffer->ReadAbleSize() > 0) {
    HttpContext *context =
        conn->GetContext()->get<HttpContext>();

    context->RecvHttpRequest(buffer);
    HttpRequest &req = context->Request();
    HttpResponse rsp(context->RespStatu());

    if (context->RespStatu() >= 400) {
        ErrorHandler(req, &rsp);
        WriteReponse(conn, req, rsp);
        buffer->MoveReadOffset(buffer->ReadAbleSize());
        conn->Shutdown();
        return;
    }

    if (context->RecvStatu() != RECV_HTTP_OVER) {
        return;
    }

    Route(req, &rsp);
    WriteReponse(conn, req, rsp);
    context->ReSet();

    if (rsp.Close()) {
        conn->Shutdown();
    }
}

如果解析阶段已经出错,当前字节流的报文边界也不再可信,因此代码会发送错误响应、丢弃缓冲区剩余数据并关闭连接。如果这次只收到半个请求,解析器则保留当前状态,OnMessage() 直接返回,等待下一批数据到来。如果请求已经完整,才执行路由、发送响应并重置上下文。

外层 while 则用于处理"一次读取中包含多条请求"的情况。解析器每次只消费当前请求对应的字节,处理完成后再检查 Buffer,有剩余数据就继续解析下一条请求。因此,半包依靠 HttpContext 跨事件保存状态,多条请求连续到达则依靠 while 逐条处理。

还要注意对象生命周期:reqHttpContext 内部对象的引用,调用 ReSet() 后内容就会被清空;rsp 是本轮循环中的栈对象。业务 Handler 只能同步使用它们,不能把请求引用、响应指针或 _matches 中的捕获结果直接保存到异步任务中;异步处理必须复制真正需要的数据。

三、静态资源与动态路由如何分流

Route() 会先判断静态资源,再进入功能性路由:

cpp 复制代码
if (IsFileHandler(req)) {
    return FileHandler(req, rsp);
}

if (req._method == "GET" || req._method == "HEAD") {
    return Dispatcher(req, rsp, _get_route);
}

一个请求想进入静态资源处理,需要同时满足四个条件:

  1. 已经设置静态资源根目录;
  2. 方法是 GETHEAD
  3. 请求路径通过合法性检查;
  4. 拼接后的目标存在并且是普通文件。

如果路径以 / 结尾,就自动追加 index.html。例如请求 /image/,最终会尝试读取 _basedir + "/image/index.html",再根据扩展名设置 Content-Type

因为静态资源判断发生在动态路由之前,所以同一路径既存在磁盘文件又注册了 GET 路由时,静态文件优先。这是当前实现明确形成的路由规则。

动态路由则会遍历当前方法对应的路由表:

cpp 复制代码
for (auto &handler : handlers) {
    if (std::regex_match(req._path, req._matches,
                         handler.first)) {
        return handler.second(req, rsp);
    }
}
rsp->_statu = 404;

regex_match 要求整个路径都符合规则,而 regex_search 只要求找到一个匹配子串。匹配成功后,req._matches[0] 保存完整路径,req._matches[1] 开始保存捕获组。比如注册:

cpp 复制代码
server.Get(R"(/numbers/(\d+))", NumberHandler);

请求 /numbers/123 时,业务函数就能从 _matches[1] 取得 123。路由按照注册顺序查找,第一个匹配成功的处理函数会立即返回,因此更具体的规则应该放在宽泛规则之前。

Dispatcher() 的返回类型和 Handler 都是 void,所以代码中的 return handler.second(req, rsp); 是合法写法:它先执行处理函数,再立即结束本次路由查找。

四、响应组织与连接关闭

WriteReponse() 会先补充 ConnectionContent-LengthContent-Type 和重定向的 Location,然后按照 HTTP 格式依次写入:

text 复制代码
状态行\r\n
响应头\r\n
\r\n
响应正文

其中 Content-Length 不只是正文大小,它还是长连接上的响应边界。一个 TCP 连接可能连续承载多组请求和响应,客户端必须知道当前响应在哪里结束,才能正确读取下一条响应。

连接是否复用需要同时考虑协议版本和 Connection。HTTP/1.1 默认使用持久连接,出现 Connection: close 才关闭;HTTP/1.0 通常默认关闭,显式声明 keep-alive 才复用。当前 HttpRequest::Close() 只有看到严格等于 keep-alive 的值才保留连接,会把没有 Connection 字段的 HTTP/1.1 请求误判为短连接,这一点后续需要修正。

在当前项目中,conn->Send() 会先复制待发送数据,再交给所属事件循环写入输出缓冲区。随后即使调用 Shutdown(),也不是立刻丢弃 fd,而是等待输出缓冲区发送完成后再释放连接,这就是当前 Connection 实现保证的优雅关闭。

五、当前实现容易出错的细节

1. HEAD 不能发送响应正文

HEAD 应返回与对应 GET 请求基本一致的状态行和响应头,但不能发送正文。当前代码会读取文件并把 _body 一起发出,不符合协议语义。统一组织响应时应先计算 GET 对应的长度,再根据方法决定是否追加正文:

cpp 复制代码
rsp.SetHeader("Content-Length",
              std::to_string(rsp._body.size()));
rsp_str << "\r\n";

if (req._method != "HEAD") {
    rsp_str << rsp._body;
}

std::string data = rsp_str.str();
conn->Send(data.data(), data.size());

这样也避免了原代码连续调用两次 rsp_str.str() 带来的重复字符串构造。进一步优化时,静态文件的 HEAD 请求可以通过文件信息取得长度,不必真的把整个文件读入内存。

2. 空正文也要考虑响应边界

当前代码只有 _body 非空时才添加 Content-Length。普通的空响应如果仍想保持连接,通常应明确发送 Content-Length: 0,否则客户端可能只能通过连接关闭判断响应结束。实际处理时还要区分 HEAD、1xx、204 和 304 等禁止携带正文或具有特殊长度语义的响应。

3. 决定关闭后不能继续处理剩余请求

当前代码调用 Shutdown() 后没有退出 while。如果缓冲区中还有流水线请求,服务器可能继续分发本应丢弃的后续请求。更稳妥的处理是清除剩余输入并立即返回:

cpp 复制代码
if (rsp.Close()) {
    buffer->MoveReadOffset(buffer->ReadAbleSize());
    conn->Shutdown();
    return;
}

4. 404、405 和 501 不是一回事

404 Not Found 表示目标资源不存在;405 Method Not Allowed 表示资源存在,但不允许当前方法,并且响应中应说明 Allow501 Not Implemented 则表示服务器没有实现该请求方法。

当前请求行正则只接受五种方法,而 Route() 又恰好处理了这五种方法,因此末尾的 405 分支基本无法到达。比如只注册了 GET /users/1,收到 POST /users/1 时,当前实现也只会在 POST 路由表中查找并返回 404。更完整的实现需要判断相同路径是否存在于其他方法的路由表。

5. 文件检查成功不代表读取一定成功

IsRegular()ReadFile() 之间,文件可能被删除、替换或因为权限问题读取失败。当前 FileHandler() 读取失败后直接返回,最终可能发送默认的空 200。这里应该设置合适的错误状态,并在 Route() 之后对没有正文的 4xx/5xx 响应统一调用 ErrorHandler()

6. _path.back() 隐含了路径非空

当前请求行正则允许空路径,而 IsFileHandler() 直接调用 req._path.back(),空字符串会造成未定义行为。处理静态资源前至少要保证路径非空并以 / 开头。

另外,目录穿越防护不能只统计 ..。更可靠的做法是先 URL 解码,再进行路径规范化,最后确认目标的真实路径仍位于静态资源根目录内,同时考虑 .、反斜杠和符号链接等情况。

关于 HEAD、方法状态码和持久连接的具体语义,可以参考 RFC 9110RFC 9112

六、面试重点

1. HttpServer 如何解耦网络层、协议层和业务层?

TcpServerConnection 只负责连接、字节收发和生命周期;HttpContext 负责把字节流解析成请求;HttpServer 负责路由和响应组织;业务 Handler 只读取 HttpRequest 并填写 HttpResponse

2. HttpContext 和外层 while 分别解决什么问题?

HttpContext 保存半包情况下的解析进度,外层 while 负责处理一次读取中连续出现的多条请求。前者解决跨事件续传,后者消费当前缓冲区中的剩余完整请求。

regex_match 要求整个字符串匹配,适合路由路径;regex_search 只要某个子串符合就返回成功。当前路由还采用首次匹配原则,因此注册顺序会影响结果。

4. HEAD、Content-Length 和长连接有什么关系?

HEAD 响应不能携带正文,但可以返回对应 GET 表示的长度。长连接又要求每条响应具有明确边界,所以不能因为 HEAD 不发送正文就随意删除描述资源长度的响应头。

5. 为什么 Send 后立即 Shutdown 不会丢数据?

因为 Send() 先把数据复制到输出缓冲区,Shutdown() 只把连接切换到等待关闭状态。事件循环会继续发送剩余数据,直到输出缓冲区为空后才真正释放连接。

6. 404、405 和 501 如何区分?

资源不存在返回 404;资源存在但不支持当前方法返回 405,并提供 Allow;服务器完全没有实现或识别该方法时返回 501。判断依据分别是资源、资源允许的方法以及服务器能力。

总结

今天完成的 HttpServer 把前面的网络层和 HTTP 解析层真正接入了业务处理:每条连接保存独立解析上下文,完整请求经过静态资源或正则路由生成响应,再由 Connection 的非阻塞发送流程写回客户端。

这部分真正的难点不是拼接一个响应字符串,而是维护字节流、请求状态、路由语义和连接生命周期之间的一致性。当前版本已经形成了完整主流程,后续可以继续完善 HEAD、响应长度、方法状态码和路径安全,再考虑大文件分块、sendfile、缓存以及更高效的路由结构。

相关推荐
重生的黑客16 小时前
Linux 进程程序替换与自定义 Shell:从 exec 函数族到命令行解释器
linux·运维·服务器·shell
北极糊的狐16 小时前
阿里云服务器-命令2-Linux 系统实时资源监视器 top 命令详解(进程级实时资源监控)
linux·运维·服务器
三言老师17 小时前
clear与history历史命令管理实操
linux·运维·服务器·网络·centos
味悲17 小时前
Linux 环境下 DNS 服务器搭建
linux·运维·服务器
greenbbLV18 小时前
中小公司积分商城选型:SaaS与私有化优劣对比分析
大数据·运维·人工智能
feasibility.18 小时前
wsl安装Ubuntu方法(含网络不稳定处理)
linux·运维·windows·ubuntu
w678200718 小时前
,攻击者通过构造特殊的XML使其包含恶意外部实体。外部实体可以为服务器敏感文件,也可以为网络请求等,之后利用方式类似于文件包含和SS ...
xml·服务器·网络
汽车网络安全爱好者19 小时前
Public Key Infrastructure(二)— 深入理解 X.509 证书:从 RFC 5280 到 OpenSSL 实践
运维·服务器·算法·网络安全·汽车·密码学·可信计算技术
阿标的博客19 小时前
Active Directory端口服务验证
服务器
落叶飘飘s20 小时前
彩笔运维勇闯机器学习--梯度下降法
运维·人工智能·机器学习