云计算百科
云计算领域专业知识百科平台

从“能响应”到“扛边界”:用 6 组客户端测试验收 C++ HTTP 服务器

前言

前面已经陆续完成了 Socket、Buffer、Channel、Poller、EventLoop、TimerWheel、Connection、TcpServer、HTTP 状态机和 HttpServer。到这里,一个基于 Reactor 模式的 C++ HTTP 服务器已经能够接收连接、解析请求、匹配路由并返回响应。

但是,服务器能正确响应一次 GET /hello,只能说明最基本的主流程已经打通。真正容易出问题的地方,往往藏在下面这些场景中:

  • 一条长连接持续通信,生存时间超过空闲超时阈值;
  • 客户端发送一次请求后长期不再活动;
  • Content-Length 与实际正文长度不一致;
  • 某个业务回调长时间阻塞事件循环;
  • 多条 HTTP 请求在同一段 TCP 字节流中连续到达;
  • 一个较大的请求体被拆成多次网络事件接收。

因此,在项目完成以后,我又写了 6 个客户端,从连接生命周期、HTTP 报文边界、事件安全和大请求体传输几个方向进行最后一轮验收。

这篇文章不只记录“客户端发送了什么”,还会分析服务器为什么应该出现对应行为,以及当前测试代码本身还有哪些可以继续完善的地方。

一、测试前先明确三个事实

1. TCP 是字节流,没有消息边界

客户端调用一次 send(),不代表服务器一定通过一次 recv() 得到相同的数据;客户端连续调用三次 send(),服务端也可能一次全部收到。

TCP 只保证字节按顺序到达,不保证应用层消息的分段方式。因此,HTTP 服务器不能把“一次读取”直接当成“一条请求”,而必须通过请求行、空行、Content-Length 等协议字段确定真正的报文边界。

2. Content-Length 决定后续字节属于谁

在本文测试没有使用 Transfer-Encoding、只通过 Content-Length 定界正文的前提下,如果请求头声明:

Content-Length: 100

那么服务器就会把头部结束后的 100 个字节视为当前请求的正文。即使客户端只想发送 11 个正文字符,后面紧接着发送的下一条 HTTP 请求,也会被服务器继续当作前一条请求的正文。

服务器无法猜测客户端的真实意图,只能按照协议给出的边界解析字节流。工程实现还需要正确处理 Transfer-Encoding、冲突长度字段和不支持的分块编码,不能无条件把所有请求都简化成这一种定界方式。

3. 空闲超时不是连接总时长

假设服务器设置了 10 秒非活跃超时:

  • 一条连接存在了 1 分钟,但每 3 秒都有通信,它仍然是活跃连接;
  • 一条连接只建立了 10 秒,但期间没有任何事件,它就应该被回收。

因此,长连接测试需要同时验证两个方向:活跃连接不能被误删,真正空闲的连接也不能一直占用 fd 和内存。

二、六组测试概览

本轮客户端统一连接本机 8085 端口,普通请求访问 /hello,上传测试则使用 PUT /1234.txt。若服务器使用项目中常见的 10 秒非活跃超时配置,那么 3 秒和 15 秒两个发送间隔刚好可以分别覆盖“持续活跃”和“已经空闲超时”两种情况。上传测试还要求服务器提前注册对应的 PUT Handler,并由 Handler 把请求正文写入目标文件。

这些请求是面向当前自定义解析器编写的简化报文,省略了 HTTP/1.1 正式请求通常必须携带的 Host 字段。若用于通用 HTTP 兼容性测试,应补上 Host: 127.0.0.1:8085;本文为了保持原测试代码以及后面的字节数推导不变,仍按当前简化格式分析。

下面记录的是测试代码路径分析和可执行的验收标准。由于这里没有附运行日志、fd 指标或文件哈希,未展示证据的部分不作为“已经实测通过”的结论。

客户端测试重点服务器的验收标准
client1.cpp 活跃长连接 持续刷新活跃度,连接总时长超过阈值也不被误回收
client2.cpp 空闲连接回收 长时间没有事件后主动释放连接
client3.cpp 错误的 Content-Length 按声明长度等待正文,流错位后安全报错或关闭,不能崩溃
client4.cpp 并发事件与慢业务 超时释放不能导致当前就绪事件访问悬空对象
client5.cpp 多条请求连续到达 逐条解析三条请求,并按顺序生成三条响应
client6.cpp 大请求体上传 在已注册保存 Handler 的前提下,完整接收正文并校验落盘内容

下面逐项分析。

三、测试一:持续活跃的长连接不能被误回收

1. 客户端代码

/* 长连接测试:
* 客户端持续给服务器发送数据。
* 即使连接总生存时间超过空闲超时阈值,
* 只要一直活跃,就不应该被服务器回收。
*/

#include "../source/server.hpp"

int main()
{
Socket cli_sock;
cli_sock.CreateClient(8085, "127.0.0.1");

std::string req =
"GET /hello HTTP/1.1\\r\\n"
"Connection: keep-alive\\r\\n"
"Content-Length: 0\\r\\n\\r\\n";

while (1) {
assert(cli_sock.Send(req.c_str(), req.size()) != 1);

char buf[1024] = {0};
assert(cli_sock.Recv(buf, 1023));
DBG_LOG("[%s]", buf);

sleep(3);
}

cli_sock.Close();
return 0;
}

2. 这个测试真正验证的是什么

客户端每隔 3 秒发送一条请求。假设服务器的非活跃超时是 10 秒,那么该连接的 Channel 事件被处理后,连接对应的定时任务会被刷新到新的过期位置。当前回调顺序是先执行读写及同步业务回调,最后再刷新活跃度;这个顺序在后面的慢业务测试中非常关键。

这里“超过超时时间”的不是两次通信之间的空闲时长,而是连接的总生存时间。比如客户端连续运行 1 分钟,连接早已存在超过 10 秒,但因为一直有事件发生,它不应该被释放。

服务器内部的逻辑可以概括为:

建立连接

添加空闲超时任务

收到请求或发生连接事件

刷新该连接的超时任务

继续保留连接

3. 通过标准

这个客户端至少连续运行多个超时周期,并且满足:

  • 每次请求都能收到正常响应;
  • 服务端不会在某个固定时间点误删连接;
  • 同一条 TCP 连接可以被持续复用;
  • 配合服务端定时器日志,可以确认任务被反复刷新后没有重复触发释放。

这组测试验证的是“活跃连接保活”,而不是“空闲连接关闭”。

四、测试二:真正空闲的连接应该被及时释放

1. 客户端代码

/* 超时连接测试:
* 发送并接收一次数据后等待较长时间,
* 查看服务器是否会按非活跃超时释放连接。
*/

#include "../source/server.hpp"

int main()
{
Socket cli_sock;
cli_sock.CreateClient(8085, "127.0.0.1");

std::string req =
"GET /hello HTTP/1.1\\r\\n"
"Connection: keep-alive\\r\\n"
"Content-Length: 0\\r\\n\\r\\n";

while (1) {
assert(cli_sock.Send(req.c_str(), req.size()) != 1);

char buf[1024] = {0};
assert(cli_sock.Recv(buf, 1023));
DBG_LOG("[%s]", buf);

sleep(15);
}

cli_sock.Close();
return 0;
}

2. 代码行为与注释的区别

原本的测试意图是“发送一次以后不再活动”,但当前代码仍然保留了 while 循环,只是把两轮通信之间的间隔改成了 15 秒。

如果服务器设置的是 10 秒非活跃超时,那么完整过程是:

第 0 秒:发送请求并收到响应
第 0~10 秒:连接没有任何活动
约第 10 秒附近:服务器开始处理到期任务并释放连接
第 15 秒:客户端进入下一轮,探测原连接是否已经关闭

时间轮通常还有 1 秒级 tick 精度,实际关闭时刻也会受连接加入槽位的时间、事件循环阻塞和任务调度影响,所以这里不应把“第 10 秒”理解成严格的墙上时间。

因此,这段代码可以发起一次超时关闭探测,第二轮 Send() 或 Recv() 承担的是“检查原连接是否已经被服务器关闭”的作用。不过,首次向已收到 FIN 的连接发送仍可能暂时成功,当前 assert(Recv(…)) 也不能可靠识别 -1。要形成可信结论,还需要显式检查收发返回值,或者结合服务端的超时关闭日志。

3. 通过标准

  • 服务器在连接空闲超过阈值以后将其移出连接表;
  • 对应的 Channel、socket 和定时任务都得到清理;
  • 客户端下一次通信时能够感知连接关闭或重置;
  • 服务端不会留下失效连接,也不会重复释放同一对象。

如果想让代码与“只发送一次”的注释完全一致,可以去掉外层循环:完成一次请求后直接等待超过超时阈值,再通过一次接收或发送检查连接状态。

五、测试三:故意伪造 Content-Length

1. 客户端代码

/* 声明一个大于实际正文的 Content-Length,
* 查看服务器如何处理不完整请求以及后续字节流错位。
*/

#include "../source/server.hpp"

int main()
{
Socket cli_sock;
cli_sock.CreateClient(8085, "127.0.0.1");

std::string req =
"GET /hello HTTP/1.1\\r\\n"
"Connection: keep-alive\\r\\n"
"Content-Length: 100\\r\\n\\r\\n"
"bitejiuyeke";

while (1) {
assert(cli_sock.Send(req.c_str(), req.size()) != 1);
assert(cli_sock.Send(req.c_str(), req.size()) != 1);
assert(cli_sock.Send(req.c_str(), req.size()) != 1);

char buf[1024] = {0};
assert(cli_sock.Recv(buf, 1023));
DBG_LOG("[%s]", buf);

sleep(3);
}

cli_sock.Close();
return 0;
}

2. 实际数据并不是 1024 字节

这段测试最需要先澄清的一点是:代码中声明的正文长度是 100 字节,不是注释中提到的 1024 字节;实际正文 "bitejiuyeke" 则只有 11 字节。

如果只发送一次,服务器解析完请求头以后会发现:

声明正文长度:100 字节
当前收到正文: 11 字节
仍然缺少: 89 字节

此时服务器不应该提前进入业务处理,因为它还没有得到一条完整请求。正确行为是保留当前 HttpContext 的 BODY 状态,等待后续数据;如果客户端一直不再发送,最终由空闲超时机制释放连接。

3. 连续发送三次后会发生什么

当前代码不是只发送一次,而是连续发送三份相同内容。在三次 Send() 都完整提交 79 字节的前提下,单份字符串的长度可以拆成:

组成部分字节数
请求行和请求头 68
实际正文 bitejiuyeke 11
单份请求字符串总长 79

第一条请求声明需要 100 字节正文,因此解析器会依次消费:

  • 第一份数据中的 11 字节正文;
  • 完整的第二份 79 字节数据;
  • 第三份数据开头的 10 字节 "GET /hello"。
  • 正好得到:

    11 + 79 + 10 = 100

    于是,在服务器看来,第一条请求终于完整了。但是它的正文已经变成:

    bitejiuyeke
    + 完整的第二条“请求”
    + 第三条请求开头的 GET /hello

    处理完第一条请求并重置 HTTP 上下文后,输入缓冲区还剩下第三份数据的后 69 字节,并且开头已经变成:

    HTTP/1.1\\r\\n
    Connection: keep-alive\\r\\n

    这显然不再是一条合法请求行。按照当前 HttpServer::OnMessage() 的错误分支,下一轮解析会生成 400 Bad Request 一类的错误响应、丢弃剩余输入,并关闭这条已经失去可靠边界的连接。

    4. 这个现象是不是服务器解析错误

    不是。

    服务器只能相信客户端声明的 Content-Length。在第一条请求正文没有收满以前,后续到达的字节都必须被视为当前正文,而不能仅凭它们“长得像 GET 请求”就擅自切换边界。

    这个测试真正验证的是:

    • 半包时,服务器不会提前执行业务;
    • 如果传输在网络层发生分段,BODY 状态能够跨事件保存并继续接收;
    • 一旦后续请求行因边界错位而非法,服务器能够安全报错并关闭连接;
    • 整个过程中不能发生越界访问、死循环或崩溃。

    它也说明了为什么 Content-Length 不只是一个普通请求头,而是 HTTP 消息解析中的安全边界。当前三次连续 Send() 并没有强制产生三次网络事件,因此这段程序主要稳定构造的是字节流错位;如果要专门覆盖“跨事件续接”,还需要控制发送节奏或减小每次发送长度。

    六、测试四:慢业务、超时任务与事件生命周期

    1. 客户端代码

    /* 并发与业务处理超时测试 */
    #include "../source/server.hpp"

    int main()
    {
    signal(SIGCHLD, SIG_IGN);

    for (int i = 0; i < 10; i++) {
    pid_t pid = fork();

    if (pid < 0) {
    DBG_LOG("FORK ERROR");
    return 1;
    } else if (pid == 0) {
    Socket cli_sock;
    cli_sock.CreateClient(8085, "127.0.0.1");

    std::string req =
    "GET /hello HTTP/1.1\\r\\n"
    "Connection: keep-alive\\r\\n"
    "Content-Length: 0\\r\\n\\r\\n";

    while (1) {
    assert(cli_sock.Send(req.c_str(),
    req.size()) != 1);

    char buf[1024] = {0};
    assert(cli_sock.Recv(buf, 1023));
    DBG_LOG("[%s]", buf);
    }

    cli_sock.Close();
    exit(0);
    }
    }

    while (1) sleep(1);
    return 0;
    }

    2. 这段客户端本身制造的是并发压力

    父进程通过 fork() 创建 10 个子进程,每个子进程都建立独立连接并不断请求 /hello。SIGCHLD 被设置为忽略,用于避免子进程退出后形成僵尸进程。

    需要注意的是,客户端代码本身并没有制造“单次业务处理 30 秒”。要复现注释描述的慢业务场景,还需要让服务器的 /hello 处理函数主动阻塞,并且阻塞时间超过非活跃超时阈值。例如:

    void Hello(const HttpRequest &req, HttpResponse *rsp)
    {
    sleep(30);
    rsp->SetContent("hello", "text/plain");
    }

    如果不增加这个前置条件,client4.cpp 主要验证的是多进程并发连接和持续请求稳定性。

    3. 为什么慢业务可能引发悬空事件

    一次 epoll_wait() 可能同时返回多个已经就绪的 Channel。下面只是用于说明风险的一种可能顺序,epoll 并不保证活跃事件按照这个排列返回:

    actives = [连接1, timerfd, 连接3, 连接4, 连接5]

    假设处理连接 1 时,业务回调阻塞了 30 秒:

    处理连接1

    业务阻塞30秒

    timerfd 已累计多次超时

    开始处理 timerfd 事件

    这 30 秒内,同一个 EventLoop 上的其他连接即使原本已经就绪,也没有机会及时进入自己的回调并刷新活跃度。慢连接自身的活跃度刷新同样要等业务回调返回。在当前按照“尚未追赶的 _tick + delay”选择刷新槽位的实现中,即使慢回调返回后先执行了刷新,随后 timerfd 一次追赶大量累计 tick 时,新槽位仍可能很快被走到。因此,慢连接自身或同一事件循环上的其他连接都有可能被判定到期。

    主从 Reactor 下,其他工作 EventLoop 和主监听循环通常仍能继续运行;只有当多个工作线程都被慢业务占满时,影响才会进一步扩大。由于 epoll 的返回顺序不固定,单次运行也不一定命中上面的危险排列,稳定复现需要重复压力、服务端日志或专门的故障注入。

    危险点在于:actives 中还保存着连接 3、4、5 对应的 Channel *。如果定时器回调当场销毁这些连接对象,事件循环继续遍历 actives 时,就会访问已经释放的内存,最终出现段错误。

    timerfd 回调立即删除连接3

    actives 中仍保存连接3的 Channel *

    随后调用 Channel::HandleEvent()

    访问悬空指针,程序崩溃

    4. 为什么释放操作要延后

    当前项目中的 Connection::Release() 不直接清理连接,而是把真正的 ReleaseInLoop() 放入事件循环任务队列。

    事件循环的顺序是:

    epoll_wait

    处理本批全部活跃 Channel

    执行任务队列

    因此,超时回调只负责登记“这条连接需要释放”。本批活跃事件处理完成后,所属工作 EventLoop 才执行 ReleaseInLoop(),移除 Channel 并关闭 socket;服务器连接表的删除还可能通过关闭回调继续投递到主 EventLoop,分阶段完成。

    这样可以避免“本批 actives 仍持有 Channel * 时立即销毁连接”这一类悬空访问。它并不自动解决所有生命周期风险,异步任务中的裸 this、重复释放等路径仍需要独立的所有权约束和幂等保护。

    5. 通过标准与能力边界

    这组测试需要配合服务端关闭原因、连接表和 fd 指标,重点观察:

    • 服务端在并发连接和慢业务下不会崩溃;
    • 超时连接最终能够被安全释放;
    • 同一连接不会被错误、挂断和超时路径重复释放;
    • 停止客户端并等待清理后,连接表和 fd 数量能够回落;
    • 后续新连接仍然可以建立并得到处理。

    延迟释放解决的是对象生命周期安全,但它不能解决慢业务本身造成的性能问题。只要业务逻辑仍在 IO 线程中同步阻塞,同一个 EventLoop 上的其他连接就会一起等待。

    更完整的改进方向是把耗时业务提交到独立工作线程池,或者改造成异步任务,让 IO 线程只负责快速完成网络事件处理。

    七、测试五:一次发送三条 HTTP 请求

    1. 客户端代码

    /* 一次给服务器发送多条请求,
    * 每一条请求都应该得到正常处理。
    */

    #include "../source/server.hpp"

    int main()
    {
    Socket cli_sock;
    cli_sock.CreateClient(8085, "127.0.0.1");

    std::string req =
    "GET /hello HTTP/1.1\\r\\n"
    "Connection: keep-alive\\r\\n"
    "Content-Length: 0\\r\\n\\r\\n";

    req +=
    "GET /hello HTTP/1.1\\r\\n"
    "Connection: keep-alive\\r\\n"
    "Content-Length: 0\\r\\n\\r\\n";

    req +=
    "GET /hello HTTP/1.1\\r\\n"
    "Connection: keep-alive\\r\\n"
    "Content-Length: 0\\r\\n\\r\\n";

    while (1) {
    assert(cli_sock.Send(req.c_str(), req.size()) != 1);

    char buf[1024] = {0};
    assert(cli_sock.Recv(buf, 1023));
    DBG_LOG("[%s]", buf);

    sleep(3);
    }

    cli_sock.Close();
    return 0;
    }

    2. 这组测试验证“粘包”与流水线请求

    这里把三条完整请求拼进同一个 std::string,然后一次交给 Send()。每条请求都是 Content-Length: 0,因此解析器读到头部后的空行时,就可以确认当前请求已经完整。

    服务器不能在处理完第一条请求后直接清空整个输入缓冲区,而应该:

    解析第1条请求

    路由并生成第1条响应

    重置当前 HttpContext

    发现 Buffer 中仍有数据

    继续解析第2条、第3条请求

    当前 HttpServer::OnMessage() 外层的 while 循环正是为这个场景准备的:每次只消费当前请求对应的字节,处理完成后重新检查 Buffer 中是否仍有可读数据。

    在完整 198 字节都已经成功提交的前提下,即使 TCP 最终把它们拆成多次到达,结果也不应该改变。每条连接都有独立的 HttpContext 保存解析状态,未完成的数据可以跨多次可读事件继续处理。

    3. 通过标准

    • 三条请求都被路由到 /hello;
    • 服务端按请求顺序生成三条响应;
    • 不会只处理第一条,也不会把三条请求拼成一个错误请求;
    • 处理完以后,输入缓冲区中不再有本轮请求留下的可读数据;
    • 连接保持可复用,下一轮三条请求仍能继续处理。

    不过,当前客户端只调用了一次 Recv()。TCP 同样不保证一次 Recv() 恰好得到三条完整响应:它可能只返回半条响应,也可能返回一条、两条或三条。

    因此,服务端日志可以帮助观察三次业务处理是否发生,但要把这段代码升级为自动化测试,客户端还需要按照响应头和 Content-Length 持续解析,明确统计收到了三条完整响应。

    八、测试六:上传一个较大的请求体

    1. 客户端代码

    这个测试有一个业务前提:服务器已经注册 PUT /1234.txt 对应的 Handler,并在 Handler 中使用 Util::WriteFile() 或等价逻辑保存正文。HttpServer 只负责完成解析和路由分发,PUT 方法本身并不会自动把 URL 映射成磁盘文件。

    /* 大请求体上传测试:
    * 上传 hello.txt,服务器将正文保存为 1234.txt。
    */

    #include "../source/http/http.hpp"

    int main()
    {
    Socket cli_sock;
    cli_sock.CreateClient(8085, "127.0.0.1");

    std::string req =
    "PUT /1234.txt HTTP/1.1\\r\\n"
    "Connection: keep-alive\\r\\n";

    std::string body;
    Util::ReadFile("./hello.txt", &body);

    req += "Content-Length: " +
    std::to_string(body.size()) +
    "\\r\\n\\r\\n";

    assert(cli_sock.Send(req.c_str(), req.size()) != 1);
    assert(cli_sock.Send(body.c_str(), body.size()) != 1);

    char buf[1024] = {0};
    assert(cli_sock.Recv(buf, 1023));
    DBG_LOG("[%s]", buf);

    sleep(3);
    cli_sock.Close();
    return 0;
    }

    2. 为什么头部和正文分两次发送仍然不代表两个包

    客户端先调用一次 Send() 发送请求行和请求头,再调用一次发送文件正文。这样写有助于构造“头部先到、正文后到”的场景,但 TCP 仍然可能:

    • 把两次发送合并后交给服务器;
    • 把头部和正文拆成更多段;
    • 先交付完整头部,再通过多次可读事件交付正文。

    服务器不应该依赖实际分包方式。解析器读到 Content-Length 后,只需要计算当前正文已经收到多少、还缺多少:

    size_t remain =
    content_length request_body.size();

    当前缓冲区足够时只消费 remain 个字节;不足时先保存已有正文并保留 BODY 状态,等待下一次数据到达。只有累计正文长度达到 Content-Length 后,才进入 PUT 路由;随后是否写入文件、写到哪里,由已经注册的业务 Handler 决定。

    如果 hello.txt 足够大并且服务端确实经历了多次读取,这段程序还能覆盖 BODY 状态跨可读事件续接。两次客户端 Send() 本身并不能保证一定发生这种分段。

    3. 如何判断上传真正成功

    只看到一次 HTTP 成功响应还不够。大请求体传输至少要检查:

    检查项通过标准
    HTTP 响应 返回预期的成功状态码
    文件存在性 服务端目标文件已经生成
    文件大小 源文件与目标文件大小完全相同
    文件内容 二进制逐字节一致或 SHA-256 相同
    连接状态 上传完成后连接仍能按预期复用或关闭

    假设 PUT Handler 把目标文件保存到 ./upload/1234.txt,在 Linux 下可以进一步比较:

    cmp ./hello.txt ./upload/1234.txt
    sha256sum ./hello.txt ./upload/1234.txt

    cmp 的退出码为 0 便能够证明两份文件逐字节一致;SHA-256 相同则更适合在自动化报告中记录。二者任选一种即可,必要时也可以同时保留。实际保存目录不同时替换成 Handler 使用的路径。

    4. 当前实现还不是真正的流式上传

    在文件足够大、发送完整且已注册保存 Handler 的前提下,这个测试用于验证“大请求体的增量接收与完整落盘”。不过,当前客户端会先把整个 hello.txt 读入 std::string,服务器也会把完整请求正文保存在 HttpRequest::_body 中,等全部接收完成后再进入业务处理。

    因此,文件越大,客户端和服务器的内存占用也会越高。更准确地说,当前版本实现的是“整包缓存后上传”,还不是内存占用受控的流式文件上传。

    后续如果要处理真正的大文件,可以继续实现:

    • 分块读取源文件并处理部分发送;
    • 服务端边接收边写入临时文件;
    • 设置请求正文大小上限;
    • 提供背压,避免接收速度远大于落盘速度;
    • 写入完成并校验成功后,再原子替换目标文件;
    • 对异常断开产生的不完整临时文件进行清理。

    九、这六组测试对应的服务器设计

    把六个客户端放在一起看,可以发现它们共同面向四组核心机制。

    1. 每条连接拥有独立的 HTTP 上下文

    不同客户端可能分别停留在请求行、请求头、正文或解析完成状态。服务器不能让所有连接共用同一个解析器,而应该在每条 Connection 中保存独立的 HttpContext。

    Connection A -> HttpContext A -> 正在等待正文
    Connection B -> HttpContext B -> 正在解析请求头
    Connection C -> HttpContext C -> 已得到完整请求

    这让半包状态能够跨事件保存,也避免不同连接之间互相污染。

    2. Buffer 与状态机共同确定请求边界

    Buffer 保存尚未消费的网络字节,HttpContext 记录当前解析阶段:

    RECV_HTTP_LINE

    RECV_HTTP_HEAD

    RECV_HTTP_BODY

    RECV_HTTP_OVER

    数据不够时暂停,数据到齐后继续;当前请求完成后只移动对应长度,剩余字节留给下一条请求。这同时支撑了 client3 的半包等待、client5 的多请求解析,以及文件足够大时 client6 的大正文分段接收。

    3. 时间轮负责连接活跃度管理

    只有启用非活跃连接回收后,连接建立时才会添加对应定时任务。Channel 事件处理完成后刷新任务;非超时路径释放连接时,如果定时器仍然存在就将其取消;超时路径则由到期任务触发释放并清理自身索引:

    连接建立 -> TimerAdd
    Channel事件处理完成 -> TimerRefresh
    其他路径释放且任务仍存在 -> TimerCancel
    长期空闲 -> 超时回调

    从黑盒角度,client1 和 client2 可以分别间接观察“持续活动时连接仍存活”和“长期空闲后连接被关闭”。若要确认内部确实经过 TimerRefresh 和到期任务路径,还需要服务端记录定时器操作与连接关闭原因。

    4. 真正释放发生在安全时机

    超时、读写错误、对端关闭和业务主动关闭都可能走向连接释放。真正困难的不是调用 close(),而是保证此时没有其他事件或回调仍然引用这条连接。

    把释放操作放入事件循环任务队列,等当前活跃事件批次处理完成后执行,可以避免 client4 所关注的“活跃数组仍持有裸指针时立即销毁连接”这一条悬空访问路径。其他异步生命周期问题仍需要继续依靠明确的对象所有权和幂等释放处理。

    十、测试客户端本身还可以怎样改进

    这 6 个程序很适合人工制造场景,但如果想把它们变成可以长期回归的自动化测试,还需要补齐几个细节。

    1. 不要把有副作用的操作放进 assert

    当前代码经常写:

    assert(cli_sock.Send(req.c_str(), req.size()) != 1);
    assert(cli_sock.Recv(buf, 1023));

    这里有两个问题。

    第一,定义 NDEBUG 后,assert 表达式会被整个移除,连 Send() 和 Recv() 都不会执行。

    第二,如果 Recv() 返回 -1,它在布尔判断中仍然是真,断言反而不会失败。更稳妥的写法是先执行,再明确判断返回值:

    ssize_t n = cli_sock.Recv(buf, sizeof(buf) 1);
    if (n <= 0) {
    ERR_LOG("connection closed or recv failed");
    return 1;
    }
    buf[n] = '\\0';

    2. 一次 Send() 不保证发送完整

    当前测试只判断返回值不等于 -1,却没有确认返回值是否等于请求总长度。小请求在本机测试中通常能够一次发完,但大文件更容易出现部分发送。

    阻塞测试客户端可以实现一个 SendAll():

    bool SendAll(Socket *sock, const char *data, size_t len)
    {
    size_t offset = 0;

    while (offset < len) {
    ssize_t n = sock->Send(data + offset, len offset);
    if (n <= 0) {
    return false; // 由上层记录错误或按明确策略重试
    }
    offset += static_cast<size_t>(n);
    }
    return true;
    }

    当前 Socket 封装会把 EINTR、EAGAIN 等情况压缩成相近的返回值,自动化测试最好进一步区分这些状态。非阻塞 socket 遇到 EAGAIN 时不应忙等,而要等待可写事件;所有重试还应设置总截止时间,避免测试永久卡住。

    3. 一次 Recv() 也不代表一条完整响应

    这 6 个客户端基本都把一次 Recv() 当成“一次响应已经接收完成”,但这只能用于人工观察,不能成为完整响应的严格证据。client3.cpp 和 client5.cpp 还可能收到多条响应,问题更加明显。真正自动化时,客户端同样需要一个响应状态机:

    读取状态行

    读取全部响应头

    根据 Content-Length 读取正文

    得到一条完整响应

    重复直到得到3条

    对于 client5.cpp,只有明确解析出三条完整响应,才能让测试自动判断成功或失败。对于本项目中的普通响应,还应先保证服务端为零长度正文也发送明确的 Content-Length: 0;如果要实现通用 HTTP 响应解析器,则还要处理 HEAD、1xx、204、304 以及通过连接关闭定界等特殊情况。

    4. 空闲关闭测试要处理 SIGPIPE

    client2.cpp 会在服务器已经关闭连接后再次发送,client3.cpp 在收到 400 并断开后也可能进入下一轮。在 Linux 中,对已关闭连接继续写入可能触发 SIGPIPE。所有循环发送的测试程序都可以统一忽略该信号,或者在发送时使用 MSG_NOSIGNAL,再通过返回值判断 EPIPE 或连接重置。

    5. 大文件测试要自动比较结果

    client6.cpp 当前忽略了 ReadFile() 的返回值。如果源文件不存在,测试可能退化成一次空正文 PUT。

    至少应该检查:

    if (!Util::ReadFile("./hello.txt", &body)) {
    ERR_LOG("read source file failed");
    return 1;
    }

    上传结束后,还应自动比较源文件和目标文件的大小与哈希,而不只是人工观察服务端日志。

    6. 给压力测试设置结束条件

    多个客户端使用无限循环,适合持续观察,但不方便自动回归。可以改成固定请求次数或固定运行时间,并统计:

    • 成功请求数;
    • 失败请求数;
    • 平均与最大延迟;
    • 连接关闭原因;
    • 测试结束后的剩余连接数和 fd 数量。

    这样,测试就能从“肉眼看日志”升级为“程序自动给出结论”。

    7. 检查建连结果并设置测试截止时间

    当前客户端没有检查 CreateClient() 是否成功。如果服务器尚未启动或端口配置错误,后续收发会掩盖真正的失败原因。建连、发送、接收和等待超时都应该有独立错误信息。

    阻塞式 Recv() 还需要设置接收超时,或者由 poll/epoll 配合绝对截止时间等待。否则服务器没有返回响应时,测试会永久阻塞,既无法失败退出,也无法继续执行清理逻辑。

    十一、这轮测试带来的进一步思考

    1. 空闲超时不能替代协议阶段超时

    当前连接只要不断产生事件,就会持续刷新空闲定时器。如果恶意客户端每隔几秒只发送一个字节,它可能长期占用连接,却始终不完成请求。

    因此,工程级服务器通常还会区分:

    • 建连后的首字节超时;
    • 请求头完整接收超时;
    • 请求正文接收超时;
    • Keep-Alive 空闲超时;
    • 业务处理超时。

    不同阶段使用不同的截止时间,才能更好地防御慢速请求。

    2. 解析错误后应尽快关闭失去边界的连接

    像 client3.cpp 这样的请求一旦发生消息边界错位,继续复用连接通常没有意义。发送错误响应、丢弃剩余输入并关闭连接,比尝试从任意位置重新寻找下一条请求更安全。

    3. 延迟释放规避当前批次悬空,异步业务保证吞吐

    把连接销毁延后到当前事件批次之后,可以避免活跃数组仍持有 Channel * 时对象已被销毁这一类 use-after-free;它并不等于所有生命周期问题都已解决。与此同时,如果业务回调长期阻塞 IO 线程,其他连接的延迟和超时判断仍然会受到影响。

    因此,生命周期安全和业务调度是两个不同问题,需要分别解决。

    4. 大请求体必须设置上限

    只要服务器按照客户端给出的 Content-Length 不受限制地扩容,就可能被极大的请求体耗尽内存。即使暂时不实现流式上传,也应该先设置明确的最大正文长度,并对超限请求返回 413 Payload Too Large。

    总结

    这 6 个客户端程序并不复杂,但它们把测试范围从“一次请求能否返回 Hello World”推进到了更接近真实服务器的问题:

    • 活跃长连接是否会被误回收;
    • 空闲连接是否能够及时释放;
    • 不完整正文是否会被正确等待;
    • 错误报文边界是否能被安全处理;
    • 慢业务下的连接释放是否存在悬空指针;
    • 多条请求是否能从连续字节流中逐条解析;
    • 在文件足够大且 PUT 保存 Handler 已注册时,大请求体是否能跨多次接收完整重组并落盘。

    写完并逐项分析这些测试以后,我对这个项目最大的体会是:服务器开发真正困难的地方,往往不是正常请求能否成功,而是当请求只到一半、连续到达、长时间不动,甚至在一个危险的时机触发释放时,程序能否仍然保持明确、稳定并且安全的行为。

    项目主流程的完成只是“能跑”,边界测试通过以后,服务器才真正向“可靠”迈进了一步。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 从“能响应”到“扛边界”:用 6 组客户端测试验收 C++ HTTP 服务器
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!