[Hical] Router 是怎么做到 O(1) 路由匹配的?兼谈 ~40ns 的 dispatchSync 快速路径#
本专栏文章:拆开 Hical · 第 2 篇
上一篇我们跟踪了一个 HTTP 请求从 socket 字节到响应序列化的全过程。走到 Router.dispatch() 这一步时我们跳过去了——现在把它展开。
一个 HTTP 框架的 Router 基本只有一件事要做:给定 method + path,找到对应的 handler。听起来简单,但"怎么找"的差异可以把延迟拉开一个数量级。
1. 三种路由、三种策略#
Hical 的 Router 支持三种路由:
1
2
3
| 静态路由: GET /api/users → hash map O(1)
参数路由: GET /api/users/{id} → per-method vector 线性匹配
通配符路由: GET /static/*path → 优先级最低,兜底匹配
|
匹配优先级:静态 > 参数 > 通配符。为什么是这个顺序?静态路由一 hash 命中就返回,参数路由需要逐个匹配,通配符是兜底的——先试最快的。
2. 静态路由:透明哈希消除 string_view→string 转换#
2.1 问题#
静态路由的 key 是 method + path 的组合。如果存在 unordered_map 里:
1
2
3
| // 每次查找需要构造 string key
std::string key = std::string(method) + ":" + std::string(path);
auto it = staticRoutes_.find(key); // 产生两次临时 string
|
每个请求都分配两次 string。每秒 10 万请求 → 每秒 20 万次堆分配 → 这不是"优化",这是"灾难"。
2.2 解法:RouteKeyView + is_transparent#
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
| // 组合键:method + path
struct RouteKey
{
HttpMethod method;
std::string path; // owned
};
// 轻量级 view:不做拷贝,直接引用 raw 数据
struct RouteKeyView
{
HttpMethod method;
std::string_view path; // non-owning
};
// 自定义 hash(对 Key 和 KeyView 都可用)
struct RouteKeyHash
{
using is_transparent = void; // ← 关键!启用异构查找
size_t operator()(const RouteKey& k) const
{
return hashMethod(k.method) ^ hashString(k.path);
}
size_t operator()(RouteKeyView k) const
{
return hashMethod(k.method) ^ hashString(k.path);
// string_view 和 string 用同样的 hash 算法
}
};
// 自定义 equal
struct RouteKeyEqual
{
using is_transparent = void; // ← 关键!
bool operator()(const RouteKey& a, const RouteKey& b) const
{
return a.method == b.method && a.path == b.path;
}
bool operator()(RouteKeyView a, const RouteKey& b) const
{
return a.method == b.method && a.path == b.path;
// string_view 可以和 string 做相等比较
}
};
// 最终使用
using StaticRouteMap = std::unordered_map<RouteKey, RouteHandler,
RouteKeyHash, RouteKeyEqual>;
// 查找时:零 string 构造
auto it = staticRoutes_.find(RouteKeyView{method, path});
// string_view 直接传进去,不构造临时 string
|
💡 is_transparent 是 C++14 引入的魔法标记——告诉 unordered_map:“我的 hash/equal 能处理与 key 不同类型但等价的参数”。不开这个标记,find(string_view) 会先构造一个 string,再传进去——那透明哈希就白费了。
3. 参数路由:per-method vector + 路径段匹配#
参数路由的格式是 /api/users/{id}/posts/{postId}。花括号里的部分是路径参数,匹配时提取出来。
1
2
3
4
5
6
7
8
9
| // 每个 method 维护自己的参数路由列表
struct ParamRoute
{
std::string pattern; // "/api/users/{id}"
std::vector<std::string> segments; // ["api", "users", "{id}"]
RouteHandler handler;
};
std::unordered_map<HttpMethod, std::vector<ParamRoute>> paramRoutes_;
|
匹配时:
1
2
3
4
5
6
7
| for (auto& route : paramRoutes_[method])
{
if (tryMatch(route, pathSegments, params)) {
return &route.handler; // 找到了,params 已填充
}
}
return nullptr; // 404
|
为什么参数路由不是 O(1) 的?因为同一个 method 下可能有多个参数路由(/api/{type}/list 和 /api/{type}/detail),必须逐个尝试。但通常情况下同一 method 的参数路由数量是 O(1) 级别的(5-20 个),线性匹配不是瓶颈。
3.1 路径段数预检#
在匹配之前先检查路径段数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| // 参数路由匹配
static constexpr size_t kMaxPathSegments = 32;
static constexpr size_t kMaxParamValueLength = 1024;
bool resolveRoute(method, path, ...)
{
auto segments = splitPath(path); // 按 '/' 分割
// 段数不匹配 → 直接跳过
for (auto& route : paramRoutes_[method])
{
if (segments.size() != route.segments.size()) {
continue; // O(1) 排除
}
// 逐段匹配
if (matchSegments(segments, route.segments, params))
{
return true;
}
}
return false;
}
|
kMaxPathSegments = 32 是路径段数的硬上限——防止攻击者用超长路径耗尽 CPU 或内存。kMaxParamValueLength = 1024 限制单个参数值({id} 部分)的长度。
4. 通配符路由:兜底匹配#
通配符路由的格式是 /static/*path——所有以 /static/ 开头的请求都被路由到这里,剩下的路径段作为参数传入。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| // 通配符路由:`*path` 捕获所有剩余段
struct WildcardRoute
{
std::string prefix; // "/static/"
RouteHandler handler;
};
std::vector<WildcardRoute> wildcardRoutes_;
// 匹配:优先级最低
for (auto& route : wildcardRoutes_)
{
if (path.starts_with(route.prefix))
{
// 提取通配符部分
auto wildcardValue = path.substr(route.prefix.size());
req.setAttribute("wildcard", wildcardValue);
return &route.handler;
}
}
|
通配符路由的关键:它的 *path 捕获的是 URL 编码之前的原始路径。因为 decode 可能改变路径结构(%2F → /),这会破坏路径段分割的语义。
5. resolveRoute:统一入口#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| std::optional<HttpResponse> Router::dispatchSync(HttpRequest& req)
{
auto result = resolveRoute(req.method(), req.path());
// ↑ 统一入口:URL decode → 段数检查 → 静态 → 参数 → 通配符
if (!result.handler)
{
return HttpResponse::notFound();
}
if (result.type == RouteType::hSync)
{
// sync handler → 直接调用,无协程帧
auto& syncHandler = *static_cast<SyncRouteHandler*>(result.handler);
return syncHandler(req); // ~40-130ns
}
// async handler → 需要协程执行,dispatchSync 搞不定
return std::nullopt; // 让调用方 fallback 到 co_await dispatch()
}
|
6. dispatchSync:~40-130ns 的零协程帧快速路径#
这是 Hical Router 最精妙的设计之一。
大多数 Web 框架中,所有请求都走协程调度——即使 handler 内部完全没有 co_await(比如返回一个静态 JSON)。协程帧分配不是免费的:每次几 KB 的堆分配 + future/promise 的构造/析构。
Hical 的做法:
1
2
3
4
5
6
7
8
9
10
11
12
13
| // HttpSessionImpl 中的请求处理循环
auto syncResult = router_.dispatchSync(req); // 先试同步路径
if (syncResult)
{
// 同步命中 → 直接返回,零协程帧!
co_await writeResponse(socket, *syncResult);
}
else
{
// 需要协程 → fallback
auto res = co_await router_.dispatch(req);
co_await writeResponse(socket, res);
}
|
dispatchSync 能省多少? 一次协程帧分配大约:
operator new:~20-50ns(取决于分配器状态和大小)- 协程帧初始化(promise_type 构造):~10-30ns
- future/promise 同步:~10-50ns
合计约 40-130ns。对于同步 handler(验证端点、健康检查、静态配置返回),这 40-130ns 是纯浪费。
💡 dispatchSync 的核心思想很简单:“不调度"也是调度。当 handler 不需要协程时,Router 就不分配协程帧——跳过 ~40-130ns 的无用开销。
7. URL 解码的路径穿越保护#
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
| std::string Router::urlDecode(std::string_view encoded)
{
std::string result;
result.reserve(encoded.size());
for (size_t i = 0; i < encoded.size(); ++i)
{
if (encoded[i] == '%' && i + 2 < encoded.size())
{
int high = hexValue(encoded[i + 1]);
int low = hexValue(encoded[i + 2]);
char decoded = static_cast<char>((high << 4) | low);
// 路径穿越检测:解码后的 '/' 或 '\' 拒绝
if (decoded == '/' || decoded == '\\') {
throw std::runtime_error("Path traversal detected");
}
result.push_back(decoded);
i += 2;
}
else if (encoded[i] == '+')
{
result.push_back(' '); // application/x-www-form-urlencoded 约定
}
else
{
result.push_back(encoded[i]);
}
}
return result;
}
|
路径穿越是 Web 框架的经典漏洞——攻击者用 %2e%2e%2f(URL 编码的 ../)试图访问父目录的文件。Hical 在 URL decode 阶段就检测了——一旦解码出 / 或 \,直接拒绝请求。
回顾一下你学会了什么#
- 三种路由策略:静态(hash O(1))→ 参数(vector 线性)→ 通配符(starts_with)
- 透明哈希
is_transparent 消除 string_view→string 的临时构造 - 路径段数预检 O(1) 排除不匹配的参数路由
- 通配符路由在 URL decode 之前匹配——防止
%2F 破坏段分割 - dispatchSync 零协程帧快速路径:同步 handler 40-130ns,跳过协程帧分配
- URL decode 时检测路径穿越——
%2f→/ 直接拒绝 - 安全约束:最多 32 个路径段、参数值最长 1024 字节
下一篇:中间件洋葱模型怎么做到"连续 N 个同步中间件只分配一次协程帧”?