- C++ 86.2%
- Shell 6.5%
- HTML 4.9%
- CMake 2.4%
- HttpServer 暴露 memoryPool();App 暴露 server(),用于读取服务自持的运行时资源 - bench_server 的 /stats 由 GlobalMemoryPool::getInstance() 改为 app.server()->memoryPool(): 两者是两个不同对象,旧实现压测再久 alloc_calls 也恒为 0;未开池时那次调用还会白创建一个 5000 块的池(默认 12KB/块),让内存基准口径失真 - 新增 tests/test_http_mempool_stats.cpp 锁死三条契约:未启用为 nullptr / 启用后指向自持池 / 计数随请求增长(防止再改回读全局单例);ctest 14/14 - README 内存池节改为给出分配频率实测(原「该计数恒为 0」提示删除) |
||
|---|---|---|
| cmake | ||
| examples | ||
| include | ||
| results | ||
| scripts | ||
| src | ||
| templates | ||
| tests | ||
| .gitignore | ||
| CMakeLists.txt | ||
| init.sql | ||
| README.md | ||
HttpFramework
项目描述
HttpFramework 是一个 C++17 的 HTTP 服务框架。
它覆盖从 socket 到业务处理的完整一层:多 Reactor 网络层、HTTP/1.1 解析与响应构造、
路由匹配、链式中间件、会话管理,以及基于线程池的异步处理模型。网络层由本项目实现,
不引入第三方网络库;接入方式是一个 CMake 目标,可以被 find_package(HttpFramework) 引入。
规模约 6,700 行(src/ + include/);测试为 13 个 ctest 目标、115 个用例。
快速开始
依赖
# 必需
sudo apt install build-essential cmake libboost-all-dev
# 可选:数据库支持(未安装时相关功能与测试自动跳过)
sudo apt install libmysqlcppconn-dev
# 可选:性能基准
sudo apt install wrk
构建
# 基础构建
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j
两种使用方式
推荐使用 http::App 类,将 Router、HttpServer、SessionManager、DbConnectionPool 封装在一起,提供链式 API,大幅减少代码编写。
方式一:编写代码
最简服务器
#include "HttpFramework.h"
int main() {
http::App app;
app.get("/", [](auto& req, auto& res) {
res.setHtml("<h1>Hello, HttpFramework!</h1>");
});
app.start(8080);
}
完整功能服务器
#include "HttpFramework.h"
#include "utils/TemplateLoader.h"
int main() {
http::App app;
app.enableLogging()
.enableSession()
.enableMemoryPool();
app.get("/", [](auto& req, auto& res) {
res.setHtml(utils::TemplateLoader::loadTemplate("index.html"));
});
app.get("/api/hello", [](auto& req, auto& res) {
res.setJson(R"({"message": "Hello from HttpFramework!"})");
});
app.get("/users/:id", [](auto& req, auto& res) {
res.setJson(R"({"user_id": ")" + req.getParam("id") + R"("})");
});
app.get("/session", [&app](auto& req, auto& res) {
auto s = app.sessionManager()->getSession(req.getUserData("session"));
int count = s && s->has("visits") ? std::stoi(s->get("visits")) + 1 : 1;
if (s) s->set("visits", std::to_string(count));
res.setJson(R"({"visits": )" + std::to_string(count) + "}");
});
app.notFound([](auto& req, auto& res) {
res.setStatus(http::HttpStatus::NOT_FOUND);
res.setJson(R"({"error": "Not Found"})");
});
app.start(8080, 4);
}
自定义中间件
// 全局中间件 — 请求日志
app.use([](const http::HttpRequest& req, http::HttpResponse& res, std::function<void()> next) {
std::cout << req.getMethodString() << " " << req.getPath() << std::endl;
next();
});
// 路径特定中间件 — API 认证
app.use("/api", [](const http::HttpRequest& req, http::HttpResponse& res, std::function<void()> next) {
if (req.getHeader("authorization").empty()) {
res.setStatus(http::HttpStatus::UNAUTHORIZED);
res.setJson(R"({"error": "Unauthorized"})");
return;
}
next();
});
// 自定义响应头
app.use([](const http::HttpRequest& req, http::HttpResponse& res, std::function<void()> next) {
res.setHeader("X-Powered-By", "HttpFramework");
next();
});
数据库集成
http::App app;
// 启用数据库(失败时自动降级,服务器正常启动)
app.enableDatabase("localhost", "root", "password", "mydb");
app.get("/db/users", [&app](auto& req, auto& res) {
auto pool = app.dbPool();
if (!pool) {
res.setJson(R"({"error": "Database not available"})");
return;
}
auto conn = pool->getConnection();
if (!conn) {
res.setStatus(http::HttpStatus::INTERNAL_SERVER_ERROR);
res.setJson(R"({"error": "Failed to get connection"})");
return;
}
try {
auto result = conn->executeQuery("SELECT * FROM users");
// ... 处理结果 ...
pool->returnConnection(conn);
} catch (const std::exception& e) {
pool->returnConnection(conn);
res.setStatus(http::HttpStatus::INTERNAL_SERVER_ERROR);
res.setJson(R"({"error": ")" + std::string(e.what()) + R"("})");
}
});
App 配置一览
| 方法 | 默认值 | 说明 |
|---|---|---|
enableLogging() |
— | 请求日志中间件 |
enableSession(expire, cleanup) |
1800s, 300s | 会话管理 + 后台自动清理 |
enableMemoryPool() |
— | 12KB 固定块内存池 |
enableDatabase(host, user, pass, db, port, max) |
port=3306, max=5 | MySQL 连接池(可选) |
start(port, threads) |
threads=4 | 启动服务器并阻塞等待信号 |
需要完全控制时,通过 app.router()、app.sessionManager()、app.dbPool()、app.stats() 直接操作底层对象。
方式二:底层 API
直接使用 HttpServer、Router、SessionManager、DbConnectionPool 等底层类,自由组装。参考 examples/full_demo.cpp,展示了手动管理信号处理、全局资源生命周期、端口检测等用法。
示例程序暴露的端点
full_demo 在 8080 端口启动后,可直接访问以下端点验证各模块:
| 端点 | 说明 |
|---|---|
/ |
首页 |
/api/status |
服务状态 |
/api/stats |
框架内部统计 |
/api/time |
服务端时间 |
/api/echo |
请求回显(POST) |
/session/info |
当前会话信息 |
/session/stats |
会话统计 |
/session/set |
写入会话数据(POST) |
/db/test |
数据库连接测试 |
/db/users |
用户列表(GET 查询 / POST 新增) |
/db/add-user |
添加用户表单 |
# 压力测试
wrk -t4 -c100 -d30s http://localhost:8080/
# 接口检查
curl http://localhost:8080/api/status
数据库相关端点需要 MySQL;未配置时 enableDatabase 会自动降级,服务器仍能正常启动。
集成到现有项目
HttpFramework 安装后可作为 CMake 包被外部项目引用。
# 外部项目的 CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(MyWebApp LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
find_package(HttpFramework REQUIRED)
add_executable(my_server main.cpp)
target_link_libraries(my_server PRIVATE HttpFramework::http_framework)
// main.cpp
#include "HttpFramework.h"
int main() {
http::App app;
app.get("/", [](auto&, auto& res) { res.setHtml("<h1>Hello</h1>"); });
app.start(8080);
}
编译时指定安装前缀:
cmake -S . -B build -DCMAKE_PREFIX_PATH=/usr/local
cmake --build build
构建选项
构建选项的完整列表见后文"构建选项与配置";最常用的一项是关掉示例编译:
# 仅构建库,不编译示例
cmake -S . -B build -DBUILD_EXAMPLES=OFF
cmake --build build
功能特性
HTTP 与路由
- HTTP/1.1 解析与响应构造:请求行/头部/正文字节级解析,含 400(畸形请求)、413(超限)等边界;
HEAD 抑制响应体但保留
Content-Length;支持静态文件与模板渲染 - 三类路由匹配:静态路径(哈希)、
:param动态段(分段匹配)、*通配符(正则回退); 按 method 建索引,路径先按段切分再匹配,避免逐条正则 - 链式中间件:洋葱模型 + 路径前缀过滤;鉴权、日志、限流等横切逻辑挂在这里
- 会话管理:内存与文件两种存储,Cookie 生命周期(含滑动续期)、过期清理
并发模型
- 多 Reactor:1 个主 Reactor 只做 accept,按 round-robin 把连接分给 N 个子 Reactor;
每连接注册
EPOLLIN | EPOLLET | EPOLLONESHOT - 独立线程池:业务逻辑不在 Reactor 线程里跑,交给共享线程池;响应回写经 eventfd 唤醒 子 Reactor 完成(工作线程不直接操作别人的 epoll)
- HTTP 长连接:支持 keep-alive 与空闲超时回收;管线化请求按序处理
- 固定块内存池(可选):请求缓冲从预分配块里取;响应侧不使用固定块池,
由 I/O 线程独占的
std::string承载。块大小与块数可配
能力边界
- 只实现 HTTP/1.1(无 HTTP/2、HTTP/3);不含反向代理、负载均衡、限流
- 单进程单实例,无集群与主备;会话默认在内存,多实例间不共享
- 数据库层是可选封装(连接池 + 查询接口),不是 ORM
- 内存池对大响应不友好:请求侧超块回 413,响应侧超块回退
std::string - 仅 Linux(epoll + eventfd)
模块组成
┌─────────────────────────────────────────────────────────┐
│ 应用层 │ 路由系统 │ 中间件系统 │ 会话管理 │ 数据库集成 │
├─────────────────────────────────────────────────────────┤
│ HTTP层 │ 请求解析 │ 响应构建 │ 协议处理 │ 状态管理 │
├─────────────────────────────────────────────────────────┤
│ 网络层 │ 多Reactor │ epoll ET │ 非阻塞I/O │ 线程池 │
├─────────────────────────────────────────────────────────┤
│ 系统层 │ Socket编程 │ 信号处理 │ 资源管理 │ 优雅关闭 │
└─────────────────────────────────────────────────────────┘
网络层
- 多 Reactor 模式:main Reactor 仅 accept + 轮询分发连接;sub Reactor 各自独立 epoll 处理 read/write;线程池处理业务逻辑
- epoll ET 模式:边缘触发,减少 epoll_wait 系统调用次数
- 非阻塞 I/O:避免线程阻塞,提高并发能力
- 连接管理:连接创建、复用、资源回收
HTTP 层
- 协议解析:HTTP/1.1 请求行、头部、主体完整解析
- 响应构建:HTTP 响应生成和格式化
- 状态管理:HTTP 状态码和错误处理
- 内容类型:HTML、JSON、文本、文件等多种响应类型
路由层
- 静态路由:精确路径匹配
- 动态路由:
/users/:id参数提取 - 通配符路由:
*通配符支持 - 正则匹配:基于正则表达式的复杂路径匹配
- 处理器分发:请求到处理器的映射和分发
中间件层
- 链式处理:多个中间件串联执行,
next()控制流转 - 路径过滤:中间件可指定作用路径前缀(如
/api) - 上下文传递:请求上下文在中间件间传递
- 内置中间件:日志、会话管理开箱即用
会话层
- 会话生命周期:创建、存储、获取、销毁
- Cookie 处理:会话 Cookie 自动管理
- 过期清理:后台线程定期清理过期会话,可配置间隔
- 线程安全:互斥锁保护并发访问
- 可扩展存储:支持内存存储和数据库持久化
数据库层
- 连接池:MySQL 连接池管理和优化
- 健康检查:连接池健康监控和自动恢复
- 事务支持:数据库事务的完整支持
- 异常处理:数据库异常的捕获和处理
工具层
- 内存池:12KB 固定大小内存块,O(1) 分配/释放
- 线程池:工作线程创建和任务分发
- 模板引擎:HTML 模板加载和
{{variable}}变量替换 - 信号处理:SIGINT/SIGTERM 优雅关闭
架构
线程模型:多 Reactor + 线程池
┌─────────────────────────────────────────┐
客户端连接 ────────▶│ 主 Reactor 线程 │
│ epoll_wait 只做 accept │
└────────────────┬────────────────────────┘
│ round-robin 分发
┌────────────────────┼────────────────────┐
▼ ▼ ▼
┌────────────────┐ ┌────────────────┐ ┌────────────────┐
│ 子 Reactor 0 │ │ 子 Reactor 1 │ │ 子 Reactor N │
│ EPOLLIN|ET │ │ ... │ │ ... │
│ |ONESHOT │ │ │ │ │
└───────┬────────┘ └───────┬────────┘ └───────┬────────┘
│ 请求收齐后入队 │ │
└───────────┬─────────┴────────────────────┘
▼
┌──────────────────────────┐
│ 共享线程池(默认 4 线程) │ 业务处理:中间件链 → 路由 → handler
└───────────┬──────────────┘
│ 写响应:把 PendingResponse 投递到目标连接的 pendingResponses
│ 然后 write(eventfd) 唤醒**属于该连接**的子 Reactor
▼
子 Reactor 线程:从队列取出 → send → 完整写出后再 EPOLL_CTL_MOD 重新注册
为什么不让工作线程直接 send / 改 epoll:回写动作经 eventfd 唤醒后
收敛回子 Reactor 线程执行;理由见"关键设计与取舍"。
请求生命周期
一个请求从进入到响应写出的完整路径;入口的 accept 与 round-robin 分发见上面的"线程模型", 参与者即线程归属:
sequenceDiagram
participant C as 客户端
participant SR as 子 Reactor
participant TP as 线程池
participant H as 业务处理
C->>SR: TCP 数据到达(EPOLLIN|EPOLLONESHOT)
SR->>SR: recv 进连接缓冲,解析请求;半包则等下一次事件
SR->>TP: enqueueDetached(请求任务)
Note over SR: ONESHOT 已消费,暂不再监听该连接
TP->>H: 中间件链 → 路由匹配 → handler
H->>H: 生成响应(序列化)
H->>SR: pendingResponses.push_back({connId, data, keepAlive}) + write(eventfd)
SR->>C: send 响应
SR->>SR: 完整写出后释放响应区、重置上下文,EPOLL_CTL_MOD 重新武装 EPOLLIN|ONESHOT
Note over SR,C: keep-alive 则继续等待下一个请求;空闲超时回收连接
连接生命周期
- 归属校验:每个连接用
ConnId = (generation << 32) | fd标识。fd 会被内核复用, 只按 fd 记录会让"旧连接的回调"误伤刚建立的新连接(这正是早期一个真实缺陷的根因) - 半关闭处理:对端 FIN 后先把缓冲读尽再判 EOF;只发 FIN 不一定产生新边沿(ET 下尤其要注意), 所以每个 epoll 节拍会主动检查各连接的空闲与 read 状态
- 优雅停机:
stop()先排空在途业务任务,再释放成员,避免共享线程池上的任务访问已析构对象
工作原理
请求从进入到写出的整体路径见"架构"一节的"请求生命周期";下面按环节展开实现。
Reactor 事件循环
主 Reactor — 仅 accept + round-robin 分发连接给子 Reactor:
void HttpServer::mainReactorLoop() {
const int MAX_EVENTS = 128;
struct epoll_event events[MAX_EVENTS]; // 局部数组,不在对象上保存
while (running_.load()) {
int nfds = epoll_wait(mainEpollFd_, events, MAX_EVENTS, 1000);
if (nfds < 0) {
if (errno == EINTR) continue;
break;
}
for (int i = 0; i < nfds; ++i) {
int fd = net::connIdFd(events[i].data.u64); // data 里存的是 ConnId,不是裸 fd
if (fd == listenFd_ && (events[i].events & EPOLLIN))
handleAccept(); // accept 轮询分发到 sub reactor
}
}
}
子 Reactor — 多个线程各带独立 epoll,处理分配的连接的 I/O:
void HttpServer::subReactorLoop(int index) {
const int MAX_EVENTS = 1024;
struct epoll_event events[MAX_EVENTS];
auto& sr = subReactors_[index];
while (running_.load()) {
int nfds = epoll_wait(sr->epollFd, events, MAX_EVENTS, 1000);
if (nfds < 0) {
if (errno == EINTR) continue;
break;
}
for (int i = 0; i < nfds; ++i) {
net::ConnId connId = events[i].data.u64;
int fd = net::connIdFd(connId);
uint32_t ev = events[i].events;
if (fd == sr->wakeFd) { handleWake(index); continue; } // eventfd:批量发送响应
if (fd == sr->timerFd) { handleTimer(index); continue; } // timerfd:空闲连接清理
// 代际校验:fd 号被回收再分配后,内核可能仍投递一次属于旧连接的事件。
// 不符时直接丢弃,绝不按"未知连接"去关闭它。
if (!ownsConnection(fd, index, connId)) continue;
if (ev & (EPOLLERR | EPOLLHUP)) {
// 不能"看到 HUP 就关":对端 shutdown(SHUT_WR) 后仍在等响应,
// 直接关闭会丢掉已到达的请求与未发出的响应,所以先把可读数据读尽。
if (ev & EPOLLIN) handleRead(fd, index, connId);
HttpContext* ctx = lookupContext(index, fd);
if (ctx && (ctx->inFlightCount() > 0 || ctx->hasCurrentResponse()))
continue; // 还有在途请求或待发响应,交给写路径收尾
closeConnection(fd, index);
} else {
if (ev & EPOLLIN) handleRead(fd, index, connId);
if (ev & EPOLLOUT) handleWrite(fd, index);
}
}
}
}
工作线程池
子 Reactor 读取到完整 HTTP 请求后,提交到线程池异步处理,子 Reactor 线程立即返回继续监听 I/O:
void HttpServer::handleRead(int clientFd, int subReactorIndex, net::ConnId connId) {
HttpContext* ctx = lookupContext(subReactorIndex, clientFd);
bool peerClosed = false;
const size_t totalRead = readAllData(clientFd, ctx, &peerClosed); // 循环 recv 直到 EAGAIN
// totalRead == 0 时用 peerClosed 区分"对端已关闭"与"暂无数据";
// 413(非池缓冲超限)/ 503(池耗尽)等判定略
dispatchBufferedRequests(clientFd, subReactorIndex, connId); // 解析出完整请求后派发
}
void HttpServer::enqueueRequestTask(int subReactorIndex, int clientFd, net::ConnId connId,
std::shared_ptr<HttpRequest> request,
std::shared_ptr<HttpResponse> response) {
markInFlight(subReactorIndex, connId, /*enter=*/true);
auto task = std::make_shared<HttpRequestTask>(
clientFd, request, response,
[this, subReactorIndex, connId](int fd, std::shared_ptr<HttpRequest> req,
std::shared_ptr<HttpResponse> res) {
processHttpRequest(subReactorIndex, connId, req, res); // 回写按 ConnId 归属
});
// 请求任务不关心返回值,走无 packaged_task / future 的轻量提交路径
threadPool_->enqueueDetached([this, subReactorIndex, connId, task]() {
try { task->execute(); } catch (...) {}
markInFlight(subReactorIndex, connId, /*enter=*/false);
finishInFlightTask();
});
}
边缘触发(ET)读处理
ET 模式下必须循环读取直到 EAGAIN,否则可能丢失数据。读尽的动作封在 readAllData 里;
读到 EOF 只置位 peerClosed、不在这里关闭连接——对端 shutdown(SHUT_WR) 之后仍要把响应发完,
关闭时机由调用方决定:
size_t HttpServer::readAllData(int fd, HttpContext* ctx, bool* peerClosed) {
char buffer[4096];
size_t readBytes = 0;
if (peerClosed) *peerClosed = false;
while (true) {
ssize_t n = ::recv(fd, buffer, sizeof(buffer), 0);
if (n > 0) {
ctx->appendData(buffer, static_cast<size_t>(n)); // 直接读进连接请求缓冲
readBytes += static_cast<size_t>(n);
} else if (n == 0) {
if (peerClosed) *peerClosed = true; // 对端关闭写方向
break;
} else {
if (errno == EAGAIN || errno == EWOULDBLOCK) break; // 数据读完
if (errno == EINTR) continue;
if (peerClosed) *peerClosed = true; // 致命错误
break;
}
}
return readBytes;
}
内存池分配
utils::MemoryBlock* utils::HttpMemoryPool::allocate() {
allocCalls_.fetch_add(1, std::memory_order_relaxed);
std::lock_guard<std::mutex> lock(mutex_);
if (freeBlocks_.empty()) {
// 池耗尽:显式计数后返回 nullptr,调用方据此回 503,而不是静默降级
allocateFailures_.fetch_add(1, std::memory_order_relaxed);
return nullptr;
}
MemoryBlock* block = freeBlocks_.front(); // 空闲块是 FIFO 队列
freeBlocks_.pop();
block->isUsed = true;
usedBlocks_++;
return block;
}
会话创建与过期清理
std::shared_ptr<Session> SessionManager::createSession() {
std::lock_guard<std::mutex> lock(sessionsMutex_);
std::string sessionId;
do {
sessionId = generateSessionId(); // 无锁生成,唯一性在此处校验
} while (sessions_.find(sessionId) != sessions_.end()); // id 冲突则重取
auto session = std::make_shared<Session>(sessionId);
session->setExpirationTime(defaultExpiration_); // 构造时即带上默认过期时间
sessions_[sessionId] = session;
return session;
}
void SessionManager::cleanupExpiredSessions() {
std::lock_guard<std::mutex> lock(sessionsMutex_);
for (auto it = sessions_.begin(); it != sessions_.end(); ) {
if (it->second->isExpired()) it = sessions_.erase(it);
else ++it;
}
}
核心组件
| 组件 | 职责与关键设计 |
|---|---|
| HttpServer | epoll ET + EPOLLONESHOT 主循环、accept 分发、连接表(代际校验)、空闲超时扫描、连接数上限 |
| HttpContext | 单连接状态:请求缓冲(读游标 + 半包起点)、在途请求计数、待发响应、连接代际 |
| HttpRequest / HttpResponse | 解析与构造:URL 解码、头名大小写归一化、HEAD 抑制响应体、序列化用 reserve + 直接拼接 |
| Router | 静态哈希 + :param 分段匹配 + * 正则回退;method 索引;运行期注册走"拷贝-换入"快照 |
| Middleware | 洋葱模型 + 路径前缀过滤;入口处一次性算好匹配列表,避免每请求整表重算 |
| SessionManager | 内存 / 文件两种存储、Cookie 生命周期(滑动续期)、过期清理、文件存储 ID 白名单 + 原子写 |
| MemoryPool | 固定块(默认 12 KB × 5000)、分配计数可观测、启动前开关(默认关闭,运行期调用被忽略并告警) |
| ThreadPool | 任务队列 + 状态统计;enqueueDetached 提供无 packaged_task/future 的轻量路径 |
单连接的"读写状态机"没有单独的类:读入、解析派发与写出分别由
HttpServer::handleRead、HttpServer::dispatchBufferedRequests、HttpServer::writeCurrentResponse
完成,连接状态全部放在 HttpContext 上。
HttpServer 关键默认值
| 参数 | 默认值 | 设置入口 |
|---|---|---|
| 最大并发连接数 | 10000(0 = 不限制) | setMaxConnections() |
| 空闲连接超时 | 60 秒 | setIdleTimeout() |
| 空闲检查周期 | 5 秒 | setIdleCheckIntervalSec() |
| 单请求体上限 | 64 MB | setMaxRequestBodyBytes() |
| 非池模式单连接请求缓冲上限 | 1 MB | setMaxRequestBytes() |
| 请求头上限 | 16 KB | setMaxRequestHeaderBytes() |
空闲检查周期与内存池的块容量/块数在 start() 时落地(timerfd 与池实例此时创建),
运行期修改无效。
关键设计与取舍
回写为什么走 eventfd。 子 Reactor 的 epoll 实例被它自己的线程独占,工作线程直接
EPOLL_CTL_MOD 等于两个线程并发操作同一个 epoll;再加上连接注册的是 EPOLLONESHOT,
跨线程改状态相当于把 oneshot 的监听状态重置,会打乱"一次事件一次处理"的语义,
可能造成重复投递或丢失事件。用 eventfd 把回写收敛回 Reactor 线程,
代价是每次响应多一次跨线程唤醒(实测这个开销远小于并发操作 epoll 的风险)。
为什么做固定块内存池,以及为什么默认关闭。 动机是"高频分配"——早期是短连接形态,
每请求新建/销毁缓冲。但长连接改造之后,500 并发 30 秒里池的 allocate/deallocate
只增加 1002 次(每千请求约 1 次),复用度已经由连接本身提供;在 glibc 的 per-thread
tcache 面前,这把带锁的池不再有优势。三轮交替复测的方向分别是 −3.5% / +6.1% / +1.1%,
只能说"无可观测收益",所以默认关闭(另外启用它会在 start() 阶段一次性构造 5000 个块,
按 sizeof(MemoryBlock) = 12312 字节计约 61.6 MB 堆分配;数据区在写入前不被触碰,
实测常驻增量约 20 MB,即"20 MB"是 RSS 口径,不是堆分配量)。
响应序列化。 早期实现逐个字段拼接、反复扩容;现在先 reserve 再直接拼接,
并把 Date 头按秒缓存,避免每个响应都做一次时间格式化。
路径参数怎么塞回请求对象。 路由匹配出的 :param 需要交给 handler。当前实现由
RouterHandler 用 const_cast 把参数写回 HttpRequest——它不在"未定义行为"的意义上有问题
(对象本身不是 const),但确实是个不漂亮的接口,记在待办里。
项目难点
① 跨线程回写:工作线程不能碰别人的 epoll
难在哪:连接注册的是 EPOLLONESHOT,事件被消费后必须由同一个 Reactor 线程重新注册。
第一版让工作线程直接对子 Reactor 的 epoll 做 EPOLL_CTL_MOD(两个原因见"关键设计与取舍")。
怎么做的:每个子 Reactor 一个 eventfd + 一个 pendingResponses 列表。工作线程只做两件事:
把 {ConnId, 响应字节, keepAlive} 投递进列表、write(eventfd)。响应内容随投递一起交付,
工作线程因此完全不触碰 HttpContext 的字段;send 与重新注册全部收敛回子 Reactor 线程。
顺带:正因为回写带了 ConnId,这里必须做归属校验(见 ③),否则队列里躺着的旧连接
回调会打到已经复用同一 fd 的新连接上。
② 内存池带来的两个问题
问题一:12 KB 单块导致响应被静默截断。 早期实现用一个 12 KB 的池块承载响应,
写入超出单块时 PooledBuffer::write 的返回值没有被检查(该缓冲挂在 HttpContext 上,现已移除),
现象很隐蔽:响应头写着 Content-Length: 20000,客户端实收 12117–12289 字节,keep-alive 下后续请求还会错位。
当时的修法是响应侧超限回退 std::string、请求侧比对实际写入字节数并回 413(带 {"limit":...});
后续(H5)连这条路径也一并去掉——响应方向改用 std::string,不再从池里取块(见"核心组件"的 HttpContext)。
问题二:它其实没有收益。 长连接改造之后池的调用频率降到每千请求约 1 次,三轮交替复测方向翻转 (调用次数、三轮吞吐与常驻增量见"实测性能"的"内存池:无可观测收益")。 结论是默认关闭——理由是无收益加内存占用,而不是"性能更差"。
③ 连接生命周期与 fd 归属:陈旧回调误杀新连接
难在哪:fd 会被内核复用。只按 fd 记录连接状态时,"上一个连接的回调"可能作用在 刚复用同一 fd 的新连接上。现象是服务端日志里出现两条相同 fd 的关闭记录 (分别来自不同的子 Reactor),而新连接复用几次之后就被单方面断开——客户端看到的是"莫名 RST"。
怎么做的:连接标识改为 ConnId = (generation << 32) | fd,所有回写与关闭都带归属校验,
代际不匹配就丢弃。复测:记录恒为每 Reactor 各一条、连接复用 8–17 次全部正常。
为什么值得讲:这类缺陷在压测里表现为"偶发连接断开",很容易被归因成"客户端或网络问题" 而放过去;把它定位到"fd 复用 + 缺少代际"需要先怀疑自己的状态模型。
工程难点
模块解耦
通过清晰接口抽象隔离模块:Router 只关心路径匹配和处理器分发,不感知 HTTP 解析细节;Middleware 通过 next() 函数控制链式流转,不依赖具体业务逻辑;Session 和 DB 通过 enable*() 按需启用,不强制绑定。
API 设计
底层 API 更加灵活,App 类更加简洁,满足不同场景需求。链式调用减少中间变量,默认参数覆盖常见配置,同时保留 router() / sessionManager() / dbPool() 访问器直接操作底层对象。
项目结构
HttpFramework/
├── CMakeLists.txt # 主构建配置
├── cmake/
│ └── HttpFrameworkConfig.cmake.in # find_package 包配置模板
├── include/
│ ├── HttpFramework.h # App 类
│ ├── http/ # HttpServer / HttpRequest / HttpResponse / HttpContext
│ ├── router/ # Router / RouterHandler
│ ├── session/ # Session / SessionManager / SessionStorage
│ ├── middleware/ # SessionMiddleware
│ └── utils/ # MemoryPool / ThreadPool / TemplateLoader / db
├── src/ # 源文件
├── templates/ # HTML 模板({{variable}} 变量替换)
├── examples/
│ ├── hello_world.cpp # 最简示例
│ ├── full_demo.cpp # 全功能演示(路由/会话/DB/模板/统计)
│ └── bench_server.cpp # 性能基准测试服务器
├── tests/ # 单元测试
├── scripts/ # 基准测试脚本
├── templates/ # HTML 模板({{variable}} 变量替换)
└── init.sql # 数据库初始化脚本
实测性能
环境:2 vCPU / 1968 MB 的 VPS(压测端与被测服务同机 loopback),服务线程池 4,
wrk 4.2.0两套脚本口径不同、数字不可互比:scripts/bench_http.sh(wrk -t4,并发 100–5000,各 30 s)与scripts/run_http_bench.sh main results/main(wrk -t2,每项独立启动服务,另出results/main/REPORT.md) 原始输出:results/http_bench_20260921_0254.txt、results/main/
并发梯度(长连接,wrk -t4)
| 并发 | 吞吐 (req/s) | p50 | p99 | socket 错误 |
|---|---|---|---|---|
| 100 | 35,858 | 2.62 ms | 6.61 ms | 0 |
| 500 | 36,039 | 13.44 ms | 23.97 ms | 0 |
| 1000 | 31,658 | 30.84 ms | 51.57 ms | 0 |
| 2000 | 27,608 | 70.46 ms | 132.08 ms | 0 |
| 5000 | 29,236 | 169.60 ms | 231.39 ms | 0 |
读法:5,000 并发下保持 2.9 万 req/s 量级、零 socket 错误。p50 从 100 档的 2.62 ms 升到 5000 档的
169.60 ms,量级符合 延迟 ≈ 并发 ÷ 吞吐 的排队关系,所以"低延迟"与"高并发"必须同时标注档位才有意义。
每请求 CPU
| 口径 | 数值 |
|---|---|
bench_http.sh(-t4,c=1000;/proc/<pid>/stat 差分 ÷ 请求数) |
37.9 µs(3,667 jiffies / 967,885 请求) |
run_http_bench.sh A6 项(-t2,c=500) |
29.6 µs(3,616 jiffies / 1,102,625 请求) |
路由与中间件(-t4,c=20)
bench_server --middleware 50 / --routes 500,原始输出 results/main/opt_c20_20260921.txt:
| 场景 | 吞吐 (req/s) | p50 |
|---|---|---|
| 50 层中间件 | 23,855 | 0.77 ms |
| 500 条路由、命中末条 | 32,901 | 0.58 ms |
| 500 条路由、命中靠前 | 33,762 | 0.56 ms |
根因是每个请求都把中间件链与路由表整表重算(findMiddlewares 走 O(M) 正则、路由逐条匹配)。
改动是入口处一次性算好中间件列表,路由改为 method 索引 + 静态哈希 + 分段匹配。
"命中靠前与命中末条几乎同速"说明收益来自"找得对不对",而不是"找得快"。
内存池:无可观测收益
交替执行(默认 ↔ --mempool),各 30 秒(-t4,c=1000):
| 轮次 | 无池 (req/s) | 开池 (req/s) |
|---|---|---|
| 1 | 33,677 | 37,100 |
| 2 | 31,913 | 35,682 |
| 3 | 34,751 | 36,644 |
分项脚本同批(-t2):无池 36,271 → 开池 39,074 req/s。两者差异落在同批测量的波动范围内,
因此只表述为无可观测收益,不给加速比。池在启动时预分配 5000 块,开池后空载 RSS
从 4.3 MB 升到 23.9 MB,默认关闭。
分配频率(curl /stats 的 mempool.alloc_calls 差分;500 并发 × 30 s,原始输出
results/main/mempool_alloc_20260921.txt):
| 连接模式 | 请求数 | alloc 增量 | 每千请求 |
|---|---|---|---|
| 长连接 | 1,167,554 | 502 | 0.43 |
短连接(Connection: close) |
420,792 | 421,047 | 1,000.6 |
长连接下每条连接只取一次请求缓冲,调用频率是千分之零点几;短连接下每请求一次分配,
这才是内存池的设计场景——而实测短连接下开池同样没有正向收益(-t4 与 -t2 两套口径都不给正收益),
因此不值得为它引入一套预分配与生命周期管理。--mempool 下 /stats 读的是服务自持的池
(app.server()->memoryPool()),未开池时该字段为 null。
短连接
三次测量是 10,533 / 12,864 / 13,737 req/s。短连接吞吐受客户端端口与 TIME_WAIT 资源影响、 波动可达 30%+,只作区间参考(约 1.1–1.4 万 req/s),不参与任何结论。
测试与基准
单元与集成测试
# 默认配置
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build -j
cd build && ctest --output-on-failure # 14/14
| 测试文件 | 覆盖内容 | 用例数 |
|---|---|---|
test_infra_threadpool |
线程池:入队/批量/队列/关闭/状态 | 9 |
test_infra_mempool |
内存池:分配/FIFO/耗尽/RAII/统计 | 11 |
test_infra_logger |
日志:级别过滤/模块过滤/输出 | 6 |
test_http_route |
路由:静态/动态/通配符/:param 形态等价性/404/方法 |
11 |
test_http_middleware |
中间件链:洋葱模型/路径过滤/鉴权 | 6 |
test_http_session |
会话:CRUD/过期/清理/Cookie | 13 |
test_http_response |
响应构建:HTML/JSON/File/Binary/重定向 | 12 |
test_http_template |
模板:加载/变量替换/fallback | 5 |
test_http_db |
数据库连接池:初始化/获取/查询/计数/TCP 探测(依赖缺失时明确跳过) | 6 |
test_edge_input |
异常输入:Header 过大/路径穿越/畸形请求 | 7 |
test_edge_stress |
并发压力:1000 请求/统计/重启/fd 泄露 | 4 |
test_http_mempool_stats |
内存池统计口径:未启用为 nullptr、启用后指向自持池、计数随请求增长 |
2 |
test_http_hardening |
加固回归:信号/管线化/框架头/慢速滴灌/畸形请求 | 20 |
test_http_keepalive |
长连接:复用/管线化/空闲回收 | 5 |
"跳过"是独立的第三种结果:需要外部依赖(数据库)的用例在依赖缺失时明确 SKIP 并打印原因,
不计入通过——例如数据库已装但服务未启动时 test_http_db 是"1 通过 + 5 跳过"、退出码 0;
完全未编译数据库支持时它只跑 1 个用例。
(ctest 自 3.16 起可用 SKIP_RETURN_CODE / SKIP_REGULAR_EXPRESSION 表达"跳过",本项目尚未采用。)
测试设计:全部使用真实文件系统 + 真实 loopback socket,不用 mock。
好处是能覆盖真实的失败路径:EADDRINUSE 端口冲突、缺失文件回 404、模板缺失 fallback、
session 过期与清理、中间件鉴权短路 401、shutdown 后 enqueue 抛异常、内存池耗尽返回 nullptr。
性能基准
仓库里有三套互补的脚本,产出都落在 results/:
| 脚本 | 用途 | 产出 |
|---|---|---|
scripts/bench_http.sh |
标准化单次测量:并发梯度、每请求 CPU、内存池交替、短连接 | results/http_bench_<时间戳>.txt(单文件) |
scripts/run_http_bench.sh <label> [dir] |
分项全指标:A1–A6(吞吐 / JSON / 延迟分布 / 最大并发 / 内存 / CPU 成本)、B1–B3(线程 / 内存池 / 路由)、C1–C2(中间件 / 会话),每项独立启动服务 | results/<label>/ 下每指标一个文件(共 14 个) |
scripts/generate_report.sh |
把分项文件汇总成报告(缺数据的项写"数据缺失",只对已锚定的文件算百分比) | results/REPORT.md |
# 标准化单次测量(对外引用数字时用它,带装置自检)
bash scripts/bench_http.sh # 全量:并发梯度 + 每请求 CPU + 内存池交替 + 短连接(约 8 分钟)
bash scripts/bench_http.sh quick # 快速回归:100/1000 两档 + 内存池单轮(约 2 分钟)
# 分项全指标(逐项对照、出报告)
bash scripts/run_http_bench.sh main results/main # 本机约 10 分钟
bash scripts/generate_report.sh # 汇总为 results/REPORT.md
两个测量脚本都会先做装置自检(端口就绪且进程存活),不通过则把整批标记作废。
引用基准数字时必须带上工具与参数:bench_http.sh 用 wrk -t4、并发 100–5000;
run_http_bench.sh 用 wrk -t2(避免压测端与被测服务抢 CPU),各档时长 15/30 秒混合。
两者口径不同,同一台机器上的绝对值也不可直接互相比较。
构建选项与配置
| 选项 | 默认 | 说明 |
|---|---|---|
CMAKE_BUILD_TYPE |
未指定 | 建议 Release;-O2 恒定生效,Release 另加 -O3 -DNDEBUG(引用性能数据须注明构建类型) |
BUILD_EXAMPLES |
ON |
编译 examples/ 下的示例与 bench_server |
ENABLE_STRICT_WARNINGS |
OFF |
把部分警告提升为错误(-Werror=return-type / -Werror=unused-result / -Werror=uninitialized / -Werror=delete-non-virtual-dtor) |
SANITIZE |
空 | 传 address / thread / undefined 启用对应 sanitizer(需要手工跑,未进 CI) |
运行时配置分两层:http::App 负责路由、会话(过期与清理间隔)、数据库与内存池开关;连接数上限、
空闲超时、内存池块大小与块数等在 HttpServer 上设置,且必须在 start() 之前调用(运行期
修改会被忽略并打印警告),App 未透出这些选项。模板目录来自编译期宏 TEMPLATES_DIR,不是运行期配置。
API 参考
下面是公开接口速查;完整签名与默认值以
include/下的头文件为准,示例见examples/。
App
namespace http {
class App {
public:
using Handler = router::Router::Handler;
using Middleware = router::Router::Middleware;
// 路由注册(链式调用)
App& get(const std::string& path, Handler h);
App& post(const std::string& path, Handler h);
App& put(const std::string& path, Handler h);
App& del(const std::string& path, Handler h);
App& patch(const std::string& path, Handler h);
App& head(const std::string& path, Handler h);
App& options(const std::string& path, Handler h);
// 中间件
App& use(Middleware m); // 全局
App& use(const std::string& path, Middleware m); // 路径特定
App& notFound(Handler h);
// 快捷功能
App& enableLogging();
App& enableSession(int expirationSeconds = 1800, int cleanupIntervalSeconds = 300);
App& enableMemoryPool(bool enable = true);
App& enableDatabase(const std::string& host, const std::string& user,
const std::string& password, const std::string& database,
int port = 3306, int maxConnections = 5);
// 生命周期
void start(int port, int threads = 4);
void stop();
// 访问底层对象
std::shared_ptr<router::Router> router();
std::shared_ptr<session::SessionManager> sessionManager();
std::shared_ptr<db::DbConnectionPool> dbPool();
const http::HttpServer::Statistics& stats();
};
}
HttpServer
class HttpServer {
public:
HttpServer(int port, size_t threadPoolSize = hardware_concurrency,
size_t subReactorCount = 0); // 0 = hardware_concurrency
bool start(); // false = 启动失败
void stop();
void setRouter(std::shared_ptr<router::Router> router);
void enableMemoryPool(bool enable = true, // 须在 start() 之前调用
size_t blockSizeBytes = 0, size_t poolBlocks = 0); // 0 = 12KB × 5000
bool isRunning() const;
};
Router
class Router {
public:
void get(const std::string& path, Handler handler);
void post(const std::string& path, Handler handler);
void put(const std::string& path, Handler handler);
void del(const std::string& path, Handler handler);
void patch(const std::string& path, Handler handler);
void head(const std::string& path, Handler handler);
void options(const std::string& path, Handler handler);
void use(const std::string& path, Middleware middleware);
void use(Middleware middleware);
void setNotFoundHandler(Handler handler);
};
HttpRequest
class HttpRequest {
public:
HttpMethod getMethod() const;
std::string getMethodString() const;
std::string getPath() const;
std::string getHeader(const std::string& name) const;
std::string getParam(const std::string& name) const; // 路由参数 :id
std::string getQuery(const std::string& name) const; // 查询参数 ?key=val
std::string getBody() const;
bool isKeepAlive() const;
size_t getContentLength() const;
void setUserData(const std::string& key, const std::string& value);
std::string getUserData(const std::string& key) const;
};
HttpResponse
class HttpResponse {
public:
void setStatus(HttpStatus status);
void setHeader(const std::string& name, const std::string& value);
void setHtml(const std::string& html);
void setJson(const std::string& json);
void setText(const std::string& text);
void setFile(const std::string& filePath);
void setBody(const std::string& body);
void setContentType(const std::string& contentType);
};
故障排除
编译错误:确保依赖完整 apt install libboost-all-dev libmysqlcppconn-dev,清理重编 rm -rf build && cmake -S . -B build && cmake --build build
数据库连接失败:MySQL 不可用时 enableDatabase() 自动降级,检查 sudo systemctl status mysql,或直接不调用 enableDatabase()
端口被占用:netstat -tlnp | grep :8080 查看占用进程
禁止源码内构建:项目强制 out-of-source build。cmake . 会直接 FATAL_ERROR,防止 make clean 误删源文件。始终使用 cmake -S . -B build