Rust in Everything
基于 topcoat-router 0.10.0

模块路由与发现

主线位置:发现主线全程。从编译期 #[page] 展开,到链接期 inventory 收集,到启动时 module_router!() 把模块路径变成 URL、交给上一页的 RouterBuilder。

sequenceDiagram
  participant M as 宏展开(编译期)
  participant K as 链接器与加载器
  participant B as ModuleRouterBuilder(启动时)
  participant R as RouterBuilder
  M->>M: page 变成单元结构体,实现 ModulePage
  M->>M: module_path 展开成字面量
  M->>K: inventory::submit:一个静态节点 + 构造函数
  K->>K: main 之前跑构造函数,节点串成链表
  B->>K: inventory::iter:取出所有 ModulePage
  B->>B: relative_module_path,按 :: 切段
  B->>B: 组、静态段、参数段、catch-all
  B->>R: page(ResolvedPage),带上算好的 Path
  R->>R: build:matchit 路由表

用法:模块树就是路由树

先看效果。实验程序的模块树(每个文件都在上一级用 mod 声明):

src/bin/modr/
  main.rs              #[layout] root_layout、#[page] home
  about.rs             #[page] page
  blog_posts.rs        #[page] index
  news.rs              segment!(rename = "articles");#[page] index
  _marketing.rs        #[layout] marketing
  _marketing/
    pricing.rs         #[page] pricing
  posts.rs             #[page] posts
  posts/
    post_id.rs         module_param!(pub post_id: u64, error = bad_request);#[page] post
  docs.rs              (只有 mod doc_path;)
  docs/
    doc_path.rs        module_param!(pub *doc_path);#[page] doc
  api.rs               #[layer] api_log
  api/
    health.rs          #[route(GET)] health
  settings.rs          #[page] settings;#[page(POST "./export")] export

main 里只有一行 topcoat::start(module_router!().build())。逐个请求(第三列是响应体,去掉了根 layout 的外壳):

/                      200  | <ul><li>/about</li><li>/blog-posts</li><li>/articles</li><li>/pricing</li><li>/posts/42</li><li>/docs/guide/a%20b</li><li>/settings/export</li></ul>
/about                 200  | <h1>about</h1>
/blog-posts            200  | <h1>blog posts</h1>
/articles              200  | <h1>news, served as articles</h1>
/news                  404  | not found
/pricing               200  | <div class="marketing"><h1>pricing</h1></div>
/_marketing/pricing    404  | not found
/marketing/pricing     404  | not found
/posts                 200  | <h1>posts</h1>
/posts/42              200  | <h1>post 42 via /posts/{post_id}</h1>
/posts/abc             400  | bad request: invalid value for path parameter "post_id"
/docs/guide/a%20b      200  | <p>segments: ["guide", "a b"]</p>
/docs                  404  | not found
/api/health            200  | ok
/settings              200  | <h1>settings</h1>
/settings/export       405  |
/about/                308 http://127.0.0.1:38107/about |
POST /settings/export  <p>export started</p> 200

首页那一行是 href! 生成的链接,每个都对上了实际服务的 URL:

<li>(href!(about::page))</li>
<li>(href!(blog_posts::index))</li>
<li>(href!(news::index))</li>
<li>(href!(_marketing::pricing::pricing))</li>
<li>(href!(posts::post_id::post, posts::post_id::PostId(42)))</li>
<li>(href!(docs::doc_path::doc, docs::doc_path::DocPath(["guide", "a b"])))</li>
<li>(href!(settings::export))</li>

对照着看规则:

模块怎么变成 URL 段结果
about、blog_posts普通模块名是静态段,转成 kebab-case/about、/blog-posts
news + segment!(rename = "articles")改名,按原样使用,不再转 kebab-case/articles;/news 不存在
_marketing_ 开头是组:参与 layout 的前缀匹配,不出现在 URL 里/pricing 套着 marketing 的 layout
post_id + module_param!(post_id: u64)参数段,名字取自声明而不是文件名/posts/{post_id}
doc_path + module_param!(*doc_path)catch-all,至少匹配一段/docs/{*doc_path};/docs 本身 404
settings.rs 里的 #[page(POST "./export")]./ 开头的路径接在模块路径后面POST /settings/export;同路径的 GET 是 405
api 里的 #[layer]layer 的路径就是模块路径只包 /api 下的路由
函数名不参与同一个模块里的 handler 共享一个路径,按方法区分

一个 #[page] 展开成什么

用 cargo expand 展开一个最小程序(crate 名 expandme):

#[page]
async fn home() -> Result<impl View> {
  Ok(view! { <h1>"hi"</h1> })
}

展开结果有一百多行,按顺序是四块。

一、函数名变成一个单元结构体,组件本体搬进 Component::render 里:

#[allow(non_camel_case_types)]
struct home;
impl ::topcoat::view::Component for home {
    type Props<'__props> = HomeProps;
    fn render<'__a, '__props>(
        self,
        cx: &'__a ::topcoat::context::Cx,
        props: Self::Props<'__props>,
    ) -> impl Future<Output = Result<impl View>> + ::core::marker::Send + '__a
    // ...

这是 #[page] 先套了一层 #[component] 的结果(page.rs 的 ToTokens)。home 从此是一个类型,所以 href!(about::page) 里写的是一个类型路径,编译器会检查它存在、可见。

二、ModulePage 的实现,放在一个 const _: () = { ... }; 里:

const _: () = {
    static ID: ::std::sync::LazyLock<::topcoat::router::RouteId> = ::std::sync::LazyLock::new(
        ::topcoat::router::RouteId::new,
    );
    impl ::topcoat::router::ModulePage for home {
        fn id(&self) -> ::topcoat::router::RouteId {
            *ID
        }
        fn methods(&self) -> ::topcoat::router::Methods<'_> {
            const METHODS: ::topcoat::router::Methods<'static> = ::topcoat::router::Methods::Only(
                &[::topcoat::router::Method::GET],
            );
            METHODS
        }
        fn module_path(&self) -> &'static str {
            "expandme"
        }
        fn render<'a>(
            &'a self,
            cx: &'a ::topcoat::context::Cx,
            body: ::topcoat::router::Body,
        ) -> ::topcoat::view::BoxView<'a> {
            // ...
        }
    }
    // ...

module_path() 的返回值在展开时是 ::core::module_path!(),cargo expand 把它也展开成了字面量 "expandme":这就是「URL 由模块位置决定」的全部来源。ID 是一个进程内自增的编号,第一次用到时分配,用来在请求时反查这个 handler 注册在哪个端点上。没写方法时默认 GET;如果属性里写了以 / 开头的绝对路径,生成的就是 Page 而不是 ModulePage,module_router! 不会收它。

三、HrefTarget 的实现:

impl ::topcoat::router::HrefTarget for home {
    fn path<'cx>(
        &self,
        cx: &'cx ::topcoat::context::Cx,
    ) -> &'cx ::topcoat::router::Path {
        match ::topcoat::router::route_endpoint(cx, *ID) {
            ::core::option::Option::Some(endpoint) => endpoint.path(),
            ::core::option::Option::None => {
                ::core::panicking::panic_fmt(
                    format_args!(
                        "page `home` is not registered on the router serving this request",
                    ),
                );
            }
        }
    }
}

href! 不是在编译期算出 URL 的,而是在请求时拿 ID 去当前路由器的 Routes 里查它的端点路径。

四、inventory 登记:

#[allow(non_upper_case_globals)]
const _: () = {
    static __INVENTORY: ::inventory::Node = ::inventory::Node {
        value: &{ &home as &'static dyn ::topcoat::router::ModulePage },
        next: ::inventory::__private::UnsafeCell::new(
            ::inventory::__private::Option::None,
        ),
    };
    unsafe extern "C" fn __ctor() {
        unsafe { ::inventory::ErasedNode::submit(__INVENTORY.value, &__INVENTORY) }
    }
    #[used]
    #[link_section = "__DATA,__mod_init_func,mod_init_funcs"]
    static __CTOR: unsafe extern "C" fn() = __ctor;
};

每个登记项是一个静态链表节点,加一个放进特殊段的函数指针。macOS 上这个段是 __DATA,__mod_init_func(Linux 上是 .init_array),动态加载器在 main 之前逐个调用这些函数,把节点挂到 dyn ModulePage 这个类型的全局链表上。整个过程没有 build.rs、不读文件系统,代价是「这个 handler 存在」只能到运行时才知道。源码地图里说的「工作区禁止 unsafe」管不到这里:这段 unsafe 是 inventory 的宏在你的 crate 里生成的。

module_router! 在启动时做什么

宏本身只有一行:

macro_rules! module_router {
    () => {
        ::topcoat::router::RouterBuilder::from(
            ::topcoat::router::ModuleRouterBuilder::new(module_path!()).discover(),
        )
    };
}

关键在 module_path!():它在调用处展开,所以写在哪个模块里,哪个模块就是 /。discover 按固定顺序把五种登记项都取出来:segment! 的覆盖、page、layout、route、layer。segment 必须先注册,因为它影响后面所有路径的计算;手工调 ModuleRouterBuilder::segment 时如果已经注册过 handler,会 panic「segment must be called before registering any resource」。

每个 handler 的路径由 module_path_to_path 算出:

let mut prefix_end = 0;
for (i, component) in relative.split("::").enumerate() {
    // ...
    let segment = self.segments.get(&relative[..prefix_end]);

    let kind = match segment.and_then(|segment| segment.kind()) {
        Some(kind) => *kind,
        None => {
            if component.starts_with('_') {
                SegmentKind::Group
            } else {
                SegmentKind::Static
            }
        }
    };
    let name = match segment.and_then(|segment| segment.rename()) {
        Some(rename) => Cow::Borrowed(rename),
        None => match kind {
            SegmentKind::Static => Cow::Owned(component.to_kebab_case()),
            _ => Cow::Borrowed(component),
        },
    };
    // ...
    path_buf += path_segment;
}

先用 relative_module_path 去掉根模块前缀,再按 :: 切开,每一级模块查一次有没有 segment! 覆盖:没有覆盖时 _ 开头是组、其余是静态段(heck 转 kebab-case);覆盖可以改种类、改名。组段在 Path 里写成 (name),所以 _marketing::pricing 的路径是 /(_marketing)/pricing,to_matchit_path 去掉组段后才是 matchit 里的 /pricing。

然后 resolve_path 接上属性里的相对路径:#[page(POST "./export")] 在宏展开时去掉开头的 .,变成 /export 接在模块路径后面;单独一个 "./" 表示「模块路径加尾斜杠」,在根模块里用会 panic(根没有尾斜杠形式);".." 开头的在宏展开时就报编译错误「a relative path cannot leave the module path」。

最后每个 handler 包成 ResolvedPage(实现了普通的 Page,path() 返回算好的路径),交给 RouterBuilder。module_router!() 返回的就是这个 RouterBuilder,后面接 .layer(...)、.assets(...)、.build() 都和手工建路由一样。

segment! 和 module_param!

segment!(kind = ..., rename = ...) 展开成一次 inventory::submit!,登记一个 Segment { module_path, kind, rename },module_path 同样来自 module_path!()。它改的是所在模块那一段。

module_param! 是 path_param! 加 segment! 的组合,grammar 里就两行:

quote! { #topcoat_router_macro::segment!(kind = #kind, rename = #name); }

path_param!(post_id: u64, error = bad_request) 那一半生成三样东西:一个新类型 PostId(名字是参数名转 PascalCase);一个 PathParam 实现,path_param::<PostId>(cx) 时从匹配到的原始参数里取 post_id 那一段,用 FromStr 解析,结果用 #[memoize] 缓存在这次请求里;一个 HrefParam 实现,让 href! 能用 PostId(42) 填参数。error = bad_request 把解析失败映射成 400,实测响应体是「bad request: invalid value for path parameter "post_id"」。不写 error,path_param 返回的是 Result<&u64, &ParseIntError>,自己处理。

catch-all 不带类型时读出来是 CatchAllSegments,按段解码:/docs/guide/a%20b 得到 ["guide", "a b"];%2F 编码的斜杠留在段内,不会被当成分隔符。

href!:名字是编译期的,路径是运行期的

href!(handler, 参数...) 分两步:编译期,handler 必须是一个实现了 HrefTarget 的类型(也就是被 #[page] / #[route] 标过的函数),名字写错就编译不过;运行期,HrefTarget::path 去当前路由器里查出注册路径,再把参数按顺序填进去,每个值用 Display 写出并百分号编码。

参数的个数、顺序、名字是运行期检查的。给 posts::post_id::post 少填一个参数:

#[page("./bad-href")]
async fn bad_href() -> Result<impl View> {
  Ok(view! { <a href=(href!(posts::post_id::post))>"post"</a> })
}

能编译,请求时 panic,客户端拿到 500:

thread 'tokio-rt-worker' (2257496) panicked at .../topcoat-router-0.10.0/src/href.rs:308:17:
no value provided for path parameter "post_id" in `/posts/{post_id}`

这和 Dioxus 的 #[derive(Routable)] 枚举(dioxus 卷的 Routable)不同:那里链接目标是一个枚举值 Route::Post { id },字段缺了编译不过;topcoat 的 href! 能保证「链到的是一个真实存在的 handler」,但参数要靠运行期断言。好处是重构时改模块位置,链接自动跟着变。

.discover():模块路由收不到的东西

module_router!() 只收「模块派生」的 handler,也就是没写路径或写了 ./ 相对路径的。另外几类要靠门面 crate 的 RouterBuilderDiscoverExt::discover:

fn discover(mut self) -> Self {
    self = self.discover_routes();
    self = self.discover_pages();
    self = self.discover_layouts();
    self = self.discover_layers();
    #[cfg(feature = "font")]
    {
        use topcoat_font::RouterBuilderFontExt;
        self = self.discover_fonts();
    }
    self
}

它收的是另一组 inventory 列表:写了绝对路径的 Page、Route、Layout、Layer。05 章的 #[shard] 和 #[procedure] 展开后登记的也是 dyn Route(.runtime() 只加一个 layer 和一个配置值,不注册它们),所以只用 module_router!().build() 的应用,shard 和 procedure 的端点不会被注册,要写成 module_router!().discover().build()。

0.10.0:根之外的 handler 让启动 panic

ModuleRouterBuilder::discover_pages 遍历的是整个二进制里所有的 dyn ModulePage,不只是根模块之下的。遇到不在根之下的,relative_module_path 里的 expect 直接 panic。复现:module_router!() 写在 mod app 里,crate 根还留着一个 #[page]:

// main.rs
mod app;

#[tokio::main]
async fn main() {
  topcoat::start(app::router()).await.unwrap();
}

#[page]
async fn stray() -> Result<impl View> {
  Ok(view! { "outside the root" })
}
// app.rs
pub fn router() -> Router {
  module_router!().build()
}
thread 'main' (2135106) panicked at .../topcoat-router-0.10.0/src/module/router.rs:84:29:
module path must be under module router's root

位置指向 module_path_to_path 里调用 relative_module_path 的那一行(后者标了 #[track_caller])。同理,如果你依赖的某个库里用了 #[page],或者一个二进制里想用两个 module_router!() 分别管两棵树,0.10.0 都会在启动时 panic。模块路由的文档写的是「Every module-derived ... under the module containing module_router!() is registered」,代码的实际行为比文档宽。tag 之后的 #518 给每个 discover_* 加了一个过滤,只收根之下的,「so routers rooted in different crates or modules can live in one binary」。

反方向的问题在 0.10.0 和之后都一样:一个文件没有在上一级 mod 声明,它就不会被编译,inventory 里没有它,对应的 URL 静默 404,没有任何提示。

小结

  • #[page] 把函数变成同名单元结构体,实现 Component、ModulePage(module_path() 就是 module_path!())、HrefTarget,再用 inventory 把 &home as &dyn ModulePage 挂到全局链表上;挂链表的函数在 main 之前由加载器调用。
  • module_router!() 在调用处取 module_path!() 作根,按 segment、page、layout、route、layer 的顺序取出登记项;每一级模块默认是静态段(kebab-case),_ 开头是组,segment! 和 module_param! 可以覆盖成改名、参数或 catch-all。
  • ./ 相对路径接在模块路径后面;绝对路径的 handler、shard、procedure 要 .discover() 才注册。
  • href! 的目标在编译期检查,路径和参数在运行期查表、断言,少填参数是请求时 panic(500)。
  • 0.10.0 遍历整个二进制的登记项,根之外有模块派生的 handler 就在启动时 panic;#518 之后改为只收根之下的。没有 mod 声明的文件静默缺席。

💬 关联讨论

发起讨论
加载中...