基于 Boost 的 HTTP 服务器:框架设计

基于 Boost 的 HTTP 服务器:框架设计

本文是 http_server 系列的框架篇,对应总览中的"三层架构"部分 展开三个核心设计决策:请求处理的解耦、并发模型的选择、缓存的分层演进

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>&)>;

Handlerstd::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 生成内容响应
graph TD subgraph 网络层 listener[listener] session[session] end subgraph 处理层 request_handler[request_handler] router[router] end subgraph 服务层 static_service[static_file_service] dynamic_api[dynamic_api_service] cache[cache] end listener-->|accept|session session-->|async_read -> 请求报文| request_handler request_handler-->|路由匹配| router router-->|分发| static_service router-->|分发| dynamic_api static_service-.->|读缓存| cache static_service-->|message_generator 响应| session dynamic_api-->|message_generator 响应| session

session 状态机

session 是网络层的核心,其生命周期是一个异步状态机:

stateDiagram-v2 [*] --> run: 连接建立 run --> do_read: strand 派发 do_read --> on_read: async_read 完成 on_read --> send_response: 业务返回响应 send_response --> on_write: async_write 完成 on_write --> do_read: keep-alive on_write --> do_close: 非 keep-alive on_read --> do_close: 超时 / EOF / 解析错误 do_close --> [*]

各状态的实现要点(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 模块):

  1. signals_(ioc, SIGINT, SIGTERM) 捕获信号
  2. listener->stop():关闭 acceptor,不再接受新连接
  3. check_timer_ 每 1s 轮询活跃会话数,清零则关闭完成
  4. 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);
    });

在途请求有机会完成,未完成的请求被强制终止,服务以确定的状态退出,确保了进程状态安全


缓存设计演进

缓存并非是一次性设计出来的,而是随项目同步演进

graph TD subgraph 请求路径 req[请求 target] path_cache[路径解析缓存<br/>map + shared_mutex<br/>4096 上限] end subgraph 内容缓存 lru[LRU 内容缓存<br/>lru_cache 泛型<br/>cached_file 条目] gzip[gzip 预压缩版本<br/>cached_file.compressed] end subgraph 响应构造 body[shared_string_body<br/>零拷贝 writer] end req -->|路径解析/校验| path_cache path_cache -->|full_path| lru lru -->|content + ETag/Last-Modified| body lru -->|compressed| gzip gzip -->|按 Accept-Encoding 选择| body

阶段一:无缓

每请求 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 全局加锁
会话内状态 免锁(串行回调) 需锁保护
锁争用 高并发下明显
代价 同连接回调串行 锁粒度控制复杂

缓存:层次化与单一

对比项 层次化缓存 单一缓存
对象特征 按大小/频率分层 一刀切
锁策略 各自适配 全局统一
复杂度 略高

END

相关推荐
DsirNg1 天前
一文讲清网络协议:从打开网页到理解 HTTP、HTTPS 与 TLS
http·https·dns·网络基础·入门教程·tls·web 安全
奈斯先生Vector1 天前
2026 AIGC API 网关韧性评测:为什么 HTTP 200 不等于有效交付
网络协议·http·aigc
谏书稀1 天前
C++/Python 混合编程(CPython)中 GIL饥饿 导致的 HTTP 服务无响应问题与解决方案
c++·python·http
全麦面包 time展天2 天前
瀚海拾贝(一)HTTP协议/IIS 原理及ASP.NET运行机制浅析【图解】
网络协议·http·asp.net
godwaskillde2 天前
Java 11 HttpClient如何配置HTTP代理?连接测试与异常处理示例
java·开发语言·http
小白说大模型2 天前
从0开始学计算机网络:HTTP 协议的进化史
人工智能·网络协议·计算机网络·http
无糖可乐没有灵魂2 天前
Security ❀ Https TLS抓包操作与解密配置
网络协议·http·https
就叫年华吧丶2 天前
Puppeteer 运行一段时间后报 `net::ERR_BLOCKED_BY_CLIENT`:一次 Chromium HTTPS 自动升级问题的完整排查
网络协议·http·https·puppeteer·chromium
半支烟隐 pzishuo3 天前
HTTP Live Streaming(HLS)直播技术分析与实现
网络·网络协议·http