Rust in Everything
基于 topcoat 0.10.0

设计取舍

主线位置:三条主线的回顾。本页不引入新代码,每一条都链回讲它的那一章;实验数字也都出自那些章。
flowchart TB
  goal["目标:服务端渲染为主、零前端构建、开箱即用"] --> r["自建 router:hyper + matchit"]
  goal --> d["链接期发现:inventory + module_path"]
  goal --> e["$(...):一份 Rust,两种执行"]
  goal --> s["安全默认值偏严"]
  r --> rc["代价:axum 的提取器、Handler 不能直接用"]
  d --> dc["代价:路由表到启动时才确定"]
  e --> ec["代价:Rust 子集、已知不一致"]
  s --> sc["代价:子域、OAuth 回调要显式放行"]

自建 router,不建在 axum 上

换来的:一个请求从头到尾只有一个 Cx(Cx、AppContext 与 memoize);模块发现直接生成路由表(模块路由与发现);视图响应可以先发首屏、再把 suspense 的内容追加在同一个响应里,还能按 Accept 换成 NDJSON 帧(一个请求从监听到 HTML);rewrite、重定向、404 都是冒泡的 Error,流式开始后的重定向自动变成脚本跳转(请求、响应与错误)。这些在 axum 的模型里不是做不到,但 axum 的 Handler 拿到的是提取器的结果,没有贯穿整个请求的上下文;响应一旦交出去,handler 就管不到流式阶段。

付出的:axum 的提取器和中间件不能直接用。tower 中间件经 TowerLayer 套进来,axum 应用经 TowerRoute 挂进来,反过来 TowerService 把 topcoat 交给 axum 当 fallback(tower 互操作与可信代理);三条通道都能用,但每过一次都要转换 body 类型。服务器循环也是自己写的,axum 和 hyper-util 已经踩过的坑要再踩一次,见本页最后一节。

什么时候重估:项目里大部分代码是 axum 的 JSON API、只有少数页面要服务端渲染时,用 axum 加一个模板库更顺;topcoat 可以作为 TowerService 挂在 axum 的 fallback 上,渐进地接管页面。

Cx 类型表 + 「函数,而不是中间件」

换来的:鉴权、当前用户、数据库连接这些东西写成 fn(cx: &Cx) -> ...,在用到的地方调用,#[memoize] 保证一个请求里只算一次、并发调用共用一个 future。读代码时,一个 handler 用了哪些前提,全写在它自己的函数体里。

付出的:忘了调用就没有检查,编译器不提醒。这一点在 shard 和 procedure 上被放大:它们有自己的端点,页面和 layout 里的检查一个都不跑(常见坑)。app_context::<T>(cx) 取不到时 panic,配置漏了要到第一次请求才发现。

放在一起看三种做法:axum 常见的写法是把当前用户做成一个提取器(FromRequestParts 与 FromRequest),handler 签名里写了它才拿得到用户,没写就拿不到,等于「用到用户的地方一定检查过」,但不需要用户的写操作照样可以忘;Dioxus 的 server function 和 topcoat 的 shard 一样,鉴权只能写在函数体里(server-fn-public-endpoint);topcoat 的守卫函数和 axum 提取器在「检查跟着使用走」这一点上相同,差别是它不出现在签名里,读调用方看不出来。

什么时候重估:如果一棵子树的所有路由都必须登录,仍然可以写一个带路径的 #[layer],它包住路径以它开头的所有路由,包括显式路径挂在那下面的 shard;「函数」和「layer」不是二选一。

链接期发现,而不是 build.rs 或枚举

换来的:零构建步骤,mod 声明就是路由表;加一个页面就是加一个文件、在上一级 mod 一下。

付出的:路由表到启动时才拼出来,没有一处能一眼看全;文件没有 mod 声明就不编译,对应 URL 静默 404;0.10.0 还会因为根之外有一个 #[page] 在启动时 panic(常见坑)。对照 Dioxus 的 Routable 枚举:编译期穷尽、一张表看全站,代价是每加一页都要改枚举(topcoat 与 Dioxus fullstack)。

$(...) 双语言

换来的:不发 wasm、不要前端构建,浏览器只下载一份 49 KB(br 约 16 KB)的通用运行时;表达式在服务端求值一次,首屏就是对的,不需要水合数据。

付出的:

  • 只支持 Rust 的一个子集,不支持的写法编译期报 unsupported expression(例如对局部变量赋值)。
  • 两份实现靠 coherence 测试对拍,登记了 6 条已知不一致($(...) 表达式);本批实验又找到一条没登记的:块的最后一个表达式是 while 时,JS 一侧生成 return while (...),整个事件处理器编译失败(常见坑)。
  • 浏览器用 new Function 编译表达式,CSP 要放行 'unsafe-eval'(按源码,未实测)。
  • 错误只在浏览器控制台里,服务端照常编译、照常渲染。

页面重跑和 shard 走服务端

换来的:状态在服务端算,客户端很薄;服务端没有每连接的常驻状态,一次重跑就是一次普通的请求分发,横向扩容不需要粘性会话。

付出的:每次变化一个往返;signal 的值、shard 的参数都是浏览器送来的用户输入,上游文档在 signal、shard、procedure 三处用粗体写了要校验;类型不对的 signal 值会被悄悄换成初值,不报错(signal、shard 与 procedure)。

资产在二进制里打标记,运行时按目录加载

换来的:不需要 build.rs 列清单,没被用到的 asset! 不进包;同一个二进制可以配不同的清单或 CDN 前缀。

付出的:直接 cargo run 起不来,要先跑 bundler;部署时 assets/ 必须和二进制同一次构建;清单里缺了某个资产,到渲染那一页时才 panic(asset! 与 bundler、常见坑)。

安全默认值偏严

OriginPolicy 默认开、在所有 layer 之外;session cookie 强制 __Host-、Secure、HttpOnly、SameSite=Lax;流式开始后写 cookie 直接 panic 而不是悄悄丢掉(Origin 检查、cookie 与 session)。代价是一些正当的跨站请求要显式放行:子域之间的表单(same-site 也被拒)、OAuth / OIDC 的 form_post 回调(常见坑)。关掉它的方法叫 dangerous_disable,名字本身就是提醒。

偏严也有没覆盖到的地方:OriginPolicy 不保护改状态的 GET;htmx 片段和整页共用一个 URL 时,响应不带 Vary: HX-Request(htmx、Datastar 与 Alpine AJAX)。

0.10.0 的已知问题

问题在哪里讲tag 之后
accept 出错(例如 EMFILE)时 internal_serve 直接返回错误,整个服务器停止;axum 0.8.9 是记日志、睡一秒、继续从连接到分发到 2026-10-14 的 main 仍未改
连接级错误被静默丢弃,router 和 core 里没有一行日志同上源码 TODO
没给 hyper 配计时器,只发半个请求头的连接一直挂着同上#497 修了
module_router! 遍历整个二进制,根之外的模块派生 handler 让启动 panic模块路由与发现#518 修了
topcoat dev 只改资产文件时不重启,旧 URL 变 404tailwind、font、icon 与 topcoat CLI#524 修了
块末尾的 while 在 JS 一侧生成 return while,事件处理器编译失败常见坑tag 之后 expr/ 目录没有提交

生产环境用 0.10.0,至少要做两件事:前面放一个有读超时的反向代理;把 topcoat::start 交给会重启的进程管理器。

小结

  • 自建 router 换来贯穿请求的 Cx、模块发现、同一响应里的流式和错误即控制流,代价是 axum 生态只能经 tower 适配层使用。
  • 「函数而不是中间件」让每个 handler 的前提写在自己身上,代价是忘了就没有;shard 和 procedure 的独立端点把这一点放大,带路径的 layer 可以补一道。
  • 链接期发现零构建步骤,代价是路由表启动时才确定、漏了 mod 静默 404。
  • $(...) 换来零 wasm、零前端构建,代价是 Rust 子集、登记在案的 6 条不一致和本批找到的 while 尾表达式问题。
  • 资产标记、偏严的安全默认值各有对应的部署和放行成本;0.10.0 的服务器循环还有 accept 出错即退出等问题,部分已在 tag 之后修复。

💬 关联讨论

发起讨论
加载中...