基于 Boost 的 HTTP 服务器:框架设计
本文是 http_server 系列的框架篇,对应总览中的"三层架构"部分 展开三个核心设计决策:请求处理的解耦、并发模型的选择、缓存的分层演进
请求处理:从耦合到解耦
官方示例的耦合问题
Beast 官方示例(http/server)中,请求处理逻辑直接写在 session 的回调链里: on_read 拿到解析完成的请求后,立即在连接上下文中构造响应------文件读取、响应头构造、 body 填充全部与 stream_、buffer_、parser_ 这些连接私有成员写在一起。
这种写法的问题在于:业务逻辑与连接生命周期强耦合
导致:
- 新增一种业务(例如 POST 动态接口),必须修改网络层代码
- 业务无法脱离连接独立测试
- 连接的状态机被业务分支侵入,可读性与可维护性同时下降
两种解耦方案的灵活度
如何让业务与连接解耦? 两种思路:回调注入,或抽象接口 + 路由表
回调注入
业务以 lambda / 函数指针形态直接注入处理流程,简单直接,无类型约束。
- 灵活:任何可调用对象都可成为处理器
- 代价:没有统一签名约束,处理器的入参、返回值形态各异,难组合、难替换、难测试
抽象接口 + 路由表
定义统一处理器签名,注册进路由表,请求经路由匹配后分发。
- 约束:所有业务遵循同一签名,可注册、可替换、可统一测试
- 代价:需要引入类型抽象与注册机制,骨架成本更高
项目中的结合
本项目采取以抽象接口为主、回调注入为辅的组合:
统一签名为骨架:
cpp
// includes/router.hpp
using Handler = std::function<http::message_generator(const http::request<http::string_body>&)>;
Handler 是 std::function 包装器------签名统一,作为抽象接口, 承载形态自由(回调注入的特征),两种方案于此处合流。
回调注入体现在两处:
- 默认处理器:main 中直接以 lambda 确保 404 code 的最低限度响应处理
- 动态 API 端点:
handle_hello/handle_echo函数指针直接注册(于src/main.cpp中进行的注册测试)
cpp
// src/main.cpp
auto default_handler = [](const auto& req) {
return server_utils::make_not_found(req, req.target());
};
auto dynamic_api = std::make_shared<server_service::dynamic_api_service>("/api");
dynamic_api->add_endpoint(http::verb::get, "/api/hello", handle_hello);
最终设计
请求处理层由三个对象构成:
| 对象 | 职责 | 关键成员 |
|---|---|---|
Handler |
统一处理器签名 | std::function<message_generator(const request&)> |
router |
路由匹配 | 精确路由 unordered_map + 前缀路由 vector |
request_handler |
分发入口 | 共享路由表 + 默认处理器 |
cpp
// includes/request_handler.hpp
class request_handler
{
using router_ptr = std::shared_ptr<router>;
private:
router_ptr routers_; // 共享路由表
Handler default_handler_; // 默认处理器
handle_request 的完整实现只需要 6 行:匹配到则调用,否则使用 404 响应
cpp
// src/request_handler.cpp
server_service::http::message_generator
server_service::request_handler::
handle_request(const http::request<http::string_body>& req)
{
if (auto matched_handler = routers_->match(req))
{
return matched_handler(req);
}
return default_handler_(req);
}
main 中的组装关系:
cpp
// src/main.cpp
auto static_service = std::make_shared<server_service::static_file_service>(config);
auto static_handler = static_service->as_handler();
auto router = std::make_shared<server_service::router>();
router->add_prefix_route(http::verb::get, "/", static_handler);
router->add_prefix_route(http::verb::head, "/", static_handler);
服务对象通过 as_handler() 转换为 Handler 注册进路由表; request_handler 组合路由表后注入 listener,session 只与 request_handler 进行交互------ 网络层不需要理解任何具体业务细节,业务层不掌握任何连接细节
message_generator:解耦的基石
解耦成立的前提是返回类型的完备性。http::message_generator 是 Beast 对"响应"的抽象:
- 可持有立即构造的普通响应(
response<...>对象) - 也可持有尚未完成的异步响应(deferred,响应数据未就绪时先返回占位)
业务层只需要"接收一个请求对象,返回一个响应生成器", 响应何时写回、连接是否复用(msg.keep_alive())均由网络层处理------ 业务层对连接一无所知,连接层对业务也一无所知
三层架构与并发模型
三层职责划分
对于个人开发,架构层面应该尽量简单
三层划分即"连接接收 / 路由匹配与分发 / 响应内容",不再细分:
| 层 | 模块 | 职责 |
|---|---|---|
| 网络层 | listener / session | 接收连接、读写报文、超时与限流 |
| 处理层 | request_handler / router | 请求分发与路由匹配 |
| 服务层 | static_file_service / dynamic_api_service / cache | 生成内容响应 |
session 状态机
session 是网络层的核心,其生命周期是一个异步状态机:
各状态的实现要点(src/server.cpp):
- do_read :每次读前
parser_.emplace()新建解析器------新实例隔离前后请求的解析状态 ,天然避免 keep-alive 下状态残留;body_limit()超限时async_read直接返回 413 code - on_read :错误码分流------
end_of_stream正常关闭、timeout超时关闭、其余记日志;解析成功则取出请求交给业务层 - send_response :取
msg.keep_alive()后async_write写回 - on_write:按 keep-alive 决定回到 do_read 或 do_close
每连接 strand 的决策
并发模型:单 io_context + 多线程事件循环
cpp
// src/main.cpp
net::io_context io{static_cast<int>(config.threads())};
std::vector<std::thread> thrds;
thrds.reserve(config.threads() - 1);
for (size_t i = 1; i < config.threads(); ++i) {
thrds.emplace_back([&io] { io.run(); });
}
io.run();
N 个线程共享一个 io_context 调用 io.run(),事件循环内部调度完成处理器。
线程间如何避免数据竞争?
选择:每连接独立 strand
cpp
// src/server.cpp
acceptor_.async_accept(
net::make_strand(ioc_), // 每个新连接获得独立 strand
beast::bind_front_handler(&listener::on_accept, shared_from_this())
);
每连接一个 strand,该连接上的所有回调(读、写、定时器)串行执行。 strand 内的代码无需加锁------session 的 parser_、buffer_ 全程免锁, 整个 session 没有一处 mutex
对于其他异步模型的选择权衡:
- 每连接一个线程:并发受线程数限制,线程/栈开销随连接数线性增长
- 裸异步回调:连接间回调可能并发触碰共享状态,需要自行加锁
- 全局共享锁:锁争用成为高并发下的吞吐瓶颈,并且在项目演进中难以优化
strand 的代价是回调无法并行执行,但是同一连接的读写天然串行,损失小, 换来的是会话状态零锁设计------高并发静态服务场景下是更优解
超时与限流
超时 :beast::tcp_stream 将套接字与超时合为一体
cpp
// src/server.cpp
stream_.expires_after(std::chrono::seconds(config_.timeout_seconds()));
每次读(do_read)与写(send_response)前刷新超时, 超时表现为 async_read / async_write 返回 beast::error::timeout, on_read / on_write 中捕获该错误码直接 do_close()
限流:活跃会话原子计数 + accept 预检
cpp
// src/server.cpp
if(session::active_sessions() >= config_.max_connections()) {
// 拒绝:写 503 + Retry-After 后关闭,不创建 session
auto stream = std::make_shared<beast::tcp_stream>(std::move(socket));
stream->expires_after(std::chrono::seconds(5));
http::async_write(*stream,
server_utils::make_service_unavailable(11, false, "Too Many Connections"),
...
数据处理流程:accept 成功 -> 原子计数预检 -> 超限则用临时 tcp_stream 异步写 503 code, (含 Retry-After 头)后 shutdown;未超限制再创建 session 会话 拒绝路径不进入业务层,限流开销仅为一次计数比较
优雅关闭
graceful_shutdown模块
信号捕获 + 双定时器(graceful_shutdown 模块):
signals_(ioc, SIGINT, SIGTERM)捕获信号listener->stop():关闭 acceptor,不再接受新连接check_timer_每 1s 轮询活跃会话数,清零则关闭完成force_timer_30s 限时:超时强制io.stop()
cpp
// src/graceful_shutdown.cpp
signals_.async_wait(
[this](const boost::system::error_code& ec, int signal_number) {
return on_signal(ec, signal_number);
});
在途请求有机会完成,未完成的请求被强制终止,服务以确定的状态退出,确保了进程状态安全
缓存设计演进
缓存并非是一次性设计出来的,而是随项目同步演进
阶段一:无缓
每请求 open + stat + read 三次系统调用,重复读盘、重复系统调用。 warm 路径的系统调用成为明显热点,影响了报文处理效率,是当时最大性能短板,影响处理效能
阶段二:LRU 内容缓存
引入泛型 lru_cache 模板:std::list 维护访问顺序 + unordered_map 建立 key 到迭代器的索引, 命中时 splice 到队首,容量满时驱逐队尾。线程安全由内部 mutex 保证。
该阶段的缓存进化为记录完整文件内容 + 元数据(ETag / Last-Modified)
阶段三:shared_ptr 零拷贝
缓存的下一层问题:值拷贝 问题。每次命中把字符串复制进响应体,大文件成本显著。(拷贝 std::string 对象)
cached_file 改进为持有 std::shared_ptr<const std::string>, 并自定义 Beast Body 类型 shared_string_body------writer 直接指向缓存数据, 响应写出零拷贝,body 数据与缓存共享一份内存 ,只需要进行std::shared_ptr<>的引用计数增加
cpp
// includes/static_file_service.hpp
struct cached_file
{
std::shared_ptr<const std::string> content;
std::string etag;
std::string last_modified;
std::time_t last_modified_time = 0;
std::chrono::steady_clock::time_point expires_at;
std::shared_ptr<const std::string> compressed; // gzip 预压缩版本
};
阶段四:路径解析缓存
内容缓存解决读盘,路径解析的系统调用 仍是热点: 路径拼接、合法性检查、weakly_canonical 规范化、目录存在性检查。
引入独立的路径解析缓存:std::map + 透明比较器实现 string_view 异构查找 (免构造临时 key,由于项目设定为C++17所以没有使用std::unoredered_map的透明比较器实现), shared_mutex 支持并发读取------读多写少场景比互斥锁吞吐更高,减少了读竞争, 容量上限 4096 防止无限增长
阶段五:TTL 失效
缓存内容过期问题:文件在服务器运行期间可能被修改,缓存会返回旧内容。
选择 TTL 缓冲失效:cache_ttl_seconds 配置 + 条目 expires_at 时间戳,命中时校验过期则驱逐重读。 未采用 inotify 主动失效------文件监听复杂度较高与 30s 内旧内容的可接受度相比,得不偿失, 但是,如果提高缓存驻留时间,该设计需要再次权衡是否进行重构
阶段六:gzip 预压缩
压缩是 CPU 密集操作,若每个请求实时压缩,高并发下 CPU 成为瓶颈。
预压缩设计:读盘时按 MIME 类型判断是否可压缩文本,压缩一次存入 compressed 同时存入缓存, 协商从"压缩"变成"选择"------按 Accept-Encoding 挑选已有的缓存版本,减少了后续文件的压缩消耗
双缓存的设计缘由
为什么路径缓存与内容缓存分开?
两者对象特征不同:
| 对比项 | 路径缓存 | 内容缓存 |
|---|---|---|
| 对象大小 | 小(路径字符串) | 大(文件内容) |
| 访问频率 | 高频(每请求必查) | 随内容命中率 |
| 锁策略 | shared_mutex(读多写少) | mutex(读写竞争) |
| 容量 | 4096 固定上限 | 配置项 max_cache_entries |
对象大小与访问频率不同,锁策略与容量策略也不同------分层是更自然合理的取舍
设计总结
请求处理:解耦
| 对比项 | 抽象接口 + 路由表 | 回调注入 |
|---|---|---|
| 签名约束 | 统一 Handler | 无约束 |
| 组合性 | 可注册、可替换 | 散落各处 |
| 测试性 | 可独立测试 | 依赖宿主 |
| 项目定位 | 骨架 | 默认处理器、API 端点 |
并发:strand 与锁
| 对比项 | 每连接 strand | 全局加锁 |
|---|---|---|
| 会话内状态 | 免锁(串行回调) | 需锁保护 |
| 锁争用 | 无 | 高并发下明显 |
| 代价 | 同连接回调串行 | 锁粒度控制复杂 |
缓存:层次化与单一
| 对比项 | 层次化缓存 | 单一缓存 |
|---|---|---|
| 对象特征 | 按大小/频率分层 | 一刀切 |
| 锁策略 | 各自适配 | 全局统一 |
| 复杂度 | 略高 | 低 |