前言
前面已经完成了 TcpServer、HttpRequest、HttpResponse 和 HttpContext:网络层能够接收 TCP 字节流,协议层也能够把字节流增量解析成 HTTP 请求。今天继续把这些模块串起来,实现真正面向业务使用的 HttpServer。
HttpServer 自己不负责 accept、recv 或 epoll,而是站在网络层和业务层之间,完成下面这条链路:
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()),在这条 Connection 的 Any 上下文中保存一个独立的 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 逐条处理。
还要注意对象生命周期:req 是 HttpContext 内部对象的引用,调用 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);
}
一个请求想进入静态资源处理,需要同时满足四个条件:
- 已经设置静态资源根目录;
- 方法是
GET或HEAD; - 请求路径通过合法性检查;
- 拼接后的目标存在并且是普通文件。
如果路径以 / 结尾,就自动追加 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() 会先补充 Connection、Content-Length、Content-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 表示资源存在,但不允许当前方法,并且响应中应说明 Allow;501 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 9110 与 RFC 9112。
六、面试重点
1. HttpServer 如何解耦网络层、协议层和业务层?
TcpServer 与 Connection 只负责连接、字节收发和生命周期;HttpContext 负责把字节流解析成请求;HttpServer 负责路由和响应组织;业务 Handler 只读取 HttpRequest 并填写 HttpResponse。
2. HttpContext 和外层 while 分别解决什么问题?
HttpContext 保存半包情况下的解析进度,外层 while 负责处理一次读取中连续出现的多条请求。前者解决跨事件续传,后者消费当前缓冲区中的剩余完整请求。
3. regex_match 和 regex_search 有什么区别?
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、缓存以及更高效的路由结构。