[Hical] 中间件洋葱模型怎么做到"连续 N 个同步中间件只分配一次协程帧"?#
本专栏文章:拆开 Hical · 第 3 篇
上一篇文章讲了 Router 的 dispatchSync——当 handler 是同步的,跳过协程帧分配。同样的思路也应用在中间件上。
大多数框架的中间件是"一层一个协程"——3 个中间件 = 3 个协程帧 = 3 次堆分配。但 Hical 问了一个问题:如果 N 个中间件都是同步的(不用 co_await),为什么不能把它们合并成一个协程帧?
答案是:可以。而且 Hical 实现了。
1. 先理解洋葱模型#
1.1 标准中间件的三个角色#
一个 HTTP 请求经过中间件管道时,有三种类型的操作:
1
2
3
| 请求进来 ──→ before1 ──→ before2 ──→ handler ──→ after2 ──→ after1 ──→ 响应出去
↑ ↑ ↑ ↑
前置拦截 前置处理 后置修改 后置处理
|
- Before:在 handler 之前执行。可以拦截请求、直接返回响应(如认证失败返回 401)
- Handler:核心业务逻辑
- After:在 handler 之后执行。可以修改响应头(如加安全头、压缩 body)
1.2 传统方案:一层一个协程#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| // 全异步中间件链:N 个中间件 = N 个协程 lambda
MiddlewareNext makeChain(asyncHandler1, asyncHandler2, asyncHandler3, finalHandler)
{
// 从内到外构建
auto inner = [h3, finalHandler](req) -> Awaitable<HttpResponse>
{
co_return co_await h3(req, finalHandler);
};
auto middle = [h2, inner](req) -> Awaitable<HttpResponse>
{
co_return co_await h2(req, inner);
};
auto outer = [h1, middle](req) -> Awaitable<HttpResponse>
{
co_return co_await h1(req, middle);
};
return outer;
}
|
3 个协程 lambda → 3 个协程帧 → 3 次堆分配。
1.3 但你想想:RateLimiter 需要协程吗?#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| // RateLimiter 的 before:纯同步操作
SyncMiddlewareResult rateLimiter(HttpRequest& req)
{
auto ip = req.clientIp();
if (!tokenBucket.consume(ip))
{
return HttpResponse::tooManyRequests(); // 拦截
}
return std::nullopt; // 放行
}
// Helmet 的 after:纯同步操作
void helmet(HttpRequest& req, HttpResponse& res)
{
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("X-Frame-Options", "DENY");
// ... 5 个更多安全头
}
|
RateLimiter 和 Helmet 都不需要 co_await 任何东西。把它们套进协程 lambda 里是浪费——白分配了 2 个协程帧。
2. 解法:tagged union + 连续 Sync 合并#
2.1 MiddlewareEntry 的 tagged union#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| struct MiddlewareEntry
{
enum class Type { hAsync, hSync };
Type type;
std::string name;
// Async:完整洋葱模型(一层一个协程帧)
MiddlewareHandler asyncHandler;
// Sync:前/后分离(零协程帧)
SyncBeforeHandler before;
SyncAfterHandler after; // 可为空
};
|
注册时:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| // Async 中间件:走标准路径
pipeline.use([](HttpRequest& req, MiddlewareNext next) -> Awaitable<HttpResponse>
{
auto start = std::chrono::steady_clock::now();
auto res = co_await next(req); // ← 有 co_await
auto elapsed = std::chrono::steady_clock::now() - start;
res.setHeader("X-Response-Time", fmt::format("{}us", elapsed.count() / 1000));
co_return res;
});
// Sync 中间件:零协程帧
pipeline.use
(
// before:前置处理(可拦截)
[](HttpRequest& req) -> SyncMiddlewareResult
{
if (!checkAuth(req))
{
return HttpResponse::unauthorized(); // 拦截
}
return std::nullopt; // 放行
},
// after:后置处理(可选)
[](HttpRequest& req, HttpResponse& res)
{
res.setHeader("X-Frame-Options", "DENY");
}
);
|
2.2 buildOptimizedChain:合并算法#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
| MiddlewareNext buildOptimizedChain(const std::vector<MiddlewareEntry>& entries,MiddlewareNext finalHandler)
{
if (entries.empty()) return finalHandler;
// 快捷路径:如果全是 Async,直接退化为 buildChainFrom
// (避免无意义的 while 循环遍历)
if (/* entries 中没有 hSync 条目 */)
{
std::vector<MiddlewareHandler> asyncHandlers;
for (const auto& e : entries)
asyncHandlers.push_back(e.asyncHandler);
return buildChainFrom(asyncHandlers, std::move(finalHandler));
}
MiddlewareNext current = std::move(finalHandler);
int i = entries.size() - 1;
while (i >= 0)
{
if (entries[i].type == MiddlewareEntry::Type::hAsync)
{
// Async 中间件:独立协程 lambda
auto mw = entries[i].asyncHandler;
current = [mw, next = current](HttpRequest& r) -> Awaitable<HttpResponse>
{
// 直接 return 转发 awaitable,不创建额外协程帧
return mw(r, next);
};
--i;
}
else
{
// 收集连续 Sync 中间件
int syncEnd = i;
while (i >= 0 && entries[i].type == MiddlewareEntry::Type::hSync) --i;
int syncStart = i + 1;
// 提取这组 Sync 的 before/after
std::vector<SyncBeforeHandler> befores;
std::vector<SyncAfterHandler> afters;
for (int j = syncStart; j <= syncEnd; ++j)
{
if (entries[j].before) befores.push_back(entries[j].before);
if (entries[j].after) afters.push_back(entries[j].after);
}
// 合并为单个协程 lambda:N 个 Sync → 1 个协程帧!
current = [befores, afters, next = current](HttpRequest& r) -> Awaitable<HttpResponse>
{
// 前置:按注册顺序执行(外层先执行)
for (const auto& before : befores)
{
auto intercepted = before(r);
if (intercepted) co_return std::move(*intercepted);
}
// 唯一的协程挂起点
auto res = co_await next(r);
// 后置:按注册逆序执行(洋葱模型语义)
for (int k = afters.size() - 1; k >= 0; --k)
{
afters[k](r, res);
}
co_return res;
};
}
}
return current;
}
|
💡 两个关键洞察:
- Async 条目的
return mw(r, next) 不是笔误——lambda 本身不需要是协程,直接转发 awaitable 给外层的 co_await,比 co_return co_await 少分配一个协程帧。这就是"转发协程消除"优化。 - 连续 Sync 中间件的 before 按注册顺序执行(外层先),after 按注册逆序执行(后注册的后置先退出)。这就是洋葱模型——进入时穿透到最内层,退出时从最内层逐层返回。
2.3 效果对比#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| 中间件注册顺序:
JwtAuth (Sync) → RateLimiter (Sync) → LogMiddleware (Async) → Helmet (Sync)
传统方案:
协程帧 #1: JwtAuth
协程帧 #2: RateLimiter
协程帧 #3: LogMiddleware (有 co_await)
协程帧 #4: Helmet
协程帧 #5: finalHandler
共 5 个协程帧
Hical 优化链:
合并组 #1: JwtAuth + RateLimiter → 1 个协程帧
协程帧 #2: LogMiddleware (不可合并,有 co_await)
合并组 #3: Helmet → 但 Helmet 接着 LogMiddleware 之后...
等等,Helmet 是 Sync 但和 JwtAuth/RateLimiter 不是连续的——
中间被 LogMiddleware (Async) 隔开了。
所以 Helmet 单独成一个协程帧。
最终: 合并组 #1 (JwtAuth+RateLimiter, 1 帧)
+ LogMiddleware (1 帧)
+ Helmet (1 帧)
+ finalHandler (1 帧)
共 4 个协程帧 (省了 1 个)
|
优化比例取决于 Sync 中间件的连续性。 如果 JwtAuth + RateLimiter + Helmet 三个 Sync 连在一起注册(没有 Async 中间件隔开),就是 1 个协程帧——省了 2 个。
3. SyncAfterHandler 为什么禁止修改 body?#
你可能会注意到文档中的警告:
1
2
3
| @warning 禁止修改响应体(body)。因为 Content-Length 已由 setBody/setJsonBody 中的
prepare_payload() 固定,修改 body 会导致 Content-Length 与实际不匹配,
在反向代理后可能引发 HTTP 响应走私或客户端截断。
|
这不是一个随意加的限制。原因是:Content-Length 在 body 设置时已经通过 to_chars 计算好了。如果 after handler 修改了 body 但没有重新计算 Content-Length,响应头中的长度就和实际 body 长度对不上。
在反向代理(nginx/envoy)后面,这可能导致 HTTP 响应走私漏洞——攻击者利用长度不匹配把一个响应的 body 注入到另一个响应中。
如果你需要在 after 阶段改 body,用完整的 Async 中间件(MiddlewareHandler),在 co_await next(req) 之后修改响应再 co_return。
4. MiddlewareProfiling:零侵入的计时统计#
当用 HICAL_ENABLE_MIDDLEWARE_PROFILING=ON 编译时,每个中间件自动记录耗时且不造成 false sharing:
1
2
3
4
5
6
7
8
9
10
11
| struct MiddlewareTimingStats
{
// 热数据:每次请求都写入,各自独占 cache line
alignas(64) std::atomic<size_t> callCount {0};
alignas(64) std::atomic<int64_t> totalTimeUs {0};
alignas(64) std::atomic<int64_t> maxTimeUs {0};
alignas(64) std::atomic<int64_t> minTimeUs {std::numeric_limits<int64_t>::max()};
// 冷数据:仅查询统计时读取
std::string name;
};
|
四个原子变量各自独占 64 字节 cache line。如果四个挤在同一条 64 字节中(总共才 32 字节),多核写的时候互相刷 cache line。分开后,4 个核同时更新 4 个中间件的统计不冲突。
💡 四个原子变量各自独占 64 字节 cache line。如果四个挤在同一条 64 字节中(总共才 32 字节),多核写的时候互相刷 cache line。分开后,4 个核同时更新 4 个中间件的统计不冲突——这就是 false sharing 的经典解法。
5. After 的逆序执行——洋葱模型的完整语义#
注意 buildOptimizedChain 中的这行代码:
1
2
3
4
5
| // 后置:按注册逆序执行
for (int k = static_cast<int>(afters.size()) - 1; k >= 0; --k)
{
afters[k](r, res);
}
|
假设你注册了三个 Sync 中间件:
1
2
3
| pipeline.use("timing", timingBefore, timingAfter); // 最先注册
pipeline.use("cors", corsBefore, corsAfter); // 第二个注册
pipeline.use("helmet", helmetBefore, helmetAfter); // 最后注册
|
执行顺序是:
1
2
3
4
5
6
7
8
9
| 请求进来
→ timingBefore() (1)
→ corsBefore() (2)
→ helmetBefore() (3)
→ handler() (核心业务)
→ helmetAfter() (3 - 最先退出)
→ corsAfter() (2)
→ timingAfter() (1 - 最后退出)
响应出去
|
进入时 outer→inner,退出时 inner→outer。 这就是"洋葱模型"——和 ASP.NET Core、Express.js 的行为完全一致。
回顾一下你学会了什么#
- MiddlewareEntry 的 tagged union 区分 Async(一层一帧)和 Sync(零帧)
- buildOptimizedChain 合并连续 Sync 中间件——N 个 Sync = 1 个协程帧
- Sync 中间件的 before 按注册顺序执行,after 按注册逆序执行(洋葱语义)
- SyncAfterHandler 禁止改 body——Content-Length 已固定,改了会引发 HTTP 响应走私
- MiddlewareProfiling 的 alignas(64) 消除 false sharing——每个原子变量独占一条 cache line
- 合并不是"无脑的"——被 Async 隔开的 Sync 中间件不能合并
下一篇:Vyukov MPSC 无锁队列在 HTTP 服务器上的实战:GenericConnection 的写路径