zjchina.net

Drogon · ROS 2 · 独立开发笔记

ENGINEERING NOTES/工程笔记

当今最前沿的全栈架构:Dioxus + Drogon + CAF 双进程 SSR 岛屿架构实战

一个老 Web 工程师为什么还要再造一遍轮子

先交代身份。我是 Drogon 社区的早期参与者,这些年一直用 C++ 写高性能 Web 服务,对 HTTP 框架内部的事件循环、过滤器链、异步回调这套打法熟到骨子里。正因为熟悉,我很清楚传统 C++ Web 的天花板和舒适区分别在哪:性能和掌控感是天花板,而头文件带来的编译膨胀、以及 UI 层长期没有现代叙事,是舒适区边缘两根生锈的栏杆。

2026 年有两件事同时成熟了:C++23 modules 在 clang 23.1 上终于能打生产,以及 Dioxus 0.8(alpha)把同一份 Rust 组件编译成 SSR 直出和 WASM 水合的链路跑顺了。白天我的主业是 ROS 2 上位机,它教我用分布式系统的眼光看一切——节点单一职责、通信走明确总线、故障域隔离。于是我决定用自己最熟的 Drogon 做基石,把这两条前沿技术线缝合成一台真正跑在线上、扛真实流量的机器,而不是又一个只在 README 里成立的 demo。

为什么敢说“最前沿“?因为这个组合里的每一项都还在快速演进,而它们之间的调度关系几乎没有现成答案:同一个请求,服务器上是双进程协作(Drogon 调 SSR 侧车),浏览器里是单进程自举(WASM CSR 接管),两种形态共用同一套组件基因,任一环节挂掉还能自动降级。这篇文章讲的就是缝合方案、真实数字和一手踩坑。

技术栈四个角色:

  • Drogon:我深度参与过的 C++ HTTP 框架,C++23 + modules 编译,做唯一公网入口和调度中枢
  • CAF(C++ Actor Framework):后端内部 actor 调度,把埋点批写从 HTTP 线程上彻底摘走
  • Dioxus 0.8(alpha):Rust 写 UI,同一套组件出 SSR 直出、WASM 水合、desktop/mobile
  • TrailBase:文章数据唯一真源,自带记录式 API 和鉴权,前端永远不直连

四个角色,一张拓扑图

全景如下,后面所有讨论都围绕这张图:

浏览器
  │  请求 /、/posts/{slug} 等公开页面
  ▼
Nginx(443,HTTP/2,TLS 在这终止;特征路径隐藏层)
  ▼  proxy_pass 127.0.0.1:8400
Drogon 后端(C++23 modules,单二进制,唯一公网入口)
  ├─ 公开页面分发:POST /_render ──→ SSR 侧车(:8301,axum + Dioxus SSR)
  │     侧车按 path 渲染 pages/* 组件,产出完整 HTML
  │     渲染期回环请求 Drogon /api/* 取数(8s 超时兜底)
  │     侧车挂了/超时 ──→ 回退 SPA 薄壳(站点不白屏)
  ├─ 公开 API:/api/posts、/api/tags、/api/track …
  ├─ 静态托管:/assets/*、/fonts/*、wasm 产物
  ├─ Admin 后台:/admin/login + /admin/api/*(会话或 loopback 内钥鉴权)
  └─ 数据桥接 → TrailBase(:4000,loopback,文章 admin_posts 单一真源)

三个进程各司其职。Drogon 是门卫和总线,TLS 终止、安全头、限流鉴权、静态长缓存全在这一层——这些恰恰是我多年用 Drogon 最有把握的部分;SSR 侧车是纯渲染器,只暴露 /_render/_render-md/health 三个端点,不碰数据库;TrailBase 是数据真源,refresh token 只存在 Drogon 进程内存里,Nginx 层把它所有特征路径(/_trailbase/_admin/_/auth/)一律 return 404,公网根本感知不到它的存在。

这是 ROS 2 刻进我脑子的设计纪律:节点单一职责,通信走明确总线,单点故障不得演变成系统级失效。侧车崩了后端还活着;后端卡了,侧车渲染 8 秒后放弃、吐薄壳;浏览器拿到薄壳,WASM 照常把页面水合出来。

岛屿模式:同一份组件,两种进程形态

“岛屿架构(Islands)“通常指静态 HTML 里嵌几个可交互小岛。我这里的做法更激进:首屏整页由 Rust SSR 直出,水合之后整页变成一座由 WASM 驱动的大岛,而决定走哪条路的开关,只是后端的一个环境变量。

双进程形态:SSR 优先

设置 ZJCHINA_SSR_BASE 后,Drogon 注册 //{slug}/{p1}/{p2} 三类公开页面路由。请求进来,Drogon 把 method、path、关键 header 打包成内部 POST 发给侧车的 /_render。侧车用 Dioxus SSR 按路径分发到对应 pages/* 组件,产出含完整 title、meta、Open Graph、正文的 HTML。

这里有个精巧的回环依赖:侧车渲染文章页需要数据,而数据在 Drogon→TrailBase 这条链路上,所以侧车以 DROGON_API_BASE 回头请求 Drogon 的 /api/posts/{slug}。数据真源始终只有一个,侧车永远是无状态渲染器——重启它不丢任何东西。

单进程形态:CSR 兜底

不设这个环境变量,Drogon 就退化为纯静态托管:/ 直供 SPA 薄壳,其余路径全局 404 回退薄壳,由 WASM 在浏览器里跑路由和渲染。这不是开发模式,这是生产降级路径:侧车发版、崩溃、被 OOM 杀掉的任何时刻,用户看到的仍是一个完整可用的站点,只是首屏从“直出“变成“水合“。

desktop/mobile 端天然就是单进程形态——没有侧车可挂,同一份 Dioxus 组件直接以纯 CSR 跑在原生 webview 里。一份组件基因,双进程是它在服务器上的高配形态,单进程是它的底线形态。

为了让 SSR 和 CSR 不长出两张脸,首页八个 section(hero、intro、impact、significance、challenges、architecture、cases、summary、blog)在 ssr/src/pages/home/src/pages/home/ 里一一对齐,全站样式只有一个真源:外链的 /assets/site.css,WASM 挂载前样式已就位,从根上杜绝样式闪烁。

调度是这台机器的灵魂

架构图是骨架,调度细节是神经。双进程模式最大的风险是“每多一跳就多一份延迟和一个故障点“,全部加固都在回答这个问题。

连接池复用。 早期实现里 Drogon 每个页面请求都新建一个到侧车的 HttpClient,TCP 握手白白叠在 TTFB 上。改成进程内按 base 缓存的 cachedSsrClient 后,回环连接常驻,这一跳的成本基本只剩本机内存拷贝——这类热路径优化对 Drogon 老手是条件反射。

HTML 微缓存。 侧车加了一层 60 秒 TTL、512 条上限的页面缓存(admin/* 不缓存、渲染错误不缓存、锁不跨 await)。热门文章页一分钟内直接命中,既不重复渲染组件,也不重复回环取数。

有界超时。 侧车回源 Drogon 的 reqwest 客户端统一挂 8 秒总超时。后端卡顿时渲染是有界的,不会把 tokio worker 一个个拖满,最后雪崩成整站 502。

CAF actor 批写埋点。 这是 CAF 在项目里最实打实的一处。每次页面浏览和分享事件,前端用 sendBeacon 打到 /api/track。若每个 beacon 同步写一次 SQLite,HTTP 线程会被磁盘 IO 牵着走。实际做法是请求处理器只做限长校验,然后 caf::anon_send 投递给一个 detached actor,立即返回;actor 内部攒批,满 8 条立即刷,否则 800 毫秒定时刷,一次事务批量写进 page_views 表。actor 持有数据库连接的 shared_ptr,与 HTTP 请求生命周期彻底解耦。

beacon → HTTP 线程(仅投递,微秒级返回)
              ↓ anon_send
         CAF detached actor(攒批:≥8 条 或 800ms)
              ↓ 单事务批量写
           SQLite page_views

效果是 /api/track 的 P99 基本与磁盘抖动无关,而后台统计看到的数据只滞后不到一秒。

C++23 modules:老框架遇上新编译器

后端最早是一个 1986 行的 main.cc。能用,但改一行全量重编,所有职责共享一个巨大编译单元——和“把所有 ROS 节点写进一个 main“是同一种技术债。

重构成果是 10 个模块单元(.cppm),clang++ 23.1、-std=c++23、CMake 4 的 CXX_MODULES + Ninja:

  • zjbackend_core:共享运行态 State 与纯工具
  • zjbackend_pv:上面说的 CAF 埋点 actor
  • zjbackend_db:SQLite schema 幂等 DDL
  • zjbackend_trail:TrailBase 桥接、token 缓存与异步刷新
  • zjbackend_static:静态 MIME、防目录穿越、差异化缓存
  • zjbackend_admin:AdminFilter 鉴权、登录会话
  • zjbackend_http:公开 API、全局安全头
  • zjbackend_cms:后台统计与文章 CRUD
  • zjbackend_listen:HTTPS/h2 与 loopback 监听器装配
  • zjbackend_app:唯一组装点,env → State → register → run

最后的 main.cpp 只有三行:import zjbackend_app; return zj::runServer();。增量编译从“喝杯咖啡“变成“眨个眼“,模块接口在编译期就挡住跨模块偷拿私有状态。

作为熟悉 Drogon 反射机制的人,我原以为模块化会很顺,实际有三处必须对齐的边界,这里把一手结论留下:

  1. 第三方头放进 global fragment。 Drogon、CAF、jsoncpp 重度依赖宏和模板,模块内直接吃这些宏会踩边角问题。稳妥做法是 module; 之后用传统 #include 引入它们,只把自有代码模块化——模块化的边界划在“我能掌控的代码“,而不是全世界。
  2. Filter 的反射注册名变了。 我清楚 Drogon 用 typeid demangle 后的类名匹配路由字符串。一旦 filter 类放进 namespace zj 或模块命名作用域,注册名会带命名空间/模块后缀,路由里写 "AdminFilter" 全部失配。结论是 filter 类留在全局作用域,路由字符串写完整注册名。这是框架既有契约在 modules 下的延伸,而不是框架的 bug。
  3. import std 在当前工具链尚不可用。 clang 23.1 的 Homebrew 构建未提供标准库 header units,标准库头也只能走 global fragment。等工具链补齐,模块化边界还能再往外推一圈。

打磨期的真实战报

架构搭完只是及格线。上线前那轮基于真实测量的体检,逼出了一批比架构本身更影响体感的修复,挑四个有代表性的。

字体闪烁(FOUT)。 站点用了三套字体,Noto Sans SC 按 unicode-range 拆成 101 个 CJK 子集,整个 fonts.css 声明了 109 个 @font-face。浏览器默认行为是发现页面真的出现某个字才请求对应分片,中文先以回退字体渲染、到齐后整体跳变。修复是把首屏 nav 和 hero 实际用到的文字跑一遍子集匹配,命中的 12 个文件(1 个 Outfit 拉丁 + 11 个 CJK 分片)全部 <link rel="preload" as="font" crossorigin>,HTML 解析阶段并行拉取。冷加载实测:字体 115ms 开始下载、FCP 416ms、零重复请求。细节值得记住:字体预加载必须带 crossorigin,同源也要带,否则 preload 响应不会被字体请求复用,等于下载两遍。

压缩与缓存。 CSS/JS 一度原样传输,gzip 开启后 fonts.css 从 112KB 降到 31KB;内容哈希命名的 wasm/woff2 和版本化 CSS/JS 统一 max-age=31536000, immutable,回访几乎零流量。

API 延迟。 /api/posts 最早每次穿透 TrailBase,实测 0.55 到 1.6 秒。加上后端缓存并下发 Cache-Control: public, max-age=30, s-maxage=300 后,响应稳定在 42 毫秒。文章是典型读多写少、发布即主动失效的数据,这种数据不缓存是对架构的浪费。

CSP 从 unsafe-inline 收到哈希白名单。 最早 style-src 带着 unsafe-inline,代码注释写着“Dioxus 会注入 style“。我在真实水合后的页面上数了一下:注入的 <style> 块是 0 个,注释前提早已过时。于是把分享弹层 9 处内联 style="" 全部 class 化,再把全站仅存的 4 个固定 <style> 块(薄壳启动提示 1 个、文章页 SSR 注入 3 个)算 sha256 进白名单;后台 CMS 页保留 unsafe-inline,公开页彻底摘掉这颗注入面帽子。中间有个反转:本以为 JS 写 setAttribute("style", …) 能绕过 CSP,Chromium 照拦,最后改 CSSOM 的 style.set_property() 直写才通过——规则管的是“内联样式声明“这个行为本身,与用什么 API 无关。

一份内核,四个平台

Dioxus 的平台抽象不是 PPT 上的口号。Cargo.toml 里 web、desktop、mobile 三个 feature 互斥,同一份页面组件、同一套 API 封装(web 走相对路径,native 走域名)编译出三种产物:

  • web:走前述混合模式,侧车直出首屏,WASM 水合接管交互
  • desktop/mobile:没有侧车,单进程纯 CSR,内核自己就是首屏和全部交互
  • 构建统一build-web.sh 一条命令完成 wasm 编译、wasm-bindgen、内容哈希命名和外置 boot 入口;后者还让 CSP 的 script-src 不需要 'unsafe-inline'

为网站写的每一个组件,同时是未来桌面端和移动端的资产。一个个人项目拿到的是“一份投入、四个出口“的杠杆。

复盘:前沿为什么站得住

回头看,三个决策被证明无比正确。渐进降级让侧车历次重启在用户侧毫无感知,这是双进程架构敢上生产的前提;微缓存 + actor 批写用最少的复杂度换掉了绝大多数性能抖动,HTTP 线程回归只做调度的本职;单一样式真源让 SSR/CSR 永远长着同一张脸,后续字体、CSP、玻璃拟态的打磨只改一处。

学费也有几笔。alpha 阶段的 Dioxus router 会在某些导航路径剥掉 query string,标签过滤最后用一个全局一击信号交接,而非优雅的路由参数;Service Worker 缓存版本至今靠手改常量(已迭代到 v5),忘记改就会让老访客卡在旧缓存,正解是构建期注入文件哈希自动版本化;线上错误监控还是空白,目前靠健康检查和用户反馈,这是下一个要补的洞。

最后交代这台机器的建造方式:相当一部分代码、架构推演和踩坑排查,是我和 AI 结对完成的——我定义问题、掌握取舍、拍板,AI 不知疲倦地读代码、跑验证、和编译错误对峙。文章里每个数字都来自真实测量,每次发布前都有真实浏览器跑过回归。

所以“当今最前沿的全栈架构“这句话,我愿意把它落在明处:C++23 modules 的生产级 HTTP 后端、CAF actor 的无阻塞调度、Rust 同构组件的双渲染形态与自动降级、一份内核四端输出——每一层都取到了 2026 年技术栈的前端,而且它们不是陈列在 demo 里,是跑在 zjchina.net 上、此刻就能访问的真实系统。前沿不是追新追来的名头,是每个环节都经得住真实流量和真实浏览器检验之后,自然成立的判断。

如果你也在做类似的缝合实验,希望这套拓扑、这些数字和坑,能帮你少熬几个晚上。

← 返回首页