仿 muduo 高并发服务器项目:封装 HTTP 请求响应并用状态机完成增量解析

前言

前面已经完成了 BufferUtilBuffer 负责保存网络中到达的字节流,Util 提供字符串分割与 URL 解码等基础能力。今天继续向 HTTP 服务器推进,实现 HttpRequestHttpResponseHttpContext

这三个类把"收到一段 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-Lengthcontent-length 应被视为同一个字段。当前 unordered_map 和字符串查找区分大小写,可能导致合法正文长度没有被识别。RFC 9110 对字段名的这一语义有明确规定。

此外,SetHeader() 使用 insert(),相同键第二次插入不会覆盖旧值。响应头如果需要更新,更适合使用下标赋值或 insert_or_assign();请求中的重复头则不能简单覆盖或忽略,尤其要拒绝互相冲突的长度字段。

3. Content-Length 决定的不是普通数值,而是报文边界

std::stol() 可能抛出异常,Content-Length: -1 转成 size_t 后还可能得到一个极大的无符号数,尾部包含其他字符的值也需要拒绝。解析时应校验整个字段都是合法的非负十进制数,并设置正文大小上限。

当前实现也不支持 Transfer-Encoding: chunked。如果暂时不实现分块解析,就应明确拒绝该请求;同时出现 Transfer-EncodingContent-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 解析的安全重点?

它决定当前请求在哪里结束。非法、重复或互相冲突的长度信息如果被不同组件以不同方式解释,后续字节就可能被误认为下一条请求,形成请求错位甚至请求走私。

附:完整代码

下面记录今天完成的当前版本。代码依赖前面已经实现的 BufferUtil;正文中提到的大小写、长度校验、长连接语义和分块编码问题暂未在附录中修改。

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;
    }
};

总结

今天完成的 HttpRequestHttpResponse 分别描述了 HTTP 请求与响应,HttpContext 则利用状态机把请求行、请求头和正文从 Buffer 中逐步解析出来。即使请求被拆成多次到达,解析进度也不会丢失;即使多条请求一起到达,也能只消费当前请求对应的数据。

这部分真正的难点不是把字符串拆开,而是在任意分包情况下准确确定报文边界。当前版本已经建立了基本解析流程,下一步可以继续组织完整响应报文、接入路由分发,并补齐头部规范化、长度限制和分块编码等边界处理。

相关推荐
云泽8081 小时前
Linux 核心机制详解(三):文件系统权限与共享安全——从删除规则到粘滞位防护
linux·运维·安全
码农阿豪1 小时前
Prometheus怎么监控另一台Linux服务器?Node Exporter配置教程
linux·服务器·prometheus
三言老师1 小时前
CentOS7 / 8 yum 查询软件安装路径实操
运维·服务器·网络·centos
BullSmall1 小时前
Jenkins LTS JDK21 docker-compose.yml(生产可用)
运维·docker·jenkins
公众号:fuwuqiBMC1 小时前
(转自“服务器BMC”)服务器BMC芯片功能——LTPI简介
运维·服务器
倚栏听雨_ylty1 小时前
H3C交换机关闭Telnet和HTTP服务实例
运维·网络·tcp/ip·网络安全
念何架构之路1 小时前
docker-Builder镜像构建
运维·docker·容器
ShineWinsu1 小时前
对于Linux:http的解析
linux·网络·c++·网络协议·http·请求·响应
易筋紫容2 小时前
为什么做了 DevOps,你还是管不好开源依赖?
运维·开源·devops