Yggdrasil:用 Rust 同时编译浏览器与服务器
一份代码,两个运行时。一个 Rust 全栈博客引擎的深度源码剖析。
Yggdrasil 是一个用 Rust 写的全栈博客 / CMS。它没有前后端分离,没有 Node 中间层,没有两个仓库的同步地狱。它只用一份 Rust 源码,通过 Cargo 的 feature gate,编译出两个完全不同的产物:一个编译成 WebAssembly 跑进浏览器当交互前端,一个编译成原生 ELF 当 Axum HTTP 服务器。它还是个 MCP 服务器,让 AI 客户端把它当知识库查询。它甚至能在 Docker 沙箱里执行读者提交的代码。
这不是一篇功能介绍。这是一份逐行剖析源码的工程文档——每个子系统为什么这么设计、付出了什么代价、留下了什么技术债。所有代码均取自仓库当前实现。
项目规模
| 维度 | 数值 |
|---|---|
| Rust 源码 | 187 个文件,约 43,660 行 |
| Dioxus server function | 分布在 29 个文件,合计约 60–80 个 |
| 数据库迁移 | 17 个(编译期 include_str! 内联) |
| 单元测试 | 23 个测试模块,637 个 #[test] |
| 前端 JS 包 | 7 个(pnpm workspace,Vite IIFE bundle) |
| 生产镜像 | 一个静态 ELF + public/ + uploads/,FROM scratch |
flowchart TB
subgraph 客户端
B[浏览器 WASM 前端]
AI["AI 客户端<br/>Claude / Cursor / Cline"]
end
subgraph Axum 服务端
AG["admin_guard<br/>302 快路径"]
MW["CSRF → Cache-Control → 压缩 → 30s Timeout"]
IR{IncrementalRenderer<br/>static/ 磁盘缓存}
MK["moka 内存缓存<br/>9 个独立 TTL"]
POOL["deadpool 连接池<br/>Fast 回收 + statement_timeout"]
MCP["/mcp<br/>rmcp + bearer 鉴权"]
SBX["Docker 沙箱<br/>bollard 代码执行"]
end
subgraph 数据层
PG[(PostgreSQL)]
FS["uploads/<br/>原图 + .cache/ WebP 派生"]
end
B -->|HTTP / SSE| AG
AG --> MW --> IR
IR -->|MISS| MK --> POOL --> PG
AI -->|MCP| MCP --> MK
B -->|runnable 代码块| SBX
B -->|图片上传/访问| FS
一、双目标编译:同一份源码的两个命运
这是整个项目的架构基石。理解了它,就理解了后续所有设计决策的约束。
1.1 两个正交的 feature
Cargo.toml 定义了两个 feature,几乎所有行为都围绕它们展开:
注意 server feature 用的是现代 dep:name 语法——这些依赖全是 optional = true,只在 server feature 激活时才拉入依赖图。
WASM 前端构建用 --no-default-features --features web。这意味着 tokio、deadpool、axum、moka、syntect、bollard、rmcp、mimalloc……这些重依赖一个都不会进前端的依赖图。前端只拉入 dioxus/web 和 web-sys / wasm-bindgen 系列。
这是前端体积和编译时间的根本保障。
1.2 三类编译桩
但有个硬约束:#[server(Fn, "/api")] 宏注册的 server function 签名,两端必须一致编译。WASM 端虽然不执行 server fn 的函数体(宏会剥离),但它必须看到函数签名,才能生成调用桩。于是项目里出现了三类编译桩,让 WASM 端通过类型检查却不引入重依赖。
第一类:整层模块消失。 cache.rs 是最极端的例子——几乎每一行都独立挂 #[cfg(feature = "server")]:
use Cache;
use LazyLock;
use Duration;
const TTL_POST_LIST: Duration = from_secs;
static POST_LIST_CACHE: = new;WASM 构建时,整个 moka 缓存层——9 个缓存实例、9 个统计结构、所有 get/set/invalidate 函数、所有 TTL 常量——全部凭空消失。零编译产物,零前端体积。
第二类:保持接口形状的桩。 数据库连接池在 WASM 端用一个 DummyPool 维持与真实 pool 完全相同的公开接口形状:
/// **请勿删除此 stub**,否则非 server 构建将无法通过编译。
模块文档解释了为什么这能工作:
这种 stub 模式是 Dioxus fullstack 项目的常见做法:服务端函数体在 WASM 构建时会被剥离,但模块结构必须保持一致,因此需要一个占位实现来满足编译器的符号解析。
第三类:unreachable!() 桩。 纯后台管理路径(如 MCP token 管理)前端永远不会调用。这些 server fn 的 WASM 端直接返回 unreachable!()——在真正的 server 构建里这些分支会被编译剔除,它只用于满足 WASM 的类型签名。AGENTS.md 明确规定 unreachable!() 只能用在 #[cfg(not(feature = "server"))] 分支里。
1.3 静态自包含二进制
这三个 feature gate 的配合(server / not(server) / wasm32)让项目可以把所有运行时资源都编译期内联,产出一个完全自包含的静态二进制:
// src/db/migrate.rs — 17 个迁移 SQL 编译期内联
const MIGRATIONS: & = &;
// src/highlight.rs — 7 个自定义语法高亮定义编译期内联
pub const CUSTOM_SYNTAXES: & = &;
// src/api/changelog.rs — CHANGELOG.md 编译期内联
const CHANGELOG_MD: &str = include_str!;结果是:运行时的 FROM scratch 镜像只需三样东西——二进制本身、public/ 目录(CSS/JS/WASM 资源)、uploads/(用户图片)。磁盘上没有散落的 .sql 文件,没有 syntaxes/ 目录,没有 CHANGELOG.md。
这些内联列表与磁盘目录的一致性由编译期测试守护。例如 custom_syntax_list_matches_directory 测试会检查 CUSTOM_SYNTAXES 数组是否与 syntaxes/ 目录下的文件完全对应——如果有人加了一个语法文件却忘了注册到数组,测试立即失败。
一个值得注意的例外:
highlight.css不是include的。它由构建工具src/bin/generate_highlight_css.rs用 syntect 在构建期生成到public/highlight.css,运行时作为普通静态资源服务。这个二进制用required-features = ["server"]门控——WASM 构建不编译 syntect,这个工具也不会参与编译。
这篇文章本身的代码块就是上面这套渲染管线的产物——下面这段 Rust 代码可以直接在页面上运行,体验 Docker 沙箱的真实执行:
二、启动流程:从零到就绪
理解了编译架构,来看运行时。main.rs 是整个服务端的启动入口,它的流程设计体现了“让用户可修复的错误走友好路径“的原则。
2.1 校验先行,panic 后行
启动的第一件事不是连接数据库,而是校验配置。validate_database_url() 在连接池被触碰之前运行,把 URL 格式错误、池大小非法这类用户可修复的问题,转成统一的 exit(1) 路径:
这个顺序至关重要。DB_POOL 是一个 LazyLock<Pool>,它的初始化闭包里有一个 .expect():
pub static DB_POOL: = new;注释解释了这个设计:
不可达的防御性 panic:
main.rs启动时已通过validate_database_url()前置校验。因此在真实运行路径上本闭包里的.expect()永远不会触发。保留.expect()只是为了满足LazyLock必须返回T(而非Result)的类型约束——若这里真的 panic,说明validate_database_url与本闭包逻辑不一致,属于代码 bug 而非用户错误。
这是 panic = "abort" 项目处理初始化的标准模式:把可修复的错误提前拦截走 exit(1),让 LazyLock 闭包退化成“只在代码有 bug 时才会触发的防御性代码“。
2.2 一次性 runtime 驱动迁移
迁移逻辑是异步的,但 main() 是同步函数。项目用一个独立的 throwaway runtime 驱动迁移,完成后显式 drop,再交接给 Dioxus 自己的 runtime:
let migrate_rt = new_multi_thread
.enable_all
.build
.expect;
migrate_rt.block_on;
drop; // 显式释放线程资源端口预探测这段值得单独说。dioxus::server::serve() 内部对 TcpListener::bind(addr).await...unwrap() 失败会直接 panic(SIGABRT)。项目在交接给 serve() 之前先探测同一地址,把 panic unwrap 转成可操作的 exit(1) + 友好提示(“端口可能已被占用,用 lsof -i :PORT 查看占用进程”)。探测用的 listener 立即 drop,由 serve() 重新绑定——同进程内快速 rebind 不经过 TIME_WAIT,无窗口问题。
2.3 路由组装与中间件分层
dioxus::server::serve() 返回一个闭包,里面组装最终的 Axum Router。路由合并和中间件顺序是精心设计的:
serve;几个值得注意的决策:
- 上传路由 300 秒超时——图片可能涉及 WebP 转码(CPU 密集),需要比默认 30 秒长得多的时间。
- SSE 路由不挂 TimeoutLayer——它是长连接,30 秒超时会直接杀掉流。CSRF 对 GET 放行(
is_write_method返回 false)。 - admin_guard 最外层——未登录的
/admin*请求在 CSRF / cache / SSR 渲染之前就被 302 短路,零渲染开销。 - 静态路由无中间件——healthz / readyz / 图片服务不需要 CSRF、不需要缓存头、不需要超时。
三、数据库策略:两种连接哲学
数据库层展示了一种成熟的系统思维:同一个连接池,根据使用场景采用完全不同的获取策略。
3.1 运行期:反雪崩的快速失败
运行期的 get_conn() 是反雪崩导向的。它只对“数据库不可达“这类错误退避重试,对“连接池已满“的 Timeout 直接返回,不重试:
pub async 这个区分的逻辑是:如果连接池满了,说明系统已经过载。此刻重试只会让等待队列更长、每个请求的延迟更高、最终触发更多超时和重试,形成雪崩。不如立即失败,让上层的 HTTP 限流器把新请求挡在门外,给系统喘息空间。
3.2 启动期:宽容的长重试窗口
启动期的 get_conn_for_startup() 采用了完全相反的哲学——固定 500ms 间隔轮询,以总时长为终止条件:
pub async ..> 注释解释了为什么启动期不需要反雪崩:
与运行期的
get_conn区别:
- 没有反雪崩约束:启动时只有这一个进程在连,不会雪崩,可以放心长重试。
- 固定间隔重试(而非指数退避):启动场景下 DB 要么起来要么没起来,固定 500ms 轮询比指数退避更可预测,也贴合
pg_isready式的等待语义。- 以总时长为终止条件(而非次数):对运维更直观——“给 DB 30 秒起来”。
一个细节:sleep 用 min(retry_interval, remaining) 确保不会睡过 deadline——即便最后一次重试只剩下 200ms,也不会浪费。
3.3 statement_timeout:全局注入
防慢查询不是在每个查询上加超时,而是在建立连接时通过 libpq options 一次性注入 PostgreSQL 的 GUC:
默认 30 秒。这意味着任何查询(包括管理员 SQL 控制台的手动查询)如果超过 30 秒,PostgreSQL 会主动杀掉它。这防止单条慢查询(如无意中的全表扫描)长时间占用连接、拖垮整个连接池。
3.4 Fast 回收策略
连接池配置了 RecyclingMethod::Fast——归还连接时不额外发 SELECT 1 验证,直接复用:
let mgr_cfg = ManagerConfig ;Verified 模式在每次 get() 时多一次 SELECT 1 往返来确认连接活着。在高并发下这个额外往返是显著的。Fast 模式依赖 tokio-postgres 在实际使用时自然报错(如果连接已断),由 get_conn 的重试层兜底。
3.5 自举:零手动首次部署
ensure_database() 在连接池首次被触碰之前运行,连接 postgres 维护库检查目标数据库是否存在:
pub async 注释特别强调了 SQL 注入防护:CREATE DATABASE 不支持参数化($1),库名直接拼到 SQL 后面。所以 is_simple_ident 检查库名只含字母数字下划线——含 -、引号等特殊字符的库名直接跳过自动创建,把“库不存在“的错误留给后续正常连接路径去报告。
3.6 咨询锁:多实例串行化迁移
多实例滚动发布时,pg_advisory_lock 保证只有一个进程执行迁移:
const ADVISORY_LOCK_KEY: i64 = 0x5947_4752_4153_494C;
// "YGGRASIL" 的十六进制——项目专属的大整数,避免与同库其它应用冲突。咨询锁是数据库级唯一的(不是行级锁、不随事务结束释放)。多个实例同时启动时,只有一个能拿到锁执行迁移,其余阻塞等待。拿到锁的实例执行完所有迁移后释放锁,等待的实例拿到锁后发现 schema_migrations 表已有全部版本,跳过执行。每个迁移在独立事务里运行,失败自动回滚,版本行不会写入。
四、认证体系:密码、会话与首用户原子 admin
4.1 Argon2:memory-hard 哈希
密码用 Argon2id(Argon2 的混合模式,同时抗 GPU 和侧信道攻击)。配置用的是 Argon2::default(),对应 OWASP 推荐的参数(m=19456 即 19 MB 内存、t=2 迭代、p=1 并行度):
..> 验证函数有个值得注意的错误处理:密码不匹配返回 Ok(false) 而非 Err——只有哈希格式无效等系统错误才返回 Err。这让调用方可以区分“密码错“和“系统错“。
Argon2 是 memory-hard 计算——它会分配并填充约 19 MB 内存。这意味着绝不能在 Tokio worker 线程上执行,否则阻塞整个线程。所以所有哈希和验证都包在 spawn_blocking 里:
let password_hash = spawn_blocking
.await
.map_err?
.map_err?;4.2 首 admin 的原子竞争
注册接口设计了一个精巧的“首用户自动成为 admin“机制。核心是一条 INSERT ... ON CONFLICT DO NOTHING RETURNING id:
pub async ..> 巧妙之处在于:第一次插入始终用 role = 'admin'。这意味着第一个注册的用户直接拿到 admin 权限。如果已有 admin,这条插入不会改变任何已有行的 role——ON CONFLICT DO NOTHING 在用户名或邮箱冲突时返回空。然后用一次 EXISTS 查询区分到底是“已有 admin 所以关闭注册“还是“用户名/邮箱已存在“。
整个竞争是原子的,无需 LOCK TABLE,无需应用层的先查后插(后者有 TOCTOU 竞态窗口)。
4.3 Timing Attack 防护
登录时如果用户不存在,也执行一次 Argon2 verify——用固定合法哈希(必然失败),抹平“用户不存在“与“密码错误“的响应时序差:
Ok => 如果没有这个防护,攻击者可以通过响应时间差异判断哪些用户名存在:用户存在时会跑 Argon2 verify(约 100ms),用户不存在时直接返回(约 1ms)。Argon2 是 memory-hard,耗时显著,时序差异明显。
注意 DUMMY_HASH 用的是合法的 Argon2 PHC 格式——verify_password 不会因为格式错误提前返回,而是真跑完整个 Argon2 计算再返回 Ok(false)。
4.4 Session LRU + 并发安全
同一用户的活跃 session 超过 MAX_SESSIONS_PER_USER(默认 5)时,删除最早的 session。关键是用事务 + FOR UPDATE 锁住用户行:
let tx = client.transaction.await?;
// 锁住该用户行,并发登录在此排队。
tx.execute.await?;
let session_count: i64 = tx.query_one.await?.get;
if session_count >= max_sessions
tx.execute.await?;
tx.commit.await?;注释标注了这个问题编号(M1)。如果没有 FOR UPDATE 锁,两个并发登录请求可能同时读到 count = 4(认为未超上限),各自插入一条 session,最终 6 条超过上限。行锁把同一用户的并发登录排队成串行执行。
Session token 用 UUID v4 生成,存储时用 SHA-256 哈希——数据库里不存原始 token,即使数据库泄露也无法直接使用 session:
Cookie 设置 HttpOnly(JS 不可读)、SameSite=Lax(跨站 POST 不带 cookie)、可选 Secure(仅 HTTPS 传输)。
4.5 session_generation:版本化失效(只接了一半)
设计意图是:用户被降级或封禁后,通过 bump 一个版本号让所有已签发 session 立即失效。读侧完全接线了——get_user_by_token 命中 moka 缓存后仍回查数据库主键:
pub async 每次请求都查一次主键看起来开销大,但注释指出这是“亚毫秒级“——主键查询走索引,几乎是常数时间。缓存的意义不在省这次查询,而在省 JOIN sessions + users 的完整用户加载。
但写侧没有接线。 migration 012 只加了列 session_generation INT DEFAULT 0,没有 PostgreSQL TRIGGER 自动 +1。源码全局搜索也没有 UPDATE users SET session_generation 的语句。这意味着当前要触发这个失效机制,只能直接改库。这是一个诚实的半成品——读侧正确(永远 0==0 通过),但“封禁用户立即踢下线“的功能事实上还没落地。
五、错误处理体系:日志记全链,响应不泄露
这是 panic = "abort" 项目最关键的安全网。
5.1 AppError:脱敏映射
错误分两类处理。DB 类错误(连接失败、查询失败、事务失败)对外只暴露通用中文提示;业务校验错误原样透传:
区分原则很清晰:DB 错误可能含敏感信息(SQLSTATE、约束名、表结构),必须脱敏;业务错误是人类可读的操作指引(“用户名长度必须在 3-50 字符之间”),应该透传给用户。
5.2 format_with_sources:展开错误链
format_with_sources 的存在本身就是一个教训。tokio_postgres::Error 的 Display 实现对 DB 侧错误只打印无信息量的占位串 db error,真正的错误消息藏在 source() 链里的 DbError:
/// 存在的原因:tokio_postgres::Error 的 Display 对 DB 侧错误只会打印
/// 无信息量的占位串 `db error`,真正的消息文本(如
/// `column "x" of relation "y" already exists`、SQLSTATE、约束名)藏在
/// source() 链里的 postgres::error::DbError。不主动遍历链,日志和错误
/// 字符串就会全部折叠成 `db error`,无法定位失败原因。
跳过重复占位层是个容易忽略的细节:如果外层和内层的 Display 输出完全相同(比如都是 db error),就不重复追加,避免 db error: db error 的冗余输出。
5.3 业务拒绝 vs 真正的错误
项目有一个贯穿全局的约定:业务校验失败返回 Ok(Response{success:false, message:"..."}) 而非 Err(ServerFnError)。例如注册时用户名不合法:
if let Err = validate_username 这让前端可以区分“操作被拒“(success: false,需提示用户修正输入)和“系统出错“(Err,需重试)。如果把业务拒绝也走 Err,前端的错误处理逻辑会混为一团。
六、Markdown 渲染管线:保存时算一次
6.1 render-once-at-save 架构
这是整个项目最关键的架构决策之一。文章的 Markdown 源码(content_md)和渲染后的 HTML(content_html)都存在数据库里:
// create.rs
let fields = render_post_fields.await?;
let row = tx.query_one.await?;读路径(占流量 99%+)只需一次 SELECT content_html FROM posts 直接返回渲染好的 HTML,不需要在请求时跑 pulldown-cmark + syntect + sanitizer + KaTeX。CPU 密集的渲染只在写时发生一次。
代价是:修改语法高亮规则不会自动刷新已有文章。需要管理员点 /admin/posts 的“rebuild all“按钮(rebuild_content_html,批量 500),全量重新渲染。
6.2 两遍遍历,一次解析
render_markdown_enhanced 有个精心设计的优化——解析一次,收集成 Vec<Event> 后两遍遍历复用:
CowStr 是 pulldown-cmark 的写时复制字符串——当内容是输入 Markdown 的子串时,它借用切片(零拷贝);只有需要修改时才分配。collect 成 Vec 后,这些借用仍然有效,两遍遍历都不会拷贝。
6.3 GFM 脚注的位掩码陷阱
pulldown-cmark 的脚注模式配置展示了对库内部的深度理解:
// ENABLE_OLD_FOOTNOTES = (1<<9)|(1<<2),它把 ENABLE_FOOTNOTES 的 bit 也打进了
// OLD 的位掩码里。Options::all() 同时置两者,使 has_gfm_footnotes()
//(=ENABLE_FOOTNOTES && !ENABLE_OLD_FOOTNOTES)返回 false,走 OLD 模式
//(续行宽松、label 可含换行)。我们想要 GFM 模式(与 GitHub 一致),所以不能
// 简单地 remove(OLD)——那会连 ENABLE_FOOTNOTES 一起清掉。
// 正确做法:先 remove(OLD)(清掉 bit 9 + bit 2),再 insert(ENABLE_FOOTNOTES)
// 单独把 bit 2 加回,使 has_gfm_footnotes() = true。
let mut opts = all;
opts.remove;
opts.insert;OLD 模式和 GFM 模式的行为差异:OLD 模式续行宽松、footnote label 可以包含换行符;GFM 模式与 GitHub 一致、解析更可控。项目要 GFM 模式,但因为位掩码的重叠设计,不能简单地“关掉旧的、打开新的“——关掉旧的会连新的也一起关掉。
6.4 HTML 转义的上下文陷阱
标题文本用于 TOC 的 aria-label 属性时,原先用 clean_html 处理。但 clean_html 面向元素内容上下文,在属性值上下文中会漏掉 ":
/// 原先用 clean_html 处理属性上下文会漏掉 ",标题形如
/// " onmouseover="alert(1) 会越出属性边界。
这是“HTML 转义不是上下文无关的“的经典案例。clean_html 对元素内容足够(元素内容里 " 无特殊含义),但属性值里 " 会闭合属性边界。修复是复用 utils::html::escape_html(同时转义 & < > " '),避免在仓库里维护第二份转义实现。
6.5 HTML 清洗:两套白名单
sanitizer.rs 基于 lol_html 提供两套清洗策略。文章正文(clean_html)允许较完整的标签集;评论(clean_comment_html)更严格——移除 img / details / summary,禁用 data URI,外链额外加 nofollow(防 SEO 垃圾注入)。文章外链只加 noopener noreferrer(防 tabnabbing)。
一个值得注意的纵深防御设计:is_safe_data_uri 在请求路径上是死代码(两处配置都硬编码 allow_data_uri: false),但保留并配测试锁定安全不变量:
D7:生产死代码——两处 SanitizerConfig 均硬编码
allow_data_uri: false,本函数在请求路径上不可达。保留作为防御纵深:测试锁定“即便 flag=true 也只放行图片类型、拒绝 data:text/html“的安全不变量。若未来需要内联 data URI,flag + 本函数已就绪。
七、代码高亮:CSS class 方案与五层查找
7.1 ClassedHTMLGenerator 而非内联 style
高亮用 syntect 的 ClassedHTMLGenerator——它不输出内联 style="color: #...",而是输出 CSS class(<span class="k">fn</span>),配合 public/highlight.css 的主题规则。好处是:亮/暗主题切换只需换 CSS 文件,不需要重新渲染 HTML。
7.2 include_str! 内联自定义语法
syntect 内置没有 JSX/TSX/Vue/Zig/Swift/Kotlin/TypeScript 的语法定义。原本可以用运行时 add_from_folder 加载,但在 FROM scratch 的生产镜像里这条路走不通:
/// 生产镜像是 FROM scratch 的静态 musl 二进制,容器内不存在 syntaxes/
/// 目录;而 CARGO_MANIFEST_DIR 烘焙的是构建机路径(Docker 里是 /build),
/// 运行时 add_from_folder 注定失败、这些语言静默回退为纯文本。
/// 因此改为 include_str! 编译期嵌入,彻底消除运行时文件依赖。
pub const CUSTOM_SYNTAXES: & = &;注释描述了一个隐蔽的 bug:CARGO_MANIFEST_DIR 在 Docker 构建时是 /build,运行时这个路径不存在。add_from_folder 会静默失败(不报错),这些语言的高亮静默回退为纯文本——一个“不报错但不工作“的问题。
7.3 find_syntax 的五层查找
用户在代码块写 rust` 或 Rust 或 ````golang 都应正确匹配。find_syntax 依次尝试五条路径,每条都是不同的匹配策略:
注意 bun → ts 的映射——bun 运行器实际跑的是 TypeScript 代码,高亮也用 TS 语法。这是代码执行与高亮的一致性考虑:如果运行器把 bun 代码当 TypeScript 执行,高亮也应该用 TypeScript 语法。
八、KaTeX:纯 Rust 数学渲染 + 物理宏表
数学公式用纯 Rust 的 katex-rs crate 渲染(无 JavaScript 运行时)。两个关键设计决策:
// katex.rs
// - OutputFormat::Html:只产出视觉层 <span class="katex">…</span>,不含 MathML
// 语义层(<math> 等)。这样 sanitizer 无需为 MathML 标签开白名单,XSS 面最小。
// - throw_on_error = false:坏公式渲染成红色错误 span 而非中断整篇文章。OutputFormat::Html 而非 OutputFormat::HtmlAndMathml 是个安全决策——MathML 会引入大量 <math> / <mrow> / <mi> 等标签,sanitizer 要么为它们全开白名单(扩大 XSS 攻击面),要么砍掉它们(破坏公式渲染)。选择只输出视觉层 span 是最干净的方案。
物理宏表
katex-rs 默认没有 physics 宏包的宏定义,导致 \vu(单位向量)、\dv(导数)、\pdv(偏导)、\divg(散度)、\curl(旋度)、\grad(梯度)等物理常用宏渲染为红字错误。项目手动注册了完整的物理宏表:
注释记录了两个刻意差异:
\divg(散度)不覆写内置\div(除号 ÷)——文档明确两者并存。如果覆写,写\div想要除号时会得到散度。\braket虽是 katex 内置宏,但内置版本只接受 1 个参数(\langle{#1}\rangle),物理语义需要 2 个参数(\langle #1 | #2 \rangle),所以覆写为物理版本。
这些是领域知识进入代码的例证——不是泛泛的“支持 LaTeX“,而是精确到“物理学 physics 宏包的每个宏如何映射“。
九、评论系统:递归 approve 祖先链
评论的嵌套审核有个微妙问题。如果一条子评论先被管理员 approve,但它的父评论还是 pending 状态,那子评论在页面上就“悬空“了——父评论不可见导致整条嵌套链断裂,读者看到的是一条回复了“空气“的评论。
解法是 approve 一条评论时,递归 approve 所有 pending 的祖先。用 PostgreSQL 的 WITH RECURSIVE CTE 在一条 SQL 里完成:
pub async WITH RECURSIVE 的 ancestors CTE 从目标评论的 parent_id 开始,沿 parent_id 链逐级向上走。UNION ALL 把每一级的 parent 都收集进来。WHERE a.parent_id IS NOT NULL 在到达根评论(parent_id IS NULL,即顶级评论)时终止递归。最终的 UPDATE 把这些祖先中所有 status = 'pending' 的批量改为 approved。
这比在应用层逐层查询+更新高效得多——一条 SQL 就完成了任意深度的祖先遍历和批量更新。
评论读取按 id ASC 排序返回扁平列表(不是按树结构),让前端自行构建嵌套树。查询包含一个 EXISTS 子查询确保文章已发布且未删除:
SELECT id, parent_id, depth, author_name, author_email, author_url, content_html, created_at
FROM comments
WHERE post_id = $1 AND status = 'approved' AND deleted_at IS NULL
AND EXISTS (
SELECT 1 FROM posts p
WHERE p.id = $1 AND p.status = 'published' AND p.deleted_at IS NULL
)
ORDER BY id ASC LIMIT 200这个 EXISTS 防止已删除或未发布的文章的评论被读到——即使评论本身是 approved 状态。
十、Slug 生成:单遍状态机与分配优化
slugify 把标题转成 URL 友好的 slug。汉字转无声调拼音(你好 → ni-hao),ASCII 字母数字保留,其余替换为 -。注释记录了一次完整的性能重构:
/// 单遍状态机实现:旧实现 to_lowercase 分配一次 String +
/// split.collect::<Vec> + join 再分配 + take.collect 第三次分配,
/// 共 4 次堆分配、3 遍扫描。
/// 现用 prev_dash 状态在单遍内合并连续 - 并按需截断,1 次分配、1 遍扫描。
几个值得注意的优化细节:
to_ascii_lowercase()逐字符而非to_lowercase()整串——后者对 Unicode 有特殊大小写映射(如德语ß→SS)会分配新 String;前者是纯查表,不分配。prev_dash状态机——用一个布尔变量在单遍内合并连续-,等价于旧实现的split('-').filter(!empty).join('-'),但零分配。with_capacity预分配——按标题长度预分配,避免 push 过程中的增量扩容。- 空标题回退时间戳——保证 slug 永不为空(空 slug 会导致 URL 路由问题)。
唯一性校验 ensure_unique_slug 在事务内调用,冲突时追加 base-2 / base-3:
/// 该函数应在事务内调用,确保与后续 INSERT/UPDATE 的 slug 唯一性检查
/// 在同一个事务中完成,避免并发竞态。
pub async 如果不放在事务内,两个并发请求可能拿到相同的 base slug,各自检查“不冲突“后同时插入,最终 slug 冲突。
十一、全文搜索:诚实的全表扫描
搜索接口的模块文档异常诚实——它直接在第一行承认了性能局限:
//! 通过 ILIKE 对 search_text 做子串模糊匹配(两侧 %),无法利用 trgm GIN
//! 索引(仅前缀模式命中),走全表扫描,靠 LIMIT 50 与搜索限流兜底。PostgreSQL 的 trigram GIN 索引(pg_trgm 扩展)可以加速 ILIKE 查询,但只对前缀模式 query% 有效。双侧通配符 '%query%' 使索引失效,退化为全表扫描。
注释坦承这是技术债:
// 后续可升级为 tsvector 全文检索(独立大改动)。当前的工程兜底是三道:
pub async 结果按 pg_trgm 的 word_similarity 函数计算相似度排序——这不是精确的相关度排序(那需要 tsvector + ts_rank),但对于博客规模的数据量是一个合理的近似。查询长度限制 200 字符按 chars().count() 而非 len()(bytes)——后者对多字节中文会截断到半个字符。
十二、HTTP 中间件栈的分层设计
12.1 Cache-Control 三级分级
cache_control_for_path 按路径前缀决定缓存策略,三档分明:
pub immutable 只给带内容哈希的静态资源——这些文件内容变了文件名就变(Dioxus 的 WASM bundle 带 hash 后缀),浏览器可以永不重新验证。公开页用 stale-while-revalidate 而非纯 max-age:允许浏览器在缓存过期后先用旧内容渲染、后台异步拉新,减少白屏时间。
add_cache_control 用 entry().or_insert() 而非直接 insert()——仅在响应尚未设置 Cache-Control 时才添加,不覆盖下游已经设置的策略。
12.2 admin_guard:fail-open 的 302 快路径
这个中间件解决了一个真实的体验问题。文档注释描述了改进前的痛:
// 此前后台鉴权完全在客户端 WASM 完成(SSR 渲染骨架屏 → WASM
// 下载/编译 → hydrate → 异步 get_current_user() → 客户端 navigator.push),
// 整条链串行,未登录用户首屏要"空白好久"才跳登录。改进后,SSR 层直接拦截:未登录访问 /admin* 返回 302 跳 /login,根本不进入 Dioxus 渲染器。关键权衡是 fail-open:
pub async DB 抖动时放行——因为把已登录的管理员踢到登录页的代价高于让客户端 AdminLayout 兜底校验。注意它只拦明确未登录(无 token),不是无差别放行。
这个中间件的安全定位是快速路径优化,不是安全决策的权威来源。真正的安全校验在每个 admin server fn 开头的 get_current_admin_user().await? 里。即便中间件 fail-open 放行了一个非法请求,server fn 仍会拒绝它。
12.3 压缩层:默认关闭
压缩默认关闭(COMPRESSION_ALGORITHMS 默认 "off"),显式设为 "all" 或算法列表才启用。tower-http 的 DefaultPredicate 自动跳过已是压缩格式的响应:
/// CompressionLayer 使用 tower-http 的 DefaultPredicate,开箱即用即:
/// - 跳过 image/* content-type(WebP/PNG/JPEG/GIF 等已是压缩格式,再压浪费 CPU)
/// - 跳过 gRPC 与 text/event-stream(SSE)
/// - 跳过小于 32 字节的响应图片实际挂在 static_routes(无中间件),根本不经压缩层。
十三、三层缓存与统一失效
13.1 三层缓存
读路径用三层缓存,每层职责不同:
磁盘层——Dioxus IncrementalRenderer 把渲染好的完整 HTML 持久化到 static/<route>/。这是最外层缓存,命中时直接返回 HTML,连 Dioxus 渲染器都不进入。TTL 由 SSR_CACHE_SECS(默认 3600 秒)控制。
内存层——moka 维护 9 个独立 TTL 的缓存,用 CacheKey 枚举统一键空间:
每个缓存有独立的 TTL 和容量,按数据特性差异化配置:
| 缓存 | TTL | 容量 | 理由 |
|---|---|---|---|
| 文章列表 | 60s | 100 | 列表变动频繁,短 TTL 保证新文章快速出现 |
| 标签列表 | 300s | 50 | 标签变动少 |
| 单篇文章 | 600s | — | 正文不变(只有编辑才变),长 TTL |
| 文章统计 | 60s | — | 发布/删除后需快速刷新 |
| 评论列表 | 60s | — | 新评论需快速出现 |
| 待审核数 | 10s | — | 管理后台需要较实时数据 |
| 会话用户 | 300s | — | 短于 DB 会话过期时间(30 天) |
| 搜索结果 | 10s | — | 搜索词多样,长缓存无意义 |
观测层——全局世代号 GLOBAL_GENERATION,经 X-SSR-Generation 响应头暴露。它是纯观测用途的——看响应头的世代号判断 SSR 缓存是否新鲜。注释明确说明它不会实际失效 SSR 缓存。
13.2 物理删除目录绕过框架限制
Dioxus 0.7 只暴露 invalidate_after(ttl) 这种粗粒度兜底,没有按路由失效的公开 API。项目用一个不算优雅但可靠的绕法——物理删除目录:
// ssr_cache.rs: invalidate_ssr_route
// 物理删除 static/<route>/ 目录,绕过 Dioxus 无公开失效 API 的限制。
// route_cache_dir 过滤 ".." 防路径穿越。13.3 统一失效序列
所有写操作的缓存失效收敛到一个函数,保证不会漏掉某一层:
pub async 这里有个微妙设计:第 5 步 invalidate_ssr_all_public 删全部公开页 SSR 缓存,所以第 4 步逐 slug 的 invalidate_ssr_route 只是先行的定向清理,不改变最终失效结果。文档注释解释了这两步并存的原因:
注意:
invalidate_ssr_all_public会删除全部公开页 SSR 缓存,因此批量场景下逐 slug 的invalidate_ssr_route仅是先行的定向清理,不会改变最终失效结果。
定向失效是“让该路由更快可见“的优化(删掉旧文件后,下次请求会 MISS 并重新渲染),全量刷新才是正确性兜底(保证所有可能受影响的页面都失效)。两者并存,既保正确又缩短局部可见延迟。
MCP 写工具复用的也是这条完全一致的失效序列——AI 客户端发文章和人在后台发文章走的是同一条正确性路径。
用 Python 模拟一下上面描述的缓存失效序列——观察“定向失效只是优化、全量刷新才是兜底“的行为:
十四、安全纵深:CSRF、限流、XFF
14.1 CSRF:SameSite 不够,但要保守放行
基础防线是 SameSite=Lax cookie。但项目文档指出它有两个盲区:(1) 登录 CSRF(攻击者诱导受害者登录攻击者账号,Lax 不阻止“设置“ cookie);(2) 未来若出现 GET 化写接口,Lax 会在顶级 GET 导航时带 cookie。因此对所有写请求叠加 Origin 校验(回退 Referer)。
最有意思的是“拿不到本站 origin 时怎么办“的权衡。trusted_origin 的注释直白:
// 返回 None 表示无法确定本站 origin(此时放行,避免误杀——CSRF 漏判
// 是请求被拒,但拿不到本站 origin 时误杀合法请求代价更高,故保守放行)。清醒的风险计算:CSRF 漏判的最坏情况是一次非法写请求被放行(可被后续权限校验兜底),误杀的最坏情况是合法用户在配置不当时彻底无法使用站点(无解)。
配套的 warn_if_app_base_url_unset 在启动时打一条 WARN——生产环境未设 APP_BASE_URL 时,CSRF 会回退到 Host 头推导,反代后有被绕过的风险。但这个 WARN 不污染每请求路径——一次性 WARN,与每请求校验完全解耦。
14.2 限流:七个桶
7 个独立 DefaultKeyedRateLimiter:strict / upload / image / comment / code_exec / code_exec_daily / unknown。全部 env 可调。
code_exec_daily 用一个巧妙的技巧模拟日限额——governor 0.8 没有 Quota::per_day:
/// governor 0.8 无 Quota::per_day,用 with_period(24h) + allow_burst(daily)
/// 模拟:每 24h 补充 1 token、突发上限即日额度,等效于「每日最多 daily 次」。
static CODE_EXEC_DAILY_LIMITER: ..> = new;容器执行成本高(CPU/内存/启动延迟),需要硬性日上限防资源耗尽,与每秒突发限流构成双层。
unknown 桶 的存在动机是务实妥协。TRUSTED_PROXY_COUNT=0(默认)时,Dioxus server function 拿不到 TCP 对端地址,get_client_ip 返回 "unknown",所有匿名请求共享同一个严格桶(1 req/s, burst 5),正常用户的高频请求被误杀。于是备一个阈值更高的宽松桶兜底:
/// 当无法识别真实客户端 IP("unknown")时使用的宽松限流桶。
/// TRUSTED_PROXY_COUNT=0(默认)时,Dioxus server function 拿不到 TCP 对端地址,
/// get_client_ip 会返回 "unknown",导致所有匿名请求共享同一个严格桶
/// (1 req/s, burst 5),正常用户的高频请求被误杀。此桶阈值更高。
static UNKNOWN_BUCKET_LIMITER: ..> = new;14.3 IP 轮换攻击与 retain_recent GC
攻击者不断换 IP 制造新限流桶键,导致键空间无限膨胀。ensure_limiter_gc 惰性派生一个常驻 tokio 任务周期回收:
/// 七个 IP 键控限流器均为独立的 DefaultKeyedRateLimiter,没有集中状态表,故采用
/// 惰性启动:首个请求到达任一 check_* 时,经 Once 派生一个常驻 tokio 任务,按
/// limiter_gc_interval 周期对全部限流器调用 retain_recent,回收长时间未命中的键。
/// retain_recent 只丢弃与「新桶」不可区分的键(即限流窗口早已冷却),
/// 因此即便间隔较长,内存占用也只反映「最近活跃过且仍在限流窗口内」的 IP 集合。14.4 XFF 解析风险
ip_from_x_forwarded_for 的文档注释像一份安全告示:
/// XFF 头由客户端**可写**,本函数以 parts[len-1-trusted_proxy_count] 选取
/// 「可信代理链最左侧之外」的地址。该取值完全依赖 trusted_proxy_count 与真实
/// 代理跳数精确相等,一旦不符即可被滥用:
/// - 配得偏大:选中客户端伪造的地址——攻击者塞入伪 XFF 段绕限流。
/// - 配得偏小:选中中间代理 IP,所有用户共享一桶。十五、SQL 控制台:四道护栏 + Token 级匹配
管理员后台有一个 SQL 控制台(全读写),这是最高风险的功能。四道护栏:
- 高危语句闸门:
DROP DATABASE/DROP SCHEMA/CREATE DATABASE绝禁;DROP/TRUNCATE/ALTER需confirm_dangerous。 - 无 WHERE 拦截:
UPDATE/DELETE无selection拒绝。 - 查询超时:复用
STATEMENT_TIMEOUT_SECS(pool 层已注入 GUC)。 - 前端二次确认。
护栏 1 的实现有个关键技术细节——用 token 序列匹配而非 contains() 子串:
/// 注意:这里存的是「关键字序列」,由 is_absolutely_forbidden 做 token 级
/// 匹配——而非原始 contains() 子串。这样 DROP DATABASE(多空格)、
/// DROP\tDATABASE、DROP\nDATABASE 等绕过单空格子串的写法都能命中。
/// 关键背景:sqlparser 的 PostgreSqlDialect 无法解析 DROP/CREATE DATABASE,
/// 这类语句【没有 AST 兜底】,字符串预检是唯一防线,故必须 token 级鲁棒。
const ABSOLUTELY_FORBIDDEN: & = &;
为什么不用 sql.contains("drop database")?因为 drop database(多空格)、drop\tdatabase(制表符)、drop\ndatabase(换行)在子串匹配下会漏过,在 token 序列下全部命中。
为什么不用 sqlparser AST 解析?注释点明:sqlparser 的 PostgreSqlDialect 无法解析 DROP DATABASE / CREATE DATABASE——这类语句没有 AST 表示,字符串预检是唯一防线。
十六、备份恢复:签名校验与幂等恢复
16.1 备份
备份优先用 pg_dump(--clean --if-exists 使恢复幂等),不可用则回退纯 SQL:
const BACKUP_SIGNATURE: &str = "-- YGGDRASIL BACKUP v1";
const FILENAME_RE: &str = r"^[a-zA-Z0-9_.\-]+$"; // 文件名白名单防路径穿越--clean --if-exists 让 pg_dump 生成的脚本自带 DROP ... IF EXISTS,使恢复操作幂等——可以安全地反复执行。
模块文档有个诚实的兼容性提示:
兼容性提示:
--clean --if-exists是后加的备份参数。本修复之前生成的备份文件不含 DROP,对其执行恢复会在第一条「relation already exists」处中止并报失败(行为正确,但无法恢复数据)——需重新创建备份才能恢复。
16.2 恢复
恢复时三道防线:(1) 签名校验(拒绝非本系统文件);(2) 路径穿越防护(Component 校验规范化路径,拒绝 ../);(3) 二次确认。psql -v ON_ERROR_STOP=1 确保任何 SQL 错误立即中止并报失败。
长耗时操作走后台任务 + 进度轮询:create_backup 和 restore_backup 立即返回 task_id,实际工作在 tokio::spawn 里执行,前端通过 get_task_progress 轮询进度。
十七、代码沙箱:Docker 隔离执行
17.1 沙箱配置
文章里的 lang runnable 代码块和 /admin/runner 在 Docker 容器里执行。build_host_config 是一份保守的安全清单:
每一项配置都有踩坑背景:
mode=1777而非uid=1000,gid=1000——后者是 Docker 的 tmpfs 扩展选项,Podman 不支持(报unknown mount option)。mode=1777是 POSIX 标准,两个运行时都支持。/tmp必须exec——Docker tmpfs 默认noexec,编译型语言(go/rust)把编译产物落在/tmp后再 exec 会报EACCES。memory == memory_swap——设为相等值禁用 swap,防止容器把内存换到磁盘拖慢整个宿主机。- 刻意不设
nproc——RLIMIT_NPROC按 UID 计数,配合 non-root 用户会让容器初始exec /bin/sh直接EAGAIN。pids_limit已在 cgroup 层兜底限制进程数,nproc是冗余且有害的双重约束。 auto_remove: false——必须 false,否则容器退出时立即删除,来不及获取日志输出。
17.2 ContainerGuard:RAII 清理
容器清理用 RAII 模式。ContainerGuard 在创建容器时构造,Drop 时 fire-and-forget 删除容器:
3 次指数退避重试(200ms → 400ms → 800ms)抵抗 Docker daemon 瞬时故障。最终失败则把 container_id 记进 error 日志,便于运维手动 docker rm -f 清理。
17.3 流式输出:select! 并发陷阱
容器执行必须用 tokio::select! 并发等待容器结束和读取日志:
// 若先 await wait_container 再读日志流,wait 会阻塞到程序结束,
// 届时 attach stream 里已缓冲全部输出,stream.next() 一次性读完——
// 表现为"等完再一次性输出",流式名存实亡。
// 用 select! 让两条分支并发:
// - log_reader:持续读 attach stream,每块 chunk 立即 tx.send 推流
// - wait_with_timeout:等容器退出(带超时),退出后日志流自然结束如果没有并发,wait_container 会阻塞到程序自然结束,届时 attach stream 里已经缓冲了全部输出,stream.next() 会一次性快速读完——用户看到的是“等很久然后一次性蹦出所有输出“,而非边执行边显示。
17.4 执行流程:校验 → 入队 → 后台执行
代码执行有两条路径——轮询(start_exec)和流式(start_exec_stream),共用同一套校验逻辑:
// 公共校验:速率限制 + 语言白名单 + 源码大小
// admin 跳过速率限制
async 后台执行用信号量限制并发容器数:
pub static RUNNER_SEMAPHORE: =
new;
错误脱敏的设计也值得说——匿名可见的错误返回中文消息(“不支持该执行语言”、“源代码过大”),系统内部异常(容器拉起失败等)只记服务端日志,对前端返回统一的“系统暂时不可用“。
17.5 SSE 流式端点
GET /api/exec/stream?task_id=X 是 SSE 端点。它从 EXEC_STREAMS 取走 receiver(取即删,防重复连接),映射成三类事件:
pub async remove 而非 get——同一 task_id 只能连一次 SSE,防止多客户端或重连导致 receiver 被多次消费。15 秒 keep-alive 防止反向代理因空闲超时关闭连接。
17.6 亲手试一试:沙箱里跑代码
上面剖析的所有安全围栏——只读 rootfs、cap_drop ALL、pids_limit 64、非 root 用户——此刻都在为你服务。下面每一个代码块都标记了 runnable,点击运行按钮即可在 Docker 容器里实时执行,输出经 SSE 流式推送回页面。
Python —— 试试触发 pids_limit:
Node.js —— 验证网络隔离:
Go —— 编译型语言在 tmpfs 上的执行路径:
Rust —— 感受 select! 并发流式(与 17.3 节呼应):
十八、MCP 服务器:博客即 AI 知识库
18.1 rmcp 版本锁定
rmcp 锁在 =3.0.0-beta.3 而非稳定的 0.2.1:
稳定版缺少几项 spec 强制项:Origin 校验、MCP-Protocol-Version 头校验、请求体积上限(4 MiB)、无状态默认(SEP-2567)。用稳定版等于裸奔。transport-worker 也必须显式加——LocalSessionManager 无条件引用 transport::worker,但 transport-streamable-http-server feature 没有自动拉入它。
18.2 双白名单与开发期容错
挂载时同时配置 Origin 白名单与 Host 白名单,两者都源自 APP_BASE_URL。有个贴心的开发期容错:
// 开发期容错:APP_BASE_URL 未设或仍是 localhost 系(开发态)时,额外放行 0.0.0.0。
// 原因:dx serve --addr 0.0.0.0 转发到后端原生 server 时会把 Host 头改写成
// 0.0.0.0:<随机端口>(原生 server 端口每次重编译变化),rmcp 据此判 rebinding 攻击
// 一律 403——导致 MCP 在 dx 开发代理(:8080)下完全不可用。0.0.0.0 仅在本地开发
// 无攻击价值,故仅在未配置生产域名时放行;生产域名态保持严格。
let is_dev = base_url.as_deref
.and_then
.and_then
.map
.unwrap_or; // 未设 APP_BASE_URL 视为开发态
if is_dev && !allowed_hosts.iter.any 18.3 鉴权中间件
bearer → principal 的 axum from_fn 中间件:解析 Authorization: Bearer ygg_... → SHA-256 哈希 → 一次常量 SQL 查 → 注入 McpPrincipal。限流按 token_id 分桶(超限 429),last_used_at 用 60 秒节流避免每次请求都写库。
Token 不裸存明文,AES-GCM-256 加密,每次生成独立的 12 字节 OsRng nonce(nonce‖ct‖tag 一同 hex 存储)。
18.4 工具组合
7 组工具(read / posts / comments / tags / media / settings / runner)在同一个 YggMcpServer 上 impl 出公开 ToolRouter,server.rs 用 + 合并成单个 ServerHandler。作用域校验收敛到一个 require_scope,按 read < write < admin 三级偏序授权。
十九、一行配置治好 405
这是整个项目最具戏剧性的故障故事。
现象: 交叉编译部署后,登录请求返回 405 Method Not Allowed,响应头 allow: GET,HEAD。
根因: Dioxus 0.7.9 的 #[server(Fn, "/api")] 宏在 URL 末尾追加去冲突数字后缀:
xxh64( <key> + ":" + module_path!(), 0 )默认 <key> = CARGO_MANIFEST_DIR。生产部署里 server 和 WASM 在不同目录编译 → 后缀不同 → 浏览器请求 /api/login<容器hash>,server 只注册了 /api/login<宿主hash> → Dioxus SSR 兜底 GET handler 接管 → POST 撞 GET-only 路由 → 405。
诊断: 比对容器启动日志里 dioxus_server "Registering: POST /api/login<数字>"(server 端后缀)和浏览器 Network 的 POST /api/login<数字>(wasm 端后缀)。不一致即此问题。可进一步用 xxh64(候选路径 + ":" + "crate::module::path", 0) 精确验证哪个编译目录。
修复: 宏官方逃生舱(dioxus-fullstack-macro src/lib.rs:392)——编译时若设了 SERVER_FN_OVERRIDE_KEY,用它替代 CARGO_MANIFEST_DIR:
两个 Dockerfile.cross builder stage 都继承这个配置,/api 路径两端匹配。
二十、零 QEMU 交叉编译
Apple Silicon 上为 x86_64 服务器构建,QEMU 让 rustc SIGSEGV,Rosetta 在 Docker VMM 下不可用。Dockerfile.cross 用两个 native-arm64 builder:
# Stage A: 前端(glibc Trixie,native arm64)
# dx CLI 需 GLIBC_2.39;Bookworm 只有 2.36(dx --version 报 GLIBC_2.39 not found)
FROM --platform=$BUILDPLATFORM rust:1.96-trixie AS frontend
# Stage B: server(Alpine musl,native arm64)
FROM --platform=$BUILDPLATFORM rust:1.96-alpine3.22 AS server
RUN apk add --no-cache zig build-base musl-dev pkgconfig
RUN cargo install cargo-zigbuild --version 0.23.0 --locked
RUN cargo zigbuild --release --target x86_64-unknown-linux-musl \
--no-default-features --features server
# Stage C: linux/amd64 FROM scratch,拷贝两个产物
FROM scratch
COPY --from=frontend /build/dist/public /app/public/
COPY --from=server /build/server /app/yggdrasil不能合并为一个 stage:dx CLI 是 glibc 二进制,zig(apk 装的)动态链接 musl + LLVM 无法跨环境拷贝。
zig 的来源本身就是故事——容器内只能从 Alpine apk(TUNA 镜像)装:ziglang.org 中国不可达(~22KB/s + SSL reset),GitHub Releases 没传 zig Linux 二进制,中文镜像全返回 HTTP 200 的 HTML 错误页。
二十一、图片处理:WebP 管线
image crate 的 webp feature 被刻意排除——所有 WebP 编解码走 zenwebp:
// webp.rs 模块文档
// - image crate:通用格式(JPEG/PNG/GIF)解码、缩放、旋转、像素格式转换。
// 本项目特意禁用了 image 的 webp feature——它不支持编码,且解码能力有限。
// - zenwebp:专门负责 WebP 的有损编码与解码。上传原图存 uploads/,访问时按需转码 WebP 派生缓存写 uploads/.cache/。后台任务每小时清理,溢出防护值得品味——panic = "abort" 下 SystemTime 下溢不是“单次清理失败“而是整个进程崩溃:
// checked_sub 防 SystemTime 下溢 panic(极端配置/时钟回拨时 max_age 可能
// 超过 now 距 UNIX_EPOCH 的时长)。release panic="abort" 会直接崩溃整个进程。
const MAX_AGE_HOURS_CAP: u64 = 87_600; // 10 年
let cutoff = now.checked_sub.unwrap_or;二十二、后台自治任务
5 个后台循环各自独立、永不中断:
| 任务 | 频率 | 职责 |
|---|---|---|
session_cleanup | 每小时 | 删过期 session + invalidate_all 内存缓存 |
post_purge | 每天 | 按保留天数物理删除回收站过期文章 |
image_cache_cleanup | 每小时 | 超龄删除 + 超量 LRU 淘汰 uploads/.cache/ |
ip_purge | 周期 | 清理限流器冷却 IP 键 |
sysinfo | 0.5s | /admin/system 指标采样 |
错误处理模式一致——记录后继续 ticker.tick().await,绝不 break。post_purge 每次执行前重新读 settings 表——管理员调了保留天数不需要重启进程,下一轮就生效。
二十三、panic=abort 与工程纪律
panic = "abort" 下任何 panic 杀死整个进程。项目立下规矩:非测试代码禁止裸 unwrap()。所有 .expect() 必须带不变性推理——消息是写给未来读代码的人的不变式契约:
// val.max(1) 保证 ≥ 1,NonZeroU32::new 必然 Some
new.expect
// etag 仅含 ASCII hex 与双引号,必然是合法 HeaderValue
from_str
.expect
// 静态 302 响应必然构造成功(合法 status + 固定 header + 空 body)
.body.expect
// 启动期已校验
build_pg_config.expect豁免规则明确记录在 AGENTS.md 里:
LazyLock/OnceLock初始化编译期常量可用.expect()——运行一次,失败意味着源常量本身有错。- WASM 浏览器上下文(
web_sys::window())可用——缺失 window 证明运行在浏览器外,是部署 bug。 unreachable!()只允许在#[cfg(not(feature = "server"))]桩里。- 测试代码和构建工具二进制豁免。
在沙箱里体验一下 Result + ? 的正确错误处理——与 unwrap() 的对比:
二十四、前端架构:use_server_future 的 route-subscription gotcha
Dioxus 0.7 的 use_server_future(内部即 use_resource)只在闭包内读取的 signal 变化时才会重跑 future。它通过 ReactiveContext 追踪闭包执行期间的订阅。
但路由参数 slug 是一个普通 String prop,被 move 进闭包后成了冻结快照——读取它不会建立订阅。这导致一个隐蔽 bug:上/下一篇导航(同一路由变体间的 slug 变化)会复用组件实例、更新 props,却无法触发 future 重跑——URL 变了但内容不变,刷新才生效。
修复:在闭包内通过 router().current::<Route>() 读取当前 slug。current() 内部调用 subscribe_to_current_context(),在 use_server_future 的 ReactiveContext 中注册订阅;路由变化时订阅触发,future 自动重跑。
这段模块文档是整个项目最值得读的注释之一——它精确描述了 Dioxus 0.7 反应式系统的语义、一个常见的 gotcha、以及修复方案。AGENTS.md 强调“编辑 pages 前先读 post_detail.rs 头文档“。
二十五、再跑几个:完整语言矩阵
如果你还没点够运行按钮,这里还有一些跨语言的示例——每个都跑在前文剖析的 Docker 沙箱里:
Bun (TypeScript) —— MCP 鉴权流程模拟:
Python —— 模拟 SQL 控制台的 token 级匹配(与第十五章呼应):
尾声:诚实的技术债
Yggdrasil 是一个认真对待工程的项目。每个决策都留下了为什么这么做的注释。它有未完成的地方,但它从不假装它们不存在。
session_generation 失效只接了一半。 读侧完全接线(缓存命中后回查主键世代号逐出),但写侧无自动 bump。“封禁用户立即踢下线“事实上还没落地。
搜索走全表扫描。 ILIKE '%query%' 无法利用 trigram GIN 索引。模块文档第一行就写着“走全表扫描“,注释坦承“后续可升级为 tsvector 全文检索“。
.cargo/config.toml:5 注释漂移。 仍写 “server: host 上 cross build”,但 cross 工具早已废弃(现为 Dockerfile.cross 全容器内构建)。
这些债务被诚实地记录在代码注释里,而非假装不存在。搜索模块的文档第一行就写着“走全表扫描“;session_generation 的文档写着“bump 后该用户所有 session 应失效“但找不到 bump 的代码。读代码的人一眼就能看到差距在哪里。


