模块路由与发现
主线位置:发现主线全程。从编译期
#[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声明的文件静默缺席。