Rust in Everything
基于 dioxus 0.7.10

本站怎么起服务

主线位置:首屏主线和调用主线的入口。一次页面请求或一次 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静态文件
1security_headers_mw加头加头加头
2CompressionLayer按 Accept-Encoding 压缩同左同左
3trailing_slash_mw尾斜杠 308不管(只看 GET / HEAD)尾斜杠 308
4rate_limit_mw不管(只看 /api/*)每次都扣令牌不管
5body_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' 不带长度。结果见文末动脑筋答案。

❗IMPORTANT
动脑筋:分块发送的 3 MB 会得到什么?

/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 完成之后才读(一次请求的渲染的「状态码和响应头:在首块之前定下」)。

最小改动清单

只列结论,改不改由站长决定。

  1. 加 catch-all 404。 Route 末尾加 #[route("/:..segments")] PageNotFound { segments: Vec<String> },组件里放 widgets::NotFound。效果见上面的实验:仍是 404,正文是站点页面而不是路由匹配日志。本站自己挂的 /js、/courses、/sitemap.xml 等是 axum 路由,优先于 fallback,不受影响。
  2. 分块请求仍会 panic 成 500。 不带 Content-Length 的大 body 绕过了 body_limit_mw 的头检查,走到 dioxus 的 unwrap(见动脑筋答案)。结果是这一个请求 500、服务端多一条 panic 日志,不影响别的请求。分块请求通常来自直接构造请求的客户端(本页没有在浏览器里核对 dioxus 客户端发出的请求头)。要消掉它,可以在 body_limit_mw 里对 /api/ 下没有 Content-Length 的 POST 直接回 411 或 413。本站网关(crates/gateway)只转发,不读也不改请求体,分块请求会原样到达应用(这是读源码得出的,没有对线上发这种请求)。
  3. 开发时热补丁会重跑闭包。 两个 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,dioxus unwrap 它,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、换成站点页面。

💬 关联讨论

发起讨论
加载中...