Rust in Everything
基于 topcoat-router 0.10.0

路由表与 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 包住哪些路由

layers_for_path:

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()
}

规则有三条:

  1. 没有路径的 layer 包住所有请求,排在最外面;建表时还另存一份到 always_layers,404、405 和尾斜杠重定向只经过它们。
  2. 有路径的 layer 只包注册路径以它开头的路由,路径越短越靠外。比较的是注册时的路径,逐段比,不看请求 URL:参数段只和同名参数段相等,组段也算数。#[layer] 的文档举了两个例子:/docs/admin 的 layer 不包 /docs/{*path} 的 page(尽管后者也服务 /docs/admin/... 这些 URL);/dashboard 的 layer 不包 /(auth)/dashboard 的 page(尽管 URL 都是 /dashboard)。
  3. 同一路径的多个 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。

💬 关联讨论

发起讨论
加载中...