本站怎么用 serde
主线位置:三条主线在本站的落点。
site.json、前置元数据、server 函数参数和 LLM 响应走反序列化主线;LLM 请求和 server 函数返回值走序列化主线;缓冲主线只有一处派生出来却从没被调用的Deserialize(内部标签的LlmContentBlock)。
flowchart TB file["site.json"] -->|from_str| cfg["SiteConfig"] cfg -->|"to_value 后写回"| file md["Markdown 前置元数据"] -->|serde_yaml| meta["PostMetadata 等"] cli["wasm 客户端"] -->|"to_vec 参数"| srv["server 函数"] srv -->|"to_string 返回值"| cli site["站点"] -->|"请求 to_string"| api["LLM 服务"] api -->|"响应 from_str"| site
本页的站点源码链接都指向本站仓库的提交 89a61b1。那个提交的 Cargo.lock 锁的是 serde 1.0.228、serde_json 1.0.150、serde_yaml 0.9.34+deprecated、dioxus-fullstack 0.7.9、axum 0.8.9;依赖的源码按「crate + 版本 + 文件」引用。工作区只在根 Cargo.toml 里写了 serde = { version = "1.0", features = ["derive"] }、serde_json = "1.0"、serde_yaml = "0.9",除 widgets 自己写了 serde_yaml = "0.9" 外,各 crate 用 workspace = true 引用。
本页的实验把站点的类型定义原样抄进一个独立的 crate(只去掉注释),用本卷的版本(serde 1.0.229、serde_json 1.0.154、serde_yaml 0.9.34)在 M2 Max、macOS、rustc 1.99.0 上跑;同样的程序在站点锁的 1.0.228 / 1.0.150 下也跑了一遍,除了 server 函数实验里非法 UTF-8 那一行的列号(见升级一页),本页引用的输出两边逐字相同。
site.json:后加的字段都有缺省
站点配置是 assets/site.json,由 SiteConfig::from_file 用 serde_json::from_str 读成 SiteConfig:
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SiteConfig {
pub site_name: String,
pub site_description: String,
#[serde(default = "default_theme")]
pub theme: String,
#[serde(default = "default_layout")]
pub active_layout: String,
pub default_language: String,
pub author: String,
pub navigation: Vec<NavItem>,
#[serde(default)]
pub auth: AuthSettings,
#[serde(default)]
pub modules: HashMap<String, ModuleSettings>,
#[serde(default)]
pub moderation: ModerationSettings,
}
前五个没有属性的字段是必填的;后面加进来的字段(主题、布局、登录、模块开关、审核)都带 default,旧的 site.json 不改也能读。没有 deny_unknown_fields,所以多出来的键直接被丢掉:仓库里的 site.json 有一个 SiteConfig 没声明的 annotations,插件运行时移除前的配置还有 paths、plugins_lock,站点的测试 legacy_plugin_fields_are_ignored 靠的就是这一点。未知键怎么被丢掉(__Field::__ignore 加 IgnoredAny)见生成的 Deserialize。
default 有两种写法,生成的代码不同:default = "default_theme" 调那个函数,default 调字段类型的 Default::default()。容器上没有 default,字段上的 default 只管「这个键整个不在」的情况。把站点的这几个类型原样抄过来,喂几份不完整的配置:
const BASE: &str = r#""site_name":"X","site_description":"","default_language":"zh","author":"","navigation":[]"#;
fn try_parse(label: &str, extra: &str) {
let json = if extra.is_empty() { format!("{{{BASE}}}") } else { format!("{{{BASE},{extra}}}") };
match serde_json::from_str::<SiteConfig>(&json) {
Ok(c) => println!(
"{label}: ok auth={:?} modules={:?} moderation.on_llm_failure={:?}",
c.auth, c.modules, c.moderation.on_llm_failure
),
Err(e) => println!("{label}: Err({e})"),
}
}
fn main() {
let site_json = std::fs::read_to_string(std::env::args().nth(1).expect("path to site.json")).unwrap();
let c: SiteConfig = serde_json::from_str(&site_json).unwrap();
println!("assets/site.json: ok theme={} providers={:?}", c.theme, c.auth.providers);
try_parse("没有 auth", "");
try_parse("auth 只写 enabled", r#""auth":{"enabled":true}"#);
try_parse("modules.blog 空对象", r#""modules":{"blog":{}}"#);
try_parse("on_llm_failure 拼错", r#""moderation":{"on_llm_failure":"allow"}"#);
try_parse("thresholds 写成字符串", r#""moderation":{"thresholds":{"block_above":"0.9"}}"#);
println!("ModuleSettings::default().enabled = {}", ModuleSettings::default().enabled);
}
assets/site.json: ok theme=ocean providers=["github", "google", "discord", "twitter"]
没有 auth: ok auth=AuthSettings { enabled: false, providers: [] } modules={} moderation.on_llm_failure=Review
auth 只写 enabled: Err(missing field `providers` at line 1 column 114)
modules.blog 空对象: ok auth=AuthSettings { enabled: false, providers: [] } modules={"blog": ModuleSettings { enabled: true }} moderation.on_llm_failure=Review
on_llm_failure 拼错: Err(unknown variant `allow`, expected `review` or `reject` at line 1 column 129)
thresholds 写成字符串: Err(invalid type: string "0.9", expected f32 at line 1 column 138)
ModuleSettings::default().enabled = false
逐行看:
auth只写enabled:SiteConfig.auth上的default只在auth这个键缺席时生效。键在,就交给AuthSettings自己的Deserialize,而它的两个字段都没有default,providers一缺就是missing field。外层的default不会传到内层。报错的列号 114 是auth这个对象结尾的}:缺字段要等visit_map把对象读完才知道。机制见生成的 Deserialize 的「缺的字段」一节,坑本身见 missing field。modules.blog是空对象:ModuleSettings的enabled写的是default = "default_enabled",得到true;同一个类型派生的Default却给false。serde 只看属性,不看Default。站点代码里没有地方调ModuleSettings::default(),这处不一致目前不会表现出来。on_llm_failure拼错:LlmFailureAction是rename_all = "lowercase"的外部标签 enum,没认出的变体名报unknown variant,并列出全部合法的名字。站点的测试llm_failure_defaults_to_review就断言"allow"必须报错:这个 enum 故意没有「放行」,拼错也不能悄悄落到某个默认值上。thresholds写成字符串:"0.9"不是数字,f32的Visitor不收字符串,见 invalid type。
from_file 的错误经站点的 From<serde_json::Error> 变成 AppError::Validation("JSON parse error: ..."),serde_json 的原文(含行列号)保留在里面。
写回 site.json:经过 Value 的两处变化
管理后台保存审核设置时,write_moderation_settings_to_site_json 不经 SiteConfig 往返,而是把文件读成 serde_json::Value,只替换 moderation 子树,好保留 SiteConfig 没声明的键:
let mut value: serde_json::Value =
serde_json::from_str(&raw).map_err(|e| format!("site.json 不是合法 JSON:{}", e))?;
let moderation =
serde_json::to_value(settings).map_err(|e| format!("序列化 ModerationSettings 失败:{}", e))?;
value["moderation"] = moderation;
let pretty =
serde_json::to_string_pretty(&value).map_err(|e| format!("序列化 site.json 失败:{}", e))?;
未知的键确实保留了,但写回去的文件和原来的不一样。把这几行原样放进实验程序,对仓库里的 site.json 跑一次,阈值设成 flag_above: 0.4、block_above: 0.85:
let settings = ModerationSettings {
enabled: true,
llm_review: true,
on_llm_failure: LlmFailureAction::Reject,
thresholds: Some(ModerationThresholdsConfig { block_above: Some(0.85), flag_above: Some(0.4) }),
url_blocklist: vec![],
};
// ... 上面那几行
println!("{pretty}");
println!("--- 直接 to_string(&settings.thresholds):");
println!("{}", serde_json::to_string(&settings.thresholds).unwrap());
{
"active_layout": "classic",
"annotations": {
"blog": true,
"course": true,
"doc": true
},
"auth": {
"enabled": true,
"providers": [
"github",
"google",
"discord",
"twitter"
]
},
"author": "yuxuetr",
"default_language": "zh",
"moderation": {
"enabled": true,
"llm_review": true,
"on_llm_failure": "reject",
"thresholds": {
"block_above": 0.8500000238418579,
"flag_above": 0.4000000059604645
},
"url_blocklist": []
},
"navigation": [
{
"key": "nav-blog",
"route": "/blog"
},
{
"key": "nav-podcast",
"route": "/podcast"
},
{
"key": "nav-forum",
"route": "/topics"
}
],
"site_description": "专注 Rust 技术栈的学习与实战",
"site_name": "Rust in Everything",
"theme": "ocean"
}
--- 直接 to_string(&settings.thresholds):
{"block_above":0.85,"flag_above":0.4}
两处变化:
- 键按字母排序了。 原文件以
site_name开头,写回后以active_layout开头,annotations里的course、doc、blog也变成了blog、course、doc。没开preserve_order时,Value::Object里的Map底层是MapImpl,也就是BTreeMap,读进来的顺序当场就丢了;见 Value 的Map一节。保存一次之后,文件里几乎每个键都换了位置。 f32多出了尾数。to_value遇到f32走Value一侧的serialize_f32,最后到Number::from_f32:默认配置下Number只存f64,0.4f32被原样转成f64,也就是0.4000000059604645。直接to_string时,写出器的write_f32按f32的最短表示写,得到0.4。差别在Value这一跳,不在 JSON。
preserve_order 换过一次底层的 map,原因也是「没人维护了」Map 的两种实现由 map.rs 里一对 cfg 选出:默认 BTreeMap,开 preserve_order 时是 IndexMap。IndexMap 不是一开始就在的。serde-rs/json#451「Use IndexMap instead of LinkedHashMap」之前,preserve_order 用的是 linked-hash-map;这个 PR 首次发布于 serde_json 1.0.20,发布说明写的是:「Switch to indexmap as the implementation of the "preserver_order" feature, as linked-hash-map is no longer maintained」。PR 的说明只有一句「This was surprisingly easy :confused:.」。今天的 map.rs 里,公开的 Map 仍是包着 MapImpl 的一个 struct,换底层的只是那一对 type 别名。本站下面要讲的 serde_yaml,也是因为「no longer maintained」才需要考虑替换。
站点的作者已经注意到了第二处:这个函数的测试里有一句注释「f32 → JSON 经 f64 序列化会引入精度尾噪(0.4 ≠ 0.4000000059604645),按近似比较」(第 1212 行)。这和浮点往返那个坑不是一回事:那里是解析 f64 时差 1 ULP,这里是 f32 拓宽成 f64 后按 f64 打印。
0.4000000059604645 会不会让审核阈值变了?站点下一次 SiteConfig::from_file 读这份文件时,flag_above 还是 Some(0.4f32) 吗?答案见文末。
LLM 客户端:标签只用在请求一侧
crates/llm 同时实现 OpenAI 兼容和 Anthropic 兼容两种协议。和 serde 有关的类型分成两组:发出去的请求体,和收回来的响应。
请求:untagged 和内部标签都只派生了 Serialize
OpenAI 协议的 content 可以是字符串,也可以是内容块数组。站点用一个 untagged enum 表示「二选一」,内容块用内部标签(openai.rs 第 102 行起):
#[derive(Serialize)]
#[serde(untagged)]
enum OpenAiContent<'a> {
Text(&'a str),
Blocks(Vec<OpenAiContentBlock<'a>>),
}
#[derive(Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum OpenAiContentBlock<'a> {
Text { text: &'a str },
ImageUrl { image_url: OpenAiImageUrl<'a> },
}
Anthropic 一侧的 AnthropicContentBlock 和它的 source 也是 tag = "type",同样只派生 Serialize。用站点的转换函数 build_openai_content / build_anthropic_content 把一条纯文本消息和一条带图片的消息转出来:
openai 请求: {"model":"deepseek-chat","messages":[{"role":"user","content":"hi"},{"role":"user","content":[{"type":"text","text":"look"},{"type":"image_url","image_url":{"url":"https://img.example/1.png"}},{"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0K"}}]}]}
anthropic 请求: {"model":"deepseek-chat","max_tokens":1024,"messages":[{"role":"user","content":[{"type":"text","text":"look"},{"type":"image","source":{"type":"url","url":"https://img.example/1.png"}},{"type":"image","source":{"type":"base64","media_type":"image/png","data":"iVBORw0K"}}]}]}
序列化一侧的 untagged 没有缓冲,也不会「不匹配」:值已经是某个变体了,生成的 serialize 只是对内层的值调一次 serialize,外面不包任何东西。内部标签的 struct 变体走 serialize_struct,先写标签再写字段,所以 "type" 总在第一个。两者生成什么见生成的 Serialize 的「另外三种表示调什么」。
untagged 的坑(data did not match any variant of untagged enum)只在反序列化时出现,而站点从来不反序列化这个类型。它也反序列化不了,因为没派生,借用的 &'a str 字段也让它不适合从响应里读。试着 from_str 一个,rustc 直接拒绝:
error[E0277]: the trait bound `OpenAiContent<'_>: serde::Deserialize<'de>` is not satisfied
--> src/bin/llm_no_de.rs:7:27
|
7 | let _c: OpenAiContent = serde_json::from_str("\"hi\"").unwrap();
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ unsatisfied trait bound
如果哪天要反过来读这种消息(比如写一个假的 OpenAI 服务做测试),把它改成拥有所有权的版本再派生 Deserialize,坏输入的报错就成了那一句话。下面四份输入的最后两份,一份是数组里出现了没声明的块类型,一份是 content 为 null:
ok: WireMessage { role: "user", content: Text("hi") }
ok: WireMessage { role: "user", content: Blocks([Text { text: "look" }, ImageUrl { image_url: OpenAiImageUrl { url: "x" } }]) }
Err: data did not match any variant of untagged enum OpenAiContent at line 1 column 97
Err: data did not match any variant of untagged enum OpenAiContent at line 1 column 30
第三行真正的原因是第二个块的 unknown variant \input_audio``,被 untagged 吞掉了。为什么只剩一句话,见 untagged。
lib.rs 里协议中立的 LlmContentBlock 倒是派生了 Deserialize,内部标签,所以它会走缓冲主线(TaggedContentVisitor 先把整个对象读成 Content)。但站点只构造它、序列化它(测试里),没有一处从 JSON 读它。直接调它的 Deserialize 看看:
LlmContentBlock {"url":"https://img.example/1.png","type":"image_url"}: ok ImageUrl { url: "https://img.example/1.png" }
LlmContentBlock {"type":"image","source":{"type":"url","url":"x"}}: Err(unknown variant `image`, expected one of `text`, `image_url`, `image_base64` at line 1 column 15)
LlmContentBlock {"type":"text"}: Err(missing field `text`)
标签不在第一个键也能读(第一行);最后一行的 missing field 没有行列号,因为它是在重放 Content 时报的,那时已经没有输入位置了,见 Content:缓冲的代价。这些只在将来有人用到这个 impl 时才相关。
响应:普通 struct 加 kind: String
读响应的类型一个 enum 都没有。OpenAI 的 ChatResponse 和 Anthropic 的 MessagesResponse 都是每个字段 #[serde(default)] 的 struct,内容块的类型读成字符串:
#[derive(Deserialize)]
struct ContentBlock {
#[serde(default, rename = "type")]
kind: String,
#[serde(default)]
text: String,
}
然后在代码里 filter(|b| b.kind == "text")。如果写成 #[serde(tag = "type")] enum,服务端多一种块(thinking、tool_use)就会报 unknown variant,整个响应读不出来;读成字符串则只是多一个被过滤掉的块。用站点的类型读几份响应:
openai 响应 正常: ok content=Some("OK")
openai 响应 多出 reasoning_content: ok content=Some("OK")
openai 响应 content 为 null: Err(invalid type: null, expected a string at line 1 column 57)
openai 响应 200 里 error 是字符串: Err(invalid type: string "quota exceeded", expected struct ApiError at line 1 column 25)
anthropic 响应 带 thinking 块: ok answer=["OK"]
多出的字段、多出的块类型都没问题。剩下两种会失败:
"content": null。ChoiceMessage的content: String带default,但default只管键缺席,键在、值是null时照样交给String的Deserialize,String不收null。要同时容忍缺席和null,类型得是Option<String>。只要某个兼容服务在content里回null,这条回复就整个读不出来。error是字符串。ApiError是 struct,"error": "..."报invalid type。这只影响 2xx 的响应:非 2xx 时chat在解析那一行之前就按状态码返回了。
两种失败都被 chat 包成「LLM 响应不是合法 JSON: invalid type: ... (body=...)」。JSON 其实是合法的,是形状不对;serde_json 的原文还在后面,看日志时以它为准。审核流水线拿到这个错误后按 on_llm_failure 处理(settings.rs 的注释:「LLM 调用失败 / 回复无法解析时怎么处理这条提交。默认送人工复核」)。
前置元数据:serde_yaml
博客、文档、课程、案例、播客的元数据都是 YAML:Markdown 文件里的放在开头两个 --- 之间,课程、章节、案例、播客还有单独的 .yaml 文件。站点有八处 serde_yaml::from_str,类型和失败时的处理各不相同:
| 位置 | 类型 | 解析失败时 |
|---|---|---|
widgets/src/mdx.rs 的 parse_mdx | PostMetadata | 记 warn,整块元数据用 Default |
docs/src/server.rs | DocMeta | 记 warn,用 Default |
course/src/server.rs 第 533 行、第 877 行 | LessonFrontmatter、ChapterMeta | unwrap_or_default(),不记日志 |
course/src/server.rs 第 953 行 | CourseMeta | 记 error,课程下线 |
cases/src/server.rs | CaseMeta | 返回 Err(String) |
podcast/src/server.rs | EpisodeMeta | .ok()?,这一期不出现 |
board/src/server.rs | FrontMatter | unwrap_or_default() |
除了 CourseMeta、CaseMeta 和 EpisodeMeta,失败都会退到 Default:页面照样出来,只是标题、描述、排序全没了。serde 的报错是整块的,一个字段类型不对,整个 struct 都读不出来。
PostMetadata:容器上的 default 加自定义函数
PostMetadata 是写得最宽容的一个:
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct PostMetadata {
/// 缺省为空(Z3):之前缺 `title` 会让整块 frontmatter 解析失败。
#[serde(deserialize_with = "de_text")]
pub title: String,
/// `summary` / `excerpt` 是其他博客系统的同义字段(Z3)。
#[serde(alias = "summary", alias = "excerpt", deserialize_with = "de_opt_text")]
pub description: Option<String>,
// ...
pub image: Option<String>,
// ...
#[serde(deserialize_with = "de_list")]
pub tags: Vec<String>,
pub draft: bool,
}
/// YAML 标量(字符串 / 数字 / 布尔)转成字符串;`title: 2024` 这类也能读。
fn yaml_scalar(v: serde_yaml::Value) -> Option<String> {
match v {
serde_yaml::Value::String(s) => Some(s),
serde_yaml::Value::Number(n) => Some(n.to_string()),
serde_yaml::Value::Bool(b) => Some(b.to_string()),
_ => None,
}
}
de_text、de_opt_text、de_list 都先读成 serde_yaml::Value,再挑出标量。deserialize_with 绕开了字段类型自己的 Deserialize,所以 title: 2024 这种 YAML 认作整数的值也能当字符串用,invalid type 在这些字段上不会发生。
容器上的 #[serde(default)] 在这里是必需的,不是多余的。带 deserialize_with 的字段缺席时不走 missing_field,即使类型是 Option 也直接报错(生成的 Deserialize 的「default:容器与字段」一节、issue #2878)。把容器上的 default 去掉,只留 title 和 description 两个字段对照:
#[derive(Debug, Deserialize)]
pub struct PostMetadataNoContainerDefault {
#[serde(deserialize_with = "de_text")]
pub title: String,
#[serde(alias = "summary", alias = "excerpt", deserialize_with = "de_opt_text")]
pub description: Option<String>,
}
读 title: 没有 description:
去掉容器 default: Err(missing field `description`)
有了容器 default,缺的字段从 PostMetadata::default() 里取,就没有这个问题。代价是 Default::default() 每次都会被调用,字段齐全也调。
再看几份真实会写出来的元数据:
post("标量都能读", "title: 2024\nsummary: 一句话\ntags: rust\nkeywords: [serde, 1.0]\ndraft: true\n");
post("冒号没加引号", "title: 升级\ndescription: serde 1.0.229: 去掉 ?\n");
post("image 写成列表", "title: 封面\nimage: [a.png]\n");
PostMetadata 标量都能读: ok PostMetadata { title: "2024", description: Some("一句话"), keywords: Some("serde, 1.0"), image: None, author: None, canonical: None, date: None, updated: None, tags: ["rust"], draft: true }
PostMetadata 冒号没加引号: Err(mapping values are not allowed in this context at line 2 column 27)
PostMetadata image 写成列表: Err(image: invalid type: sequence, expected a string at line 2 column 8)
第二行是 YAML 语法错误,在 serde 之前就失败了:未加引号的值里出现「冒号加空格」。本卷每一页的 description 一旦含有这个组合都要加双引号,原因就在这里。第三行 image 没有 deserialize_with,Option<String> 收到列表就报 invalid type;serde_yaml 的报错前面带字段路径 image:,比 serde_json 的报错多一层定位。
DocMeta:每个字段 default,但没有自定义函数
文档页(包括本卷)的元数据读成 DocMeta。它每个字段都有 default,但没有 deserialize_with,每个字段都按自己的类型读;source 是 DocSource,两个必填字段,crate 是关键字所以 rename:
/// frontmatter `source: { crate: tokio, version: "1.54.0" }`
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct DocSource {
#[serde(rename = "crate")]
pub krate: String,
/// 不加引号也行:serde_yaml 读进 String 时保留原文,`1.10` 不会变成 `1.1`
pub version: String,
}
doc("keywords 写成字符串", "title: 读法\nkeywords: serde\n");
doc("sidebar_position 带引号", "title: 读法\nsidebar_position: \"3\"\n");
doc("version 不加引号", "title: x\nsource: { crate: bytes, version: 1.10 }\n");
doc("version 1.0.229", "title: x\nsource: { crate: serde, version: 1.0.229 }\n");
doc("source 缺 version", "title: x\nsource: { crate: serde }\n");
// 最后一行:读本卷「怎么读这一卷」那一页真实的前置元数据
DocMeta keywords 写成字符串: Err(keywords: invalid type: string "serde", expected a sequence at line 2 column 11)
DocMeta sidebar_position 带引号: Err(sidebar_position: invalid type: string "3", expected i32 at line 2 column 19)
DocMeta version 不加引号: ok title="x" keywords=[] sidebar_position=None source=Some(DocSource { krate: "bytes", version: "1.10" })
DocMeta version 1.0.229: ok title="x" keywords=[] sidebar_position=None source=Some(DocSource { krate: "serde", version: "1.0.229" })
DocMeta source 缺 version: Err(source: missing field `version` at line 2 column 9)
本卷 how-to-read: title="怎么读这一卷" sidebar_position=Some(1) source=Some(DocSource { krate: "serde", version: "1.0.229" }) keywords=8
三个 Err 都会让整页的元数据退成 Default:标题、描述、侧栏位置一起丢失。DocSource 那条注释说的是真的:version: 1.10 在 YAML 里是浮点数,但 String 的 Deserialize 调的是 deserialize_string,serde_yaml 0.9.34 的 deserialize_str(src/de.rs 第 1472 行)遇到标量直接把原文交给 visit_str,不做类型解析,所以得到 "1.10"。这是 serde_yaml 自己的选择,换一个 YAML 库不一定这样。
用户内容也会进 serde_yaml
Markdown 组件对 untrusted: true 的内容(评论、论坛话题,见 comment.rs)同样调 parse_mdx,所以一条以 --- 开头的评论,中间那段会交给 serde_yaml。用三份恶意的 YAML 试 PostMetadata(--release):
parse("自引用别名", "title: &a [*a]\n");
// a 是 10 个 x,b 是 10 个 *a,……,j 是 10 个 *i;title 引用 j
let mut laughs = String::from("a: &a [x,x,x,x,x,x,x,x,x,x]\n");
for i in 1..10 {
let p = (b'a' + i - 1) as char;
let c = (b'a' + i) as char;
laughs.push_str(&format!("{c}: &{c} [*{p},*{p},*{p},*{p},*{p},*{p},*{p},*{p},*{p},*{p}]\n"));
}
laughs.push_str("title: *j\n");
parse("十层别名展开", &laughs);
parse("嵌套 200 层", &format!("title: {}{}\n", "[".repeat(200), "]".repeat(200)));
自引用别名: Err(recursion limit exceeded at line 1 column 8) (0 ms)
十层别名展开: Err(repetition limit exceeded) (10 ms)
嵌套 200 层: Err(recursion limit exceeded at line 1 column 135) (0 ms)
serde_yaml 0.9.34 自己有两道限制:嵌套深度从 remaining_depth: 128 往下减(src/de.rs 第 112、133 行),别名展开的跳转次数不超过事件数的 100 倍(src/de.rs 第 479 行)。十层、每层十倍的别名(展开后 10¹⁰ 个元素)在 10 ms 内被拒绝。失败之后 parse_mdx 退到 Default,第二个 --- 之后的内容照常渲染。
「+deprecated」是什么意思
Cargo.lock 里的版本号是 0.9.34+deprecated。+ 后面是 SemVer 的构建元数据,比较版本时被忽略,所以 serde_yaml = "0.9" 照样解析到它,本卷实验 crate 写的 =0.9.34 也一样。serde_yaml 的 README 第 10 行和 src/lib.rs 第 10 行写着:
Rust library for using the [Serde] serialization framework with data in [YAML]
file format. _(This project is no longer maintained.)_
它的源码里没有任何 #[deprecated] 属性,编译不会出警告。「deprecated」只是一个声明:不会再有新版本,bug 和安全问题都不会再修。serde_yaml 依赖的 unsafe-libyaml 0.2.11 是 libyaml 的 C 代码机器翻译成的 unsafe Rust。用本机的 advisory-db 副本(最后提交 2026-10-20)离线跑 cargo audit --no-fetch,和 YAML 有关的只有一条:
Crate: unsafe-libyaml
Version: 0.2.11
Warning: unmaintained
Title: unsafe-libyaml is unmaintained
Date: 2024-03-25
ID: RUSTSEC-2024-0449
URL: https://rustsec.org/advisories/RUSTSEC-2024-0449
serde_yaml 自己只有一条 2018 年的 RUSTSEC-2018-0005(别名递归导致 abort,>= 0.8.4 已修),0.9.34 不受影响。
对本站来说,风险是「将来出了问题没人修」,不是「现在有已知漏洞」。替换时要注意站点已经依赖了 serde_yaml 的三样具体行为:mdx.rs 的 yaml_scalar、de_opt_text、de_list 直接用 serde_yaml::Value;DocSource.version 依赖 deserialize_str 不做类型解析;From<serde_yaml::Error> 把错误类型写进了 app-core。那条 advisory 建议的替代品是 saphyr(配 serde-saphyr)和 noyalib;serde_yaml 的分叉 serde_yaml_ng 仍然用 unsafe-libyaml。这几个库本机都没有,本页没有验证它们的行为。
server 函数:两端都是 serde_json
本站 72 个 server 函数全是 #[post("/api/...")],路径里没有参数、没有 query,所有参数都经请求体走。dioxus 怎么把它们注册成路由、请求体超限时为什么是 500,axum 卷的 dioxus 生成的 Router 和 bytes 卷的 server 函数的请求体 讲过。serde 一侧是这样的:
dioxus-fullstack-macro 0.7.9
src/lib.rs第 342 行起,把参数打包成一个泛型结构体,字段名就是参数名:#[derive(serde::Serialize, serde::Deserialize)] #[serde(crate = "serde")] struct ___Body_Serialize___< #(#tys,)* > { #(#names,)* }客户端(wasm):dioxus-fullstack 0.7.9
src/client.rs第 306 行的send_json用serde_json::to_vec;结果是{}或null时发空 body。服务端:
src/magic.rs第 523 行起的ExtractRequest实现,空 body 换成"{}",然后serde_json::from_slice::<In>,其中In: DeserializeOwned(第 525 行)。返回值:
src/magic.rs第 630 行serde_json::to_string(&res).unwrap(),客户端用serde_json::from_slice::<T>读,空 body 当成null。
按 upload_image(name: String, data_base64: String) 的形状手写出那个结构体,模拟两端:
#[derive(Serialize, Deserialize)]
#[serde(crate = "serde")]
#[allow(non_camel_case_types)]
struct ___Body_Serialize___<__Ty0, __Ty1> {
name: __Ty0,
data_base64: __Ty1,
}
// 服务端:magic.rs 第 542 行起的那几行
fn server_extract(bytes: &[u8]) -> Result<(String, String), serde_json::Error> {
let as_str = String::from_utf8_lossy(bytes);
let bytes = if as_str.is_empty() { "{}".as_bytes() } else { bytes };
serde_json::from_slice::<___Body_Serialize___<String, String>>(bytes)
.map(|data| (data.name, data.data_base64))
}
客户端发出: {"name":"a.png","data_base64":"iVBORw0K"}
服务端 正常: ok name="a.png" data_base64="iVBORw0K"
服务端 空 body: Err(missing field `name` at line 1 column 2)
服务端 多一个字段: ok name="a.png" data_base64="x"
服务端 少一个字段: Err(missing field `data_base64` at line 1 column 16)
服务端 类型不对: Err(invalid type: integer `123`, expected a string at line 1 column 33)
服务端 字符串里有非法 UTF-8: Err(invalid unicode code point at line 1 column 11)
返回 (): null
返回 Option::<String>::None: null
客户端把 null 读成 (): ()
几点:
- 参数结构体没有
deny_unknown_fields,多传的参数被忽略;少传的参数是missing field,Option参数缺席时是None。客户端和服务端从同一份源码、同一个Cargo.lock编译,正常情况下字段名不会对不上;可能对不上的是部署切换时,浏览器里还在跑的旧 wasm 调新的服务端。 In: DeserializeOwned让 server 函数的参数不能是&str这类借用类型,借用字符串那个坑在这里根本写不出来,编译期就拒绝了。- 解析失败时,
magic.rs把错误交给ServerFnError::from:dioxus-fullstack-core 0.7.9src/error.rs第 129 行把它变成ServerFnError::Deserialization,第 150 行把这个变体映射到 500,Display是第 66 行的"error deserializing server function results: {0}"。按这几行读,参数不对时客户端收到的是 500 和一句「results」反序列化失败,而不是 axum 的Json提取器那样的 400 / 422(见 body 提取器)。 - 两端都是 Rust 的 serde_json,数字不经过 JavaScript,
i64/u64在客户端也是精确的。 - 返回值
serde_json::to_string(&res).unwrap()在序列化失败时会 panic。serde_json 序列化失败的常见原因是 map 的键不是字符串(key must be a string);本站 server 函数的返回类型里没有 map,派生类型里仅有的两个 map 字段HashMap<String, ModuleSettings>和BTreeMap<String, u64>键都是String。
axum 提取器与支付回调
站点直接写的 axum 提取器只有两个,都收成 HashMap<String, String>:OAuth 回调的 Query、支付宝回调的 Form。两者都经 serde_urlencoded 反序列化,机制在 axum 卷的 Path、Query 与请求元数据和 body 提取器;为什么用 HashMap 而不是带必填字段的 struct,见路由与静态资源。HashMap<String, String> 什么键都收,不会产生 serde 的错误。
微信支付和 Stripe 的回调先按原始字节验签,再 serde_json::from_str 成 Value,用下标取字段,比如 Stripe 的 session["amount_total"].as_i64().unwrap_or(-1)。金额是以分为单位的整数,Value 里存成 u64 / i64,不经过浮点。这条路上没有派生类型,也就没有 missing field:缺的字段变成 Value::Null,as_i64 返回 None,由站点自己决定怎么处理。
常见坑对照
| 坑 | 本站相关的代码 | 会不会遇到 | 依据 |
|---|---|---|---|
| untagged 不匹配 | OpenAiContent | 不会 | 只派生 Serialize,rustc 拒绝 from_str |
| invalid type | site.json、DocMeta、LLM 响应 | 会 | 上面的输出:"0.9"、keywords: serde、content: null |
| missing field | AuthSettings、DocSource、server 函数参数 | 会 | auth 只写 enabled;PostMetadata 靠容器 default 避开了 deserialize_with 那一种 |
| 借用字符串 | 请求类型里的 &'a str | 不会 | 只序列化;server 函数参数要求 DeserializeOwned |
| flatten 与 deny_unknown_fields | 无 | 不会 | crates/ 里没有 flatten,也没有 deny_unknown_fields |
| u128 | 无 | 不会 | 没有 u128 / i128 字段 |
| 非自描述格式 | 无 | 不会 | 只用 JSON 和 YAML;dioxus 的 CborEncoding 站点没有选 |
| key must be a string | 两个 map 字段 | 不会 | 键都是 String |
| 递归深度 | 用户评论的前置元数据 | 会被拒绝,不会崩 | serde_yaml 的两道限制,上面的输出 |
| Rc 没实现 Serialize | 无 | 不会 | 派生类型里没有 Rc / Arc 字段 |
| from_reader 慢 | 无 | 不会 | 全部 from_str / from_slice,没有 from_reader |
| 浮点往返 | 审核阈值 | 相关但不同 | 写回时 f32 拓宽成 f64,读回仍相等 |
| 声明的长度不对 | 无 | 不会 | 没有手写的 Serialize |
| 两个 serde 版本 | 无 | 不会 | Cargo.lock 里只有一个 serde |
可以改的地方(未实施)
下面是按上面的实验能得出的建议,站点在 89a61b1 上都没有做:
AuthSettings加容器级#[serde(default)],让"auth": {"enabled": true}也能读。ChoiceMessage.content改成Option<String>(或用deserialize_with把null当空串),ApiError容忍字符串形式。DocMeta的keywords学PostMetadata用de_list,单个字符串不再让整页元数据失效。- 课程的
unwrap_or_default()至少记一条日志,和docs、mdx.rs一致。 - 写回
site.json时想保持键的顺序,可以给 serde_json 开preserve_order。它是全图合并的 feature,会让所有Value的Map都变成IndexMap(Value从 32 字节变成 72 字节,见 Value),要先评估。 - 换掉 serde_yaml 之前,先给上面那三样行为(
Value的用法、1.10保留原文、错误类型)写测试。
动脑筋答案
写回的文件再读成 SiteConfig(同一个程序的最后几行):
let back: SiteConfig = serde_json::from_str(&pretty).unwrap();
let t = back.moderation.thresholds.unwrap();
println!("flag_above == Some(0.4f32): {}", t.flag_above == Some(0.4f32));
println!("block_above == Some(0.85f32): {}", t.block_above == Some(0.85f32));
--- 写回的文件再读成 SiteConfig:
flag_above == Some(0.4f32): true
block_above == Some(0.85f32): true
都是 true。0.4000000059604645 是 0.4f32 转成 f64 之后的最短十进制表示,把它解析成 f32 时舍入回同一个数。阈值没有变,变的只是文件里的文本;所以站点测试用近似比较而不是字符串比较,也是对的。
小结
- 本站的 serde 用法全是 derive,格式只有 JSON 和 YAML,没有
flatten、deny_unknown_fields、borrow,也没有手写 impl。 site.json靠字段级default兼容旧配置、靠「未知键丢弃」兼容多余字段;外层的default不传到内层,AuthSettings缺providers仍报missing field。- 经
Value写回site.json会按字母重排所有键,f32阈值多出f64尾数;读回后数值不变。 - untagged 和内部标签只用在 LLM 请求一侧(只有
Serialize),untagged 不匹配和Content的坑碰不到;响应用kind: String而不是标签 enum,容忍新块类型,但content: null会报invalid type。 PostMetadata用容器default加serde_yaml::Value的自定义函数,绕开了invalid type和deserialize_with的missing field;DocMeta没有,一个字段写错整页元数据就退回Default。- serde_yaml 0.9.34 的「+deprecated」是构建元数据,表示不再维护,不产生编译警告;
cargo audit只报出unsafe-libyaml无人维护。用户评论的前置元数据也进 serde_yaml,深度和别名展开都有上限。 - server 函数两端都是 serde_json:参数打包成以参数名为字段的结构体,要求
DeserializeOwned;解析失败按 dioxus 的代码是 500。