路由表与 layer 链
主线位置:请求主线的中段,以及启动时的建表。
dispatch找到路由之后,请求在这里穿过 layer 链到达 handler;这些链是build时一次编好的。
flowchart TB
subgraph builder["RouterBuilder 收集"]
routes["route:dyn Route"]
pages["page:dyn Page"]
layouts["layout:dyn Layout"]
layers["layer:dyn Layer"]
end
pages --> pwl["PageWithLayouts:page 加前缀匹配的 layout"]
layouts --> pwl
pwl --> regs["全部注册项:都是 Route"]
routes --> regs
regs --> ep["Endpoints:matchit 路径 → 方法表"]
regs --> rt["Routes:每个路由带自己的 layer 栈"]
layers --> rt
layers --> always["always_layers:没有路径的 layer"]
ep --> twins["尾斜杠的另一种写法:重定向路由"]
四种注册项
RouterBuilder 收集四种东西,各是一个 trait object 的列表:
| 方法 | trait | 是什么 |
|---|---|---|
.route(r) | Route | 一个 handler:id、methods、path、handle(cx, body) -> Future<Result<Response>>。#[route]、shard、procedure、TowerRoute 都是它 |
.page(p) | Page | 一个页面:render(cx, body) -> BoxView,返回的是视图而不是响应 |
.layout(l) | Layout | 外壳:render(cx, slot) -> BoxView,slot 是里面那一层的视图 |
.layer(l) | Layer | 中间件:path() -> Option<&Path>、handle(cx, body, next) |
此外还有几个只有一份的配置:origin_policy、trusted_proxies、trailing_slash、compression,以及按类型存值的 app_context。#[page] 这些宏生成的类型就是上面某个 trait 的实现,模块路由与发现里展开一个看。
build:两张表
build 一共做六件事。
一、给每个 page 配 layout。路径是 page 路径前缀的 layout 都算,按路径长度从短到长排,page 嵌在最里面:
let mut registrations = self.routes;
for page in self.pages {
let mut matching: Vec<Arc<dyn Layout>> = self
.layouts
.iter()
.filter(|layout| page.path().starts_with(layout.path()))
.cloned()
.collect();
matching.sort_by_key(|layout| layout.path().len());
registrations.push(Box::new(PageWithLayouts::new(page, matching)));
}
从这里开始 page 也只是一个 Route(PageWithLayouts 实现了 Route)。渲染时 PageWithLayoutsInner::render 先拿到 page 的视图,再从最内层的 layout 往外,一层层用 Slot::new(view) 包起来。
二、按 matchit 路径把路由归到端点上。同一个 URL 路径的路由共用一个 Endpoint;分组的键是 to_matchit_path(),也就是去掉组段 (name) 之后的路径,所以「组不同、URL 相同」的两个 page 会落到同一个端点上。
Endpoint 是一张方法表:
pub struct Endpoint {
standard: [Option<RouteIndex>; STANDARD_METHODS.len()],
other: HashMap<Method, RouteIndex>,
any: Option<RouteIndex>,
path: Box<str>,
}
九个标准方法各占数组的一格,扩展方法放进 HashMap,any 是 #[route(*)]。RouteIndex 包着一个 NonZeroUsize(存的是下标加一),这样 Option<RouteIndex> 和 usize 一样大,九格的数组只占 72 字节。
三、给每个路由选 layer 栈,见下一节。路由连同它的 layer 栈存进 Routes,端点里只存下标。
四、查重。同一端点上同一方法注册两次、两个 any 路由,都 panic;一个带路径的 layer 一个路由都没包到,也 panic(多半是路径写错了),只有根路径的 layer 除外。
五、HEAD 指向 GET:没有显式 HEAD 路由的端点,HEAD 槽复制 GET 槽(上一页的实验)。
六、尾斜杠的另一种写法,见后文。
最后得到的 RouterInner 有 routes、endpoints、always_layers、app_context、origin、trusted_proxies、compression 七个字段,整个包在一个 Arc 里,Router 的克隆只是克隆这个 Arc。
Layer 与 Next
Layer 只有两个方法:
pub trait Layer: Send + Sync + 'static {
/// The URL path prefix whose matched routes this layer wraps, or `None`
/// to wrap every request.
fn path(&self) -> Option<&Path>;
/// Handles a request, calling `next` to continue down the chain.
fn handle<'a>(&'a self, cx: &'a Cx, body: Body, next: Next<'a>) -> LayerFuture<'a>;
}
LayerFuture<'a> 是 Pin<Box<dyn Future<Output = Result<Response>> + Send + 'a>>。它借用 cx 和 layer 自己,所以 layer 不用 Clone、不用 Arc,也没有 tower 那种 poll_ready。#[layer] 宏把一个 async fn(cx: &Cx, body: Body, next: Next<'_>) -> Result<Response> 变成这个 trait 的实现。
Next 是「还没跑的 layer 切片」加「终点」:
pub fn run(self, cx: &'a Cx, body: Body) -> LayerFuture<'a> {
match self.layers.split_first() {
Some((layer, rest)) => layer.handle(
cx,
body,
Next {
layers: rest,
..self
},
),
None => match self.terminal {
Terminal::Route(route) => route.handle(cx, body),
Terminal::MethodNotAllowed(endpoint) => {
let error = method_not_allowed(endpoint.methods().cloned());
Box::pin(async move { Err(error.into()) })
}
Terminal::NotFound => Box::pin(async { Err(not_found().into()) }),
},
}
}
每走一格,切片短一格;切片空了就跑终点。404 和 405 也是终点,只不过终点直接返回一个 Err,所以 layer 对「handler 的结果」和「路由失败」看到的是同一种东西:next.run 返回的 Result。
匹配上的路由,它的 layer 栈存在 RouteWithLayers 里,handle 就是 Next::new(&self.layers, Terminal::Route(&*self.route)).run(cx, body)。这就是为什么 dispatch 对匹配上的路由传一个空切片:layer 已经在路由里了。
和 axum 比,这个模型更接近 axum 的 from_fn 里的 Next,而不是 tower 的层层嵌套类型:axum 在 .layer() 时就把中间件套成一个新的 Service 类型,再擦除成 Route(Route 与类型擦除);topcoat 的每个 layer 都是 dyn Layer,每走一格装箱一个 future。好处是 layer 能借用上下文、不需要 Clone;代价是每层一次动态分发和一次分配,并且编译期不知道链上有什么。
哪些 layer 包住哪些路由
pub(crate) fn layers_for_path(layers: &[Arc<dyn Layer>], path: &Path) -> Box<[Arc<dyn Layer>]> {
let mut matching: Vec<&Arc<dyn Layer>> = layers
.iter()
.filter(|layer| layer.path().is_none_or(|prefix| path.starts_with(prefix)))
.rev()
.collect();
// The root path's backing string is empty, so its length is offset to
// keep a pathless layer strictly less specific than a layer at `/`.
matching.sort_by_key(|layer| layer.path().map_or(0, |path| path.len() + 1));
matching.into_iter().cloned().collect()
}
规则有三条:
- 没有路径的 layer 包住所有请求,排在最外面;建表时还另存一份到
always_layers,404、405 和尾斜杠重定向只经过它们。 - 有路径的 layer 只包注册路径以它开头的路由,路径越短越靠外。比较的是注册时的路径,逐段比,不看请求 URL:参数段只和同名参数段相等,组段也算数。
#[layer]的文档举了两个例子:/docs/admin的 layer 不包/docs/{*path}的 page(尽管后者也服务/docs/admin/...这些 URL);/dashboard的 layer 不包/(auth)/dashboard的 page(尽管 URL 都是/dashboard)。 - 同一路径的多个 layer,后注册的在外(先
.rev()再稳定排序)。.discover()收集到的 layer 不允许同路径,因为链接期收集的顺序不稳定。
上一页的实验里注册了三个 layer:一个没有路径的(.layer(Pathless)),一个根模块里的 #[layer](路径是 /),一个 #[layer("./api")](路径 /api)。GET /api/items 的打印:
pathless > GET /api/items
root > /api/items
api > /api/items
handler list_items (GET)
api < 200 OK
root < 200 OK
pathless < 200 OK
GET /nope(404)只有 pathless 两行,root 那个 layer 虽然路径是 /,也没有跑:它是「有路径」的 layer,只包匹配上的路由。想让一个 layer 也看到 404,要么实现 Layer 时 path 返回 None,要么用 LayerFn::new(None::<&str>, ...),#[layer] 宏写不出没有路径的 layer。
layout 的嵌套
layout 和 layer 用同一条前缀规则,只是作用在视图上。模块路由的实验里,根模块有一个出 <html> 外壳的 layout,_marketing 组里有一个加 <div class="marketing"> 的 layout,_marketing::pricing 有一个 page:
/pricing 200 | <div class="marketing"><h1>pricing</h1></div>
/about 200 | <h1>about</h1>
(输出里去掉了根 layout 产生的 <!DOCTYPE html><html><body> 和 </body></html>。)/pricing 的注册路径是 /(_marketing)/pricing,两个 layout 的路径 / 和 /(_marketing) 都是它的前缀,按长度从外到里套;/about 只有根 layout。组段不出现在 URL 里,但参与这里的前缀匹配:这正是「同一层 URL 下用不同外壳」的办法。
尾斜杠
TrailingSlash 有三个值:Redirect(默认)、Serve、Strict。topcoat 认为 /users 和 /users/ 是两个不同的路径(尾斜杠是一个空的静态段),这个设置决定「只注册了其中一种时,另一种怎么办」。
建表的最后一步 register_twins 给每个端点算出「另一种写法」:Serve 就复制一份方法表,两种 URL 跑同一个路由;Redirect 就注册一个 RedirectRoute,它的 layer 栈是 always_layers。根路径、catch-all 结尾的路径、两种写法都已经注册了的,都跳过。
### http://127.0.0.1:38102/users/?x=1
HTTP/1.1 308 Permanent Redirect
location: /users?x=1
content-length: 0
pathless > GET /users/?x=1
pathless < Err(redirect)
308 而不是 301:308 要求客户端保持方法和请求体,POST /users/ 被重定向后仍是 POST。查询串原样带上。服务器端只有没有路径的 layer 跑了,路由自己的 layer 栈不参与。
BodyLimit 也是一个 layer
请求体大小限制没有放在服务器层面,而是一个普通的 layer。BodyLimit 的 handle 只做一件事:把限制值放进一个子上下文,再 next.run。
fn handle<'a>(&'a self, cx: &'a Cx, body: Body, next: Next<'a>) -> LayerFuture<'a> {
let cx = cx.with(self.kind);
Box::pin(async move { next.run(&cx, body).await })
}
真正检查限制的是读请求体的提取器:Bytes 的 FromRequest 调 to_bytes(body, body_limit(cx)),String、Json、Form 都先读成 Bytes。body_limit 从上下文里取值,取不到就是默认的 2 MiB。所以:
BodyLimit::max(n)不带.at()时路径是/,包住所有匹配上的路由;.at("/upload")只管/upload下的。路径更长的 layer 在里面,后放进上下文的值覆盖外面的,所以窄范围的限制总是赢。- handler 直接拿
body: Body自己读流,限制不生效;文档里写着「reading the body by hand bypasses that limit」。
实测(.layer(BodyLimit::max(16).at("/upload")),handler 参数是 body: String):
### -X POST --data hello http://127.0.0.1:38102/upload
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 12
got 5 bytes
### -X POST --data hello hello hello hello http://127.0.0.1:38102/upload
HTTP/1.1 413 Payload Too Large
content-type: text/plain; charset=utf-8
content-length: 17
content too large
和 axum 的 DefaultBodyLimit 比:axum 也是默认 2 MiB、也由提取器检查,但它把限制作为一个值放进请求的 extensions;topcoat 放进 Cx。
StripPrefixLayer 是另一个内置的 layer:把请求 URI 去掉一个前缀,放进子上下文的 Parts 里再往下传。它主要配合 TowerRoute 挂外部服务用,在 tower 互操作与可信代理里演示。
建表时的 panic
这些检查都在 build 里同步完成,启动时就 panic,不会等到第一个请求。一个小程序分别触发:
"dup" => Router::builder().route(a1).route(a2).build(),
"layer" => Router::builder().route(a1).layer(BodyLimit::max(1).at("/nothing")).build(),
"conflict" => Router::builder().route(by_id).route(by_name).build(),
a1、a2 都是 #[route(GET "/a")];by_id、by_name 分别是 /users/{id} 和 /users/{name}。
## dup
thread 'main' (2145477) panicked at src/bin/buildpanic.rs:19:52:
duplicate route registered for `GET /a`
## layer
thread 'main' (2145484) panicked at src/bin/buildpanic.rs:20:84:
layer with path `/nothing` did not match any route, this is likely a mistake
## conflict
thread 'main' (2145491) panicked at .../topcoat-router-0.10.0/src/builder.rs:441:31:
failed to register route: conflicts with registered route `/users/{id}`
- 前两个的位置指向调用
build()的那一行,因为build标了#[track_caller]。 - 第三个是 matchit 报的冲突(axum 卷的 matchit里讲过哪些路径算冲突)。它的 panic 发生在
or_insert_with_key的闭包里,#[track_caller]穿不过闭包,所以位置是 topcoat 自己的builder.rs:441。 - 第四种是
.discover()收集到的两个 layout 或两个 layer 落在同一路径:「multiple discovered layouts registered for the same path」,下一页讲。
小结
RouterBuilder收集 route、page、layout、layer 四种 trait object;build把 page 和前缀匹配的 layout 合成PageWithLayouts,之后一切都是Route。Endpoints是 matchit 路由树加方法表(九格数组 + 扩展方法 + any),Routes存每个路由和它的 layer 栈;组段不进 matchit 路径。Next是剩余 layer 切片加终点,404、405 也是终点;每个 layer 一个装箱的 future,可以借用cx,不需要Clone。- 没有路径的 layer 包住所有请求(含 404、405、尾斜杠重定向),有路径的按注册路径逐段前缀匹配,短的在外;同路径后注册的在外。
#[layer]宏写不出没有路径的 layer。 - 尾斜杠默认 308 重定向并保留查询串;
BodyLimit是把限制放进Cx的 layer,由读 body 的提取器检查,默认 2 MiB。 - 重复路由、没用上的 layer、matchit 冲突、发现到的同路径 layout / layer,都在
build时 panic。