前言
前面已经完成了 Buffer 和 Util:Buffer 负责保存网络中到达的字节流,Util 提供字符串分割与 URL 解码等基础能力。今天继续向 HTTP 服务器推进,实现 HttpRequest、HttpResponse 和 HttpContext。
这三个类把"收到一段 TCP 数据"推进到了"得到一条结构化 HTTP 请求":
text
TCP 字节流
↓
Buffer
↓
HttpContext 分阶段解析
↓
HttpRequest
↓
业务处理并填写 HttpResponse
其中,HttpRequest 保存解析结果,HttpResponse 描述准备返回的响应,HttpContext 则记录请求还没有收完整时的解析进度。
一、HttpRequest 与 HttpResponse 的职责
HttpRequest 保存一条请求的核心信息,包括请求方法、资源路径、协议版本、正文、请求头和查询参数。_matches 则为后续路由正则匹配保留提取结果。
cpp
std::string _method;
std::string _path;
std::string _version;
std::string _body;
std::smatch _matches;
std::unordered_map<std::string, std::string> _headers;
std::unordered_map<std::string, std::string> _params;
ReSet() 会清空上一条请求的数据,使同一个上下文能够继续处理长连接上的下一条请求。std::smatch 没有直接的 clear(),因此这里构造一个空对象并通过 swap() 完成重置。
ContentLength() 根据请求头判断正文长度,Close() 判断本次请求结束后是否关闭连接。这两个接口看起来简单,却直接影响报文边界和连接复用,也是后面最需要完善的地方。
HttpResponse 保存状态码、响应头、正文和重定向信息。普通响应可以通过 SetContent() 同时设置正文与 Content-Type;重定向则通过 SetRedirect() 记录状态码和目标地址,后续组织响应报文时再生成对应的 Location 头。
二、HttpContext:为什么解析 HTTP 需要状态机
TCP 是字节流协议,一次读取不保证正好得到一条完整请求。请求可能被拆成多次到达,也可能和下一条请求一起到达,因此解析器必须记住当前进行到了哪一步。
cpp
typedef enum {
RECV_HTTP_ERROR,
RECV_HTTP_LINE,
RECV_HTTP_HEAD,
RECV_HTTP_BODY,
RECV_HTTP_OVER
} HttpRecvStatu;
状态变化如下:
text
RECV_HTTP_LINE
↓
RECV_HTTP_HEAD
↓
RECV_HTTP_BODY
↓
RECV_HTTP_OVER
任意阶段解析失败 → RECV_HTTP_ERROR
三个常见场景都依赖这套状态:
- 完整请求一次到达时,可以连续解析请求行、请求头和正文;
- 请求被拆开时,保留当前状态,等待下一批数据继续处理;
- 两条请求粘在一起时,只消费第一条请求所需的字节,把剩余数据留在
Buffer中。
RecvHttpRequest() 中的 switch 故意没有写 break:
cpp
switch (_recv_statu) {
case RECV_HTTP_LINE: RecvHttpLine(buf);
case RECV_HTTP_HEAD: RecvHttpHead(buf);
case RECV_HTTP_BODY: RecvHttpBody(buf);
}
如果 Buffer 已经包含完整请求,解析完请求行后就应立即继续处理请求头,而不是退出并等待下一次网络事件。每个阶段函数开头都会检查当前状态,所以数据不足或发生错误后,后续阶段会直接返回。
三、请求行、请求头和正文的分阶段解析
1. 解析请求行
请求行通过正则表达式拆出四部分:
text
matches[1]:请求方法
matches[2]:资源路径
matches[3]:查询字符串
matches[4]:HTTP 版本
方法统一转换为大写,路径和查询参数则分别进行 URL 解码:
cpp
_request._path = Util::UrlDecode(matches[2], false);
std::string key = Util::UrlDecode(str.substr(0, pos), true);
std::string val = Util::UrlDecode(str.substr(pos + 1), true);
路径中的 + 就是普通加号,不应自动变为空格;表单风格的查询字符串中,+ 通常表示空格,所以两处解码参数不同。
2. 逐行解析请求头
请求头同样使用 GetLineAndPop() 逐行获取。读到 \n 或 \r\n 空行,说明头部结束,可以进入正文阶段;暂时读不到完整一行时,则保留 RECV_HTTP_HEAD 状态等待后续数据。
普通头部按第一个 ": " 分成字段名和值,再保存到 _headers。当前写法适合学习基本格式,但它只接受冒号后恰好一个空格,工程实现还应支持合法的可选空白并校验字段名。
3. 按 Content-Length 增量接收正文
正文阶段先计算还缺少多少数据:
cpp
size_t real_len = content_length - _request._body.size();
如果 Buffer 中的数据足够,就只读取 real_len 个字节并进入 RECV_HTTP_OVER;多出的字节可能属于下一条请求,不能一起取走。如果数据不足,则先把当前可读内容追加到 _body,下次继续接收。
这正是处理半包和粘包的关键:不要猜测一次 recv() 对应几条请求,而要按照 HTTP 自己的报文边界精确移动读偏移。
四、当前实现容易出错的细节
1. switch 的贯穿必须明确表达
当前无 break 是有意设计,不是遗漏。但这种写法容易触发编译器警告,也可能被维护者误加 break。更清晰的做法是添加 [[fallthrough]],或者使用循环根据状态持续推进,并显式区分"继续解析、等待数据、完成、错误"四种结果。
2. 请求头字段名不区分大小写
HTTP 字段名不区分大小写,因此 Content-Length 和 content-length 应被视为同一个字段。当前 unordered_map 和字符串查找区分大小写,可能导致合法正文长度没有被识别。RFC 9110 对字段名的这一语义有明确规定。
此外,SetHeader() 使用 insert(),相同键第二次插入不会覆盖旧值。响应头如果需要更新,更适合使用下标赋值或 insert_or_assign();请求中的重复头则不能简单覆盖或忽略,尤其要拒绝互相冲突的长度字段。
3. Content-Length 决定的不是普通数值,而是报文边界
std::stol() 可能抛出异常,Content-Length: -1 转成 size_t 后还可能得到一个极大的无符号数,尾部包含其他字符的值也需要拒绝。解析时应校验整个字段都是合法的非负十进制数,并设置正文大小上限。
当前实现也不支持 Transfer-Encoding: chunked。如果暂时不实现分块解析,就应明确拒绝该请求;同时出现 Transfer-Encoding 和 Content-Length 时更不能继续按普通请求处理,因为不同节点采用不同边界可能引发请求走私。RFC 9112 专门规定了 HTTP/1.1 的报文长度判断顺序。
4. 长短连接需要结合协议版本
当前 Close() 只有在头部值严格等于 "keep-alive" 时才保留连接,这会把没有 Connection 字段的 HTTP/1.1 请求判断为短连接。
HTTP/1.1 默认使用持久连接,出现 Connection: close 才关闭;HTTP/1.0 通常默认关闭,显式声明 keep-alive 才复用。字段值还应按照大小写无关的 token 解析,而不是只做一次完全相等比较。
5. 不仅要限制单行长度
MAX_LINE 可以限制请求行或单个头部行,但大量较短的请求头仍可能耗尽内存。完整实现还应限制头部总字节数、字段数量和正文长度。请求目标过长可以返回 414,请求头过大更适合返回 431,正文过大则应返回 413。
五、面试重点
1. 为什么要把 HttpContext 和 HttpRequest 分开?
HttpRequest 表示最终解析结果,而 HttpContext 还要保存解析阶段、错误状态和响应状态码。请求发生半包时,最终对象尚未完整,但上下文必须跨多次网络事件继续存在。
2. 为什么 HTTP 解析器需要状态机?
因为 TCP 没有消息边界。请求行、请求头和正文可能分多次到达,也可能一次到达多条请求。状态机让解析器能够在数据不足时暂停,并在新数据到达后从正确阶段继续。
3. switch 中为什么没有 break?
这是为了让一次已经到齐的数据连续经过请求行、请求头和正文三个阶段。该写法依赖每个函数对 _recv_statu 的检查,工程中应显式标注 fallthrough,避免维护时破坏控制流。
4. 如何正确处理半包和粘包?
半包时保存已解析的状态和正文片段,等待下一次接收;粘包时严格按照空行、Content-Length 或受支持的传输编码确定边界,只移动当前请求对应的读偏移。
5. 为什么 Content-Length 是 HTTP 解析的安全重点?
它决定当前请求在哪里结束。非法、重复或互相冲突的长度信息如果被不同组件以不同方式解释,后续字节就可能被误认为下一条请求,形成请求错位甚至请求走私。
附:完整代码
下面记录今天完成的当前版本。代码依赖前面已经实现的 Buffer 和 Util;正文中提到的大小写、长度校验、长连接语义和分块编码问题暂未在附录中修改。
cpp
class HttpRequest {
public:
std::string _method; // 请求方法
std::string _path; // 资源路径
std::string _version; // 协议版本
std::string _body; // 请求正文
std::smatch _matches; // 资源路径的正则提取数据
std::unordered_map<std::string, std::string> _headers; // 头部字段
std::unordered_map<std::string, std::string> _params; // 查询字符串
public:
HttpRequest() : _version("HTTP/1.1") {}
void ReSet() {
_method.clear();
_path.clear();
_version = "HTTP/1.1";
_body.clear();
std::smatch match;
_matches.swap(match);
_headers.clear();
_params.clear();
}
// 插入头部字段
void SetHeader(const std::string &key, const std::string &val) {
_headers.insert(std::make_pair(key, val));
}
// 判断是否存在指定头部字段
bool HasHeader(const std::string &key) const {
auto it = _headers.find(key);
if (it == _headers.end()) {
return false;
}
return true;
}
// 获取指定头部字段的值
std::string GetHeader(const std::string &key) const {
auto it = _headers.find(key);
if (it == _headers.end()) {
return "";
}
return it->second;
}
// 插入查询字符串
void SetParam(const std::string &key, const std::string &val) {
_params.insert(std::make_pair(key, val));
}
// 判断是否有某个指定的查询字符串
bool HasParam(const std::string &key) const {
auto it = _params.find(key);
if (it == _params.end()) {
return false;
}
return true;
}
// 获取指定的查询字符串
std::string GetParam(const std::string &key) const {
auto it = _params.find(key);
if (it == _params.end()) {
return "";
}
return it->second;
}
// 获取正文长度
size_t ContentLength() const {
// Content-Length: 1234\r\n
bool ret = HasHeader("Content-Length");
if (ret == false) {
return 0;
}
std::string clen = GetHeader("Content-Length");
return std::stol(clen);
}
// 判断是否是短链接
bool Close() const {
// 没有 Connection 字段,或者有 Connection 但是值是 close,
// 则都是短链接,否则就是长连接
if (HasHeader("Connection") == true &&
GetHeader("Connection") == "keep-alive") {
return false;
}
return true;
}
};
class HttpResponse {
public:
int _statu;
bool _redirect_flag;
std::string _body;
std::string _redirect_url;
std::unordered_map<std::string, std::string> _headers;
public:
HttpResponse() : _redirect_flag(false), _statu(200) {}
HttpResponse(int statu) : _redirect_flag(false), _statu(statu) {}
void ReSet() {
_statu = 200;
_redirect_flag = false;
_body.clear();
_redirect_url.clear();
_headers.clear();
}
// 插入头部字段
void SetHeader(const std::string &key, const std::string &val) {
_headers.insert(std::make_pair(key, val));
}
// 判断是否存在指定头部字段
bool HasHeader(const std::string &key) {
auto it = _headers.find(key);
if (it == _headers.end()) {
return false;
}
return true;
}
// 获取指定头部字段的值
std::string GetHeader(const std::string &key) {
auto it = _headers.find(key);
if (it == _headers.end()) {
return "";
}
return it->second;
}
void SetContent(const std::string &body,
const std::string &type = "text/html") {
_body = body;
SetHeader("Content-Type", type);
}
void SetRedirect(const std::string &url, int statu = 302) {
_statu = statu;
_redirect_flag = true;
_redirect_url = url;
}
// 判断是否是短链接
bool Close() {
// 没有 Connection 字段,或者有 Connection 但是值是 close,
// 则都是短链接,否则就是长连接
if (HasHeader("Connection") == true &&
GetHeader("Connection") == "keep-alive") {
return false;
}
return true;
}
};
typedef enum {
RECV_HTTP_ERROR,
RECV_HTTP_LINE,
RECV_HTTP_HEAD,
RECV_HTTP_BODY,
RECV_HTTP_OVER
} HttpRecvStatu;
#define MAX_LINE 8192
class HttpContext {
private:
int _resp_statu; // 响应状态码
HttpRecvStatu _recv_statu; // 当前接收及解析的阶段状态
HttpRequest _request; // 已经解析得到的请求信息
private:
bool ParseHttpLine(const std::string &line) {
std::smatch matches;
std::regex e(
"(GET|HEAD|POST|PUT|DELETE) ([^?]*)(?:\\?(.*))? "
"(HTTP/1\\.[01])(?:\n|\r\n)?",
std::regex::icase);
bool ret = std::regex_match(line, matches, e);
if (ret == false) {
_recv_statu = RECV_HTTP_ERROR;
_resp_statu = 400; // BAD REQUEST
return false;
}
// 0: GET /bitejiuyeke/login?user=xiaoming&pass=123123 HTTP/1.1
// 1: GET
// 2: /bitejiuyeke/login
// 3: user=xiaoming&pass=123123
// 4: HTTP/1.1
// 请求方法的获取
_request._method = matches[1];
std::transform(_request._method.begin(),
_request._method.end(),
_request._method.begin(),
::toupper);
// 资源路径的获取,需要进行 URL 解码操作,但是不需要 + 转空格
_request._path = Util::UrlDecode(matches[2], false);
// 协议版本的获取
_request._version = matches[4];
// 查询字符串的获取与处理
std::vector<std::string> query_string_arry;
std::string query_string = matches[3];
// 查询字符串的格式 key=val&key=val...,
// 先以 & 符号进行分割,得到各个字串
Util::Split(query_string, "&", &query_string_arry);
// 针对各个字串,以 = 符号进行分割,得到 key 和 val,
// 得到之后也需要进行 URL 解码
for (auto &str : query_string_arry) {
size_t pos = str.find("=");
if (pos == std::string::npos) {
_recv_statu = RECV_HTTP_ERROR;
_resp_statu = 400; // BAD REQUEST
return false;
}
std::string key =
Util::UrlDecode(str.substr(0, pos), true);
std::string val =
Util::UrlDecode(str.substr(pos + 1), true);
_request.SetParam(key, val);
}
return true;
}
bool RecvHttpLine(Buffer *buf) {
if (_recv_statu != RECV_HTTP_LINE) return false;
// 1. 获取一行数据,带有末尾的换行
std::string line = buf->GetLineAndPop();
// 2. 需要考虑:缓冲区中的数据不足一行,或一行数据过大
if (line.size() == 0) {
// 缓冲区中的数据不足一行,如果很长了仍不足一行,
// 说明请求存在问题
if (buf->ReadAbleSize() > MAX_LINE) {
_recv_statu = RECV_HTTP_ERROR;
_resp_statu = 414; // URI TOO LONG
return false;
}
// 数据不足一行但长度正常,等待新数据到来
return true;
}
if (line.size() > MAX_LINE) {
_recv_statu = RECV_HTTP_ERROR;
_resp_statu = 414; // URI TOO LONG
return false;
}
bool ret = ParseHttpLine(line);
if (ret == false) {
return false;
}
// 首行处理完毕,进入头部获取阶段
_recv_statu = RECV_HTTP_HEAD;
return true;
}
bool RecvHttpHead(Buffer *buf) {
if (_recv_statu != RECV_HTTP_HEAD) return false;
// 一行一行取出数据,直到遇到空行为止
// 头部格式:key: val\r\nkey: val\r\n...
while (1) {
std::string line = buf->GetLineAndPop();
// 需要考虑:缓冲区中的数据不足一行,或一行数据过大
if (line.size() == 0) {
// 缓冲区中的数据不足一行,如果很长了仍不足一行,
// 说明请求存在问题
if (buf->ReadAbleSize() > MAX_LINE) {
_recv_statu = RECV_HTTP_ERROR;
_resp_statu = 414; // URI TOO LONG
return false;
}
// 数据不足一行但长度正常,等待新数据到来
return true;
}
if (line.size() > MAX_LINE) {
_recv_statu = RECV_HTTP_ERROR;
_resp_statu = 414; // URI TOO LONG
return false;
}
if (line == "\n" || line == "\r\n") {
break;
}
bool ret = ParseHttpHead(line);
if (ret == false) {
return false;
}
}
// 头部处理完毕,进入正文获取阶段
_recv_statu = RECV_HTTP_BODY;
return true;
}
bool ParseHttpHead(std::string &line) {
// key: val\r\nkey: val\r\n...
if (line.back() == '\n') line.pop_back();
if (line.back() == '\r') line.pop_back();
size_t pos = line.find(": ");
if (pos == std::string::npos) {
_recv_statu = RECV_HTTP_ERROR;
_resp_statu = 400;
return false;
}
std::string key = line.substr(0, pos);
std::string val = line.substr(pos + 2);
_request.SetHeader(key, val);
return true;
}
bool RecvHttpBody(Buffer *buf) {
if (_recv_statu != RECV_HTTP_BODY) return false;
// 1. 获取正文长度
size_t content_length = _request.ContentLength();
if (content_length == 0) {
// 没有正文,则请求接收解析完毕
_recv_statu = RECV_HTTP_OVER;
return true;
}
// 2. 当前已经接收了多少正文,
// 其实就是往 _request._body 中放了多少数据
size_t real_len =
content_length - _request._body.size();
// 3. 接收正文放到 body 中,同时考虑当前缓冲区中的数据
// 3.1 缓冲区中包含当前请求的所有剩余正文
if (buf->ReadAbleSize() >= real_len) {
_request._body.append(buf->ReadPosition(), real_len);
buf->MoveReadOffset(real_len);
_recv_statu = RECV_HTTP_OVER;
return true;
}
// 3.2 缓冲区中的数据不足,先取出已有数据,等待新数据到来
_request._body.append(buf->ReadPosition(),
buf->ReadAbleSize());
buf->MoveReadOffset(buf->ReadAbleSize());
return true;
}
public:
HttpContext()
: _resp_statu(200),
_recv_statu(RECV_HTTP_LINE) {}
void ReSet() {
_resp_statu = 200;
_recv_statu = RECV_HTTP_LINE;
_request.ReSet();
}
int RespStatu() {
return _resp_statu;
}
HttpRecvStatu RecvStatu() {
return _recv_statu;
}
HttpRequest &Request() {
return _request;
}
// 接收并解析 HTTP 请求
void RecvHttpRequest(Buffer *buf) {
// 不同状态做不同的事情,这里不要 break。
// 处理完请求行后,应立即处理头部,而不是等待新数据。
switch (_recv_statu) {
case RECV_HTTP_LINE:
RecvHttpLine(buf);
case RECV_HTTP_HEAD:
RecvHttpHead(buf);
case RECV_HTTP_BODY:
RecvHttpBody(buf);
}
return;
}
};
总结
今天完成的 HttpRequest 和 HttpResponse 分别描述了 HTTP 请求与响应,HttpContext 则利用状态机把请求行、请求头和正文从 Buffer 中逐步解析出来。即使请求被拆成多次到达,解析进度也不会丢失;即使多条请求一起到达,也能只消费当前请求对应的数据。
这部分真正的难点不是把字符串拆开,而是在任意分包情况下准确确定报文边界。当前版本已经建立了基本解析流程,下一步可以继续组织完整响应报文、接入路由分发,并补齐头部规范化、长度限制和分块编码等边界处理。