本站怎么起服务
主线位置:首屏主线和调用主线的入口。一次页面请求或一次 server function 调用,在落到 dioxus 的 SSR fallback 或 server function 路由之前,先穿过本站在
main.rs里加的五层中间件;本页看这几层各管哪一段,dioxus 自己那一段见 一次请求的渲染。
flowchart TB req["请求"] --> sec["security_headers_mw"] sec --> comp["CompressionLayer"] comp --> slash["trailing_slash_mw"] slash --> rl["rate_limit_mw"] rl --> bl["body_limit_mw"] bl -->|"声明长度超限"| r413["413"] bl --> table["dioxus 的路由表"] table --> sf["76 个 server fn"] table --> own["本站自己的路由"] table --> pubf["public 文件"] table --> ssr["SSR fallback"]
本页的站点源码链接都指向本站公开仓库的提交 89a61b1(2026-10-23)。写作时本站仓库已经到了 eefd161,多出来的 body_limit_mw 那一层只在后面的提交里,下文描述它、不链接它。本站锁的是 dioxus 0.7.9,和本卷的 0.7.10 只差 CLI(怎么读这一卷)。
这一章不重复别的卷已经讲过的部分:dioxus::server::router(App) 造出的 Router 长什么样、本站的四个 mount 函数加了哪些路由,见 axum 卷的 dioxus 生成的 Router 和 路由与静态资源;中间件为什么是这个顺序、限流为什么手写,见 tower 卷的 本站的中间件栈;日志 subscriber 实际是谁装的,见 tracing 卷的 subscriber 的初始化;连接上没有读头超时、没有优雅停机,见 hyper 卷的 本站的服务端路径。
main 的两条分支
main 编两次(同一份代码编两次):带 server feature 的服务端二进制走 dioxus::serve,wasm 客户端走 dioxus::launch。
fn main() {
app_core::engines::module::register_compiled_modules(compiled_modules());
// Phase 2.1 / 2.2: 启动期一次性注册 MDX 嵌入组件。
// 在 server 和 client 两边都调用,用于 SSR + hydration 双边 registry 一致。
// 1) widgets 内置 9 个默认组件 (YouTube / Bilibili / 5 色 / Underline / Strikethrough)
widgets::register_default_components();
// 2) 各业务模块注册自身提供的组件 (PodcastCard ...)
module_podcast::register_components();
// ...
#[cfg(feature = "server")]
dioxus::serve(|| async move {
// ... 读 .env、日志、启动检查、连库与迁移、两个后台任务
let router = dioxus::server::router(App)
.route("/healthz", get(crate::server::health::healthz));
// ... 四个 mount、几次 layer
Ok(router)
});
// Client: standard launch
#[cfg(not(feature = "server"))]
dioxus::launch(App);
}
分支之前的三行两边都跑。Markdown 里嵌的组件(YouTube、PodcastCard……)是按名字查一张注册表渲染的,服务端渲染时查到什么、客户端水合时也要查到同样的东西,否则两边建出来的树不一样,水合按位置取值就会错位(水合的「两边顺序不一致会怎样」)。
dioxus::serve 把闭包交给 serve_router:先 dioxus_logger::initialize_default()、绑端口,然后 release 构建只调一次闭包,axum::serve(listener, router);debug 构建自己写 accept 循环,收到 dx 发来的带跳转表的热补丁消息时,整个闭包再跑一遍,换掉路由表(一次请求的渲染的「三个入口」)。本站的闭包里不只有装路由:读 .env、连库、跑迁移、tokio::spawn 搜索索引和支付对账两个定时任务都在里面。生产是 release,只跑一次;开发时每打一次热补丁,这些事都重来一遍,后台任务会多起一份。闭包里的 ASSETS_ROOT 用 OnceLock 防的就是「重跑时重复泄漏」,后台任务还没有同样的保护。这只影响开发。
闭包返回的 Router 用的是 dioxus::server::router(App),它固定 ServeConfig::new():没有增量缓存,流式模式是默认的 StreamingMode::Disabled(增量渲染与配置)。本站代码里没有出现 ServeConfig、StreamingMode 或 incremental。这决定了两件事:每个页面请求都完整地新建一个 VirtualDom 渲染一遍;所有 suspense 都等完了才发第一个字节,组件里设置的状态码一定来得及生效(下一页的 NotFound 就靠这一点)。
五层各挡在哪一段
eefd161 的 main.rs 在路由装好之后依次 layer 了五次,后加的在外层(顺序的理由见 本站的中间件栈,那一页写于 89a61b1,当时还是四层)。从 dioxus 这一侧看,请求分三类:页面 GET 落进 SSR fallback;server function 是 POST /api/...;public/ 下的 wasm、JS 和本站自己的静态目录是普通文件。
| 由外到内 | 层 | 页面 GET(SSR) | server fn POST | 静态文件 |
|---|---|---|---|---|
| 1 | security_headers_mw | 加头 | 加头 | 加头 |
| 2 | CompressionLayer | 按 Accept-Encoding 压缩 | 同左 | 同左 |
| 3 | trailing_slash_mw | 尾斜杠 308 | 不管(只看 GET / HEAD) | 尾斜杠 308 |
| 4 | rate_limit_mw | 不管(只看 /api/*) | 每次都扣令牌 | 不管 |
| 5 | body_limit_mw(eefd161 新加) | 没有 body | 声明长度超限回 413 | 没有 body |
server function 和这几层的关系有一处容易想错。页面里的 use_server_future 在服务端渲染时直接调用函数体,不走 HTTP(server function),所以首屏取数据不经过 rate_limit_mw,也不会被 body_limit_mw 看见;只有浏览器发来的 POST /api/... 才经过这五层。水合以后由 use_resource 发出的调用都属于后一种,下一页按代码数过,包在 ModuleGate 里的页面水合后至少发 7 个这样的请求。
请求体:413 还差分块请求
dioxus-fullstack 0.7.10 读 server function 的 body 用的是 Bytes::from_request(request, &()).await.unwrap(),超过 axum 默认的 2 MiB 就 panic、回 500,而不是 413(机制见 bytes 卷的 server 函数的请求体,坑见 server-fn-body-limit)。eefd161 的 body_limit_mw 做两件事:/api/upload 用 DefaultBodyLimit::max(8 MiB) 包住下一层;请求头里 Content-Length 超过这条路径的上限(上传 8 MiB,其余 2 MiB)时直接回 413,请求到不了 dioxus 的 unwrap。
bytes 卷用一个模仿 dioxus 的 axum handler 验证过这个修法。这里换成真的 dioxus:实验 crate 依赖 dioxus = "=0.7.10"(router、fullstack),照本站的形状写 dioxus::serve(|| async move { Ok(dioxus::server::router(App).layer(from_fn(body_limit_mw))) }),body_limit_mw 从本站逐字拷来,两个参数相同的 server function:
#[post("/api/upload")]
async fn upload_image(name: String, data_base64: String) -> Result<String, ServerFnError> {
Ok(format!("{name}: {} base64 chars", data_base64.len()))
}
// any other server fn: same arguments, default 2 MiB limit
#[post("/api/comment")]
async fn comment(name: String, data_base64: String) -> Result<String, ServerFnError> {
Ok(format!("{name}: {} base64 chars", data_base64.len()))
}
dx build --platform web --fullstack --release 之后启动 ./server,用 curl 发 {"name":"a.png","data_base64":"AAAA…"},一次带 Content-Length(curl 默认),一次加 -H 'Transfer-Encoding: chunked' 不带长度。结果见文末动脑筋答案。
/api/comment 的上限是 2 MiB。带 Content-Length: 3000033 发过去,body_limit_mw 读头就回 413。同样 3 MB 的 JSON 用分块编码发、请求头里没有长度呢?/api/upload 上分块发 9 MB 呢?
未知路径:404 的正文是整张路由表
dioxus 生成的 Router 里,页面都不是 axum 路由,而是同一个 SSR fallback;URL 对不上 Route 枚举的任何一项时,dioxus-router 抛 ParseRouteError,渲染管线在根错误边界里看到它,就映射成 404,正文是 routing_error.to_string();render_handler 在 SSRError::HttpError 这一支把它原样写进响应体,没有 HTML,也不区分 debug 和 release(Router 运行时)。本站的 Route 没有 catch-all 项,于是对本地 dx serve 起的站点(eefd161,debug)发 GET /nope:
HTTP/1.1 404 Not Found
content-length: 1708
...(安全头)
Failed to parse route Route did not match:
Attempted Matches:
1) Route 'Home' ('/') did not match:
Found additional trailing segments: nope
2) Route 'EcosystemPage' ('/ecosystem/:id') did not match:
Static segment 'ecosystem' did not match instead found 'nope'
...
15) Route 'MyAnnotations' ('/me/annotations') did not match:
Static segment 'me' did not match instead found 'nope'
16) Route 'AdminDashboard' ('/admin') did not match:
Static segment 'admin' did not match instead found 'nope'
状态码是对的,但访客看到的是一段纯文本的匹配日志,里面列着 /admin、/me/annotations 这些路径。上面的实验 crate(release)对 GET /nope 给出同样格式的正文,所以这不是开发构建才有的。POST /nope 是 405、allow: GET,HEAD,这一点 axum 卷已经讲过。
本站已经有现成的零件:widgets 里的 NotFound 渲染一段提示,同时在服务端 FullstackContext::commit_http_status(StatusCode::NOT_FOUND, None)。给 Route 末尾加一个 #[route("/:..segments")] 的项、组件里用 NotFound,就能得到带站点外壳的 404。加 catch-all 的风险是状态码变成 200(route-not-found-status),commit_http_status 正好补上这一点。实验 crate 加一个 feature 打开这样一项(组件体和 NotFound 相同)重新构建,release:
GET / 200 text/html; charset=utf-8 985
GET /blog/x 200 text/html; charset=utf-8 1026
GET /nope 404 text/html; charset=utf-8 1006
GET /a/b/c 404 text/html; charset=utf-8 1010
/nope 的正文变成整页 HTML,里面是 <h1 data-node-hydration="0">页面不存在</h1>。能这样做,靠的是上一节说的 StreamingMode::Disabled:状态码在所有 suspense 完成之后才读(一次请求的渲染的「状态码和响应头:在首块之前定下」)。
最小改动清单
只列结论,改不改由站长决定。
- 加 catch-all 404。
Route末尾加#[route("/:..segments")] PageNotFound { segments: Vec<String> },组件里放widgets::NotFound。效果见上面的实验:仍是 404,正文是站点页面而不是路由匹配日志。本站自己挂的/js、/courses、/sitemap.xml等是 axum 路由,优先于 fallback,不受影响。 - 分块请求仍会 panic 成 500。 不带
Content-Length的大 body 绕过了body_limit_mw的头检查,走到 dioxus 的unwrap(见动脑筋答案)。结果是这一个请求 500、服务端多一条 panic 日志,不影响别的请求。分块请求通常来自直接构造请求的客户端(本页没有在浏览器里核对 dioxus 客户端发出的请求头)。要消掉它,可以在body_limit_mw里对/api/下没有Content-Length的POST直接回 411 或 413。本站网关(crates/gateway)只转发,不读也不改请求体,分块请求会原样到达应用(这是读源码得出的,没有对线上发这种请求)。 - 开发时热补丁会重跑闭包。 两个
tokio::spawn的后台任务每次热补丁多起一份。可以像ASSETS_ROOT那样用一个OnceLock包住;只影响dx serve,不急。
动脑筋答案
实验 crate site-fs(dioxus =0.7.10,--release),M2 Max、macOS 27、rustc 1.99.0。每行是路径、发送方式、JSON 字节数、状态码、响应体前 80 字节:
/api/comment len json=1000033 200 "a.png: 1000000 base64 chars"
/api/comment len json=3000033 413 请求体过大
/api/upload len json=7000033 200 "a.png: 7000000 base64 chars"
/api/upload len json=9000033 413 请求体过大
/api/comment chunked json=1000033 200 "a.png: 1000000 base64 chars"
/api/comment chunked json=3000033 500 Internal Server Error
/api/upload chunked json=7000033 200 "a.png: 7000000 base64 chars"
/api/upload chunked json=9000033 500 Internal Server Error
两个分块请求在服务端日志里各留下一条 panic:
thread '<unnamed>' (2148669) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/dioxus-fullstack-0.7.10/src/magic.rs:542:69:
called `Result::unwrap()` on an `Err` value: FailedToBufferBody(LengthLimitError(LengthLimitError(Error { inner: LengthLimitError })))
- 带长度的请求都按设计走:普通 server function 3 MB 是 413,上传 7 MB 收下,9 MB 是 413。
- 分块的 3 MB 和 9 MB 都是 500。头里没有长度,
body_limit_mw放行;DefaultBodyLimit(普通路径是默认 2 MiB,上传是中间件设的 8 MiB)在读 body 时才生效,读超了返回 rejection,dioxusunwrap它,panic 变成 500。 - 分块的 7 MB 上传是 200:8 MiB 的
DefaultBodyLimit对分块请求同样有效,修法的第一半(放宽上传)不依赖Content-Length。
小结
main分两支:服务端dioxus::serve包住dioxus::server::router(App),客户端dioxus::launch(App);组件注册表在分支前两边都建,水合才对得上。router(App)用默认ServeConfig:没有增量缓存,不流式,所有 suspense 等完才发首字节,组件里设的状态码总能生效。- 五层中间件里,限流只管
/api/*;首屏的use_server_future在服务端直接调函数体,不经过这几层,水合以后的use_resource调用才经过。 body_limit_mw让带长度的超限请求得到 413;不带长度的分块请求仍会让 dioxus panic 成 500。- 未知路径是 404,但正文是
ParseRouteError的全文,列出所有路由;加一个 catch-all 项并用已有的NotFound组件,就能保持 404、换成站点页面。