Rust in Everything
基于 serde 1.0.229

unknown field kind

主线位置:缓冲主线。带 flatten 的结构体把认不出的键值都缓冲进 __collect,循环结束后交给每个 flatten 字段;外层的 deny_unknown_fields 最后看 __collect 里还剩没剩东西。机制见 flatten,这一页只讲症状和修法。

flowchart TB
  coll["__collect 缓冲"] --> kind{"flatten 的是什么"}
  kind -->|struct| take["认领后删掉"]
  kind -->|"map 或内部标签"| keep["只借不删"]
  take --> remain{"还剩键吗"}
  keep --> remain
  remain -->|剩| err["unknown field"]
  remain -->|不剩| ok["Ok"]

本页实验在 M2 Max、macOS、rustc 1.99.0 上运行,依赖 serde = "=1.0.229"(derive)、serde_json = "=1.0.154"。

现象

一个块有 id,其余字段由内部标签的 Kind 决定。外层加了 deny_unknown_fields,想拒绝拼错的键。输入完全合法,却报错。src/bin/flatten_deny.rs:

#[derive(Deserialize, Debug)]
#[serde(tag = "kind")]
enum Kind {
  Image { url: String },
  Text { body: String },
}

#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
struct Block {
  id: u32,
  #[serde(flatten)]
  kind: Kind,
}
tagged enum  {"id":1,"kind":"Image","url":"u"}
  Err(unknown field `kind` at line 1 column 33)

kind 是标签,Kind 明明用了它。把 kind 从输入里删掉(src/bin/flatten_more.rs,同样的 Block):

no tag         {"id":1,"url":"u"}
  Err(missing field `kind` at line 1 column 18)

有标签报多余,没标签报缺失:这个结构体没有能通过的输入。flatten_deny.rs 里另外几组(Meta 只有一个 author: String 字段):

#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
struct Plain {
  title: String,
  author: String,
}

#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
struct WithStruct {
  title: String,
  #[serde(flatten)]
  meta: Meta,
}

#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
struct WithMap {
  title: String,
  #[serde(flatten)]
  extra: HashMap<String, u32>,
}

#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
struct StrictMeta {
  author: String,
}

#[derive(Deserialize, Debug)]
struct InnerDeny {
  title: String,
  #[serde(flatten)]
  meta: StrictMeta,
}
plain        {"title":"t","author":"a","x":0}
  Err(unknown field `x`, expected `title` or `author` at line 1 column 29)
struct       {"title":"t","author":"a"}
  Ok(WithStruct { title: "t", meta: Meta { author: "a" } })
struct       {"title":"t","author":"a","x":0}
  Err(unknown field `x` at line 1 column 32)
map          {"title":"t"}
  Ok(WithMap { title: "t", extra: {} })
map          {"title":"t","n":1}
  Err(unknown field `n` at line 1 column 19)
inner deny   {"title":"t","author":"a","x":0}
  Ok(InnerDeny { title: "t", meta: StrictMeta { author: "a" } })

四种行为:

flatten 的是deny_unknown_fields 写在结果
struct外层按直觉工作
map外层map 里只要有一项就报错,只能是空的
内部标签 enum外层合法输入也报错,报的是标签名
struct内层永远不触发

报错的文字也和普通的不一样:plain 那行是 unknown field `x`, expected `title` or `author` ,位置在 "x" 后面;有 flatten 时只有 unknown field `x` ,没有候选列表,位置是对象末尾。看到不带 expected 的 unknown field,基本可以确定是 flatten 这一条路报的。

出处

普通结构体在读到认不出的键时,当场调 Error::unknown_field,带上字段表。有 flatten 时认不出的键不能当场拒绝,它可能属于某个 flatten 字段,所以先进 __collect;所有 flatten 字段取完之后,collected_deny_unknown_fields 找第一项还没被删掉的:

            if let _serde::#private::Some(_serde::#private::Some((__key, _))) =
                __collect.into_iter().filter(_serde::#private::Option::is_some).next()
            {
                if let _serde::#private::Some(__key) = _serde::#private::de::content_as_str(&__key) {
                    return _serde::#private::Err(
                        _serde::de::Error::custom(format_args!("unknown field `{}`", &__key)));

这里用的是 custom,没有字段表可给,所以没有 expected。检查的是「还在不在」,而 FlatMapDeserializer 对不同的字段类型取法不同:struct 经 FlatStructAccess 认领并 take();map 经 FlatMapAccess 只借用、不删;内部标签 enum 调 deserialize_any,被转给 deserialize_map,同样只借不删。kind、url 都还在 __collect 里,报的是第一项 kind。内层的 deny_unknown_fields 不触发,是因为 FlatStructAccess 只把内层字段表里有的键交给它。为什么 map 只借不删,见 flatten 的「只借不删」和那里关于 #1299 的冷知识。

📝NOTE
没有蠢问题

问: 这个组合有一种情况永远失败,为什么 serde_derive 不在编译期报错?

答: 因为宏看不到。derive 处理 Block 时,kind: Kind 只是一串记号,Kind 是 struct、map 还是内部标签 enum,要等类型检查之后才知道,而那时宏早就展开完了。check.rs 里和 flatten 有关的检查(check_flatten_field)只拦得住看语法就能判断的情况:tuple struct、newtype struct 上的 flatten。它也不能一刀切地禁止这个组合,因为 flatten struct 的情况能正常工作;issue #2384 就是请求把「简单情况能用」写进文档。内部标签这一种在 issue #1358(2018-08,serde 1.0.70)里就被报告过,原话是「This panics with „unknown field type“」,「it can't make its mind if it wants type or not」,到 1.0.229 仍是 open。

另一个静默的:flatten 的 Option

deny_unknown_fields 是「不该报的报了」,#[serde(flatten)] meta: Option<Meta> 是反过来:该报的不报。FlatMapDeserializer 回答不了「这里是不是 null」,于是用 Meta 试一次,任何错误都当成 None(__private_visit_untagged_option,见 flatten 的「Option:失败就当 None」)。src/bin/flatten_fix.rs 的一部分:

#[derive(Deserialize, Debug)]
struct Meta {
  author: String,
  views: u8,
}

#[derive(Deserialize, Debug)]
struct MaybeMeta {
  title: String,
  #[serde(flatten)]
  meta: Option<Meta>,
}
MaybeMeta  {"title":"t"}
  Ok(MaybeMeta { title: "t", meta: None })
MaybeMeta  {"title":"t","author":"a","views":300}
  Ok(MaybeMeta { title: "t", meta: None })

第二行的 views 超出 u8,from_str 却返回 Ok,author 也一起丢了。

修法

  • flatten struct:照常用外层的 deny_unknown_fields。
  • flatten map:两者本来矛盾,map 就是用来收「其余的键」的。去掉 deny_unknown_fields,需要的话自己检查 map 的键。
  • flatten 内部标签 enum:把 deny_unknown_fields 从外层挪到 enum 上。它作用于每个 struct 变体,变体看得到 __collect 里所有还没被删掉的键,多出来的就报错。旁边还有别的 flatten 字段时要注意声明顺序,见下面的实验。
  • 内层的 deny_unknown_fields:删掉,它不做任何事。
  • flatten 的 Option<T>:改成 flatten 一个非 Option 的 struct,把可以缺的字段各自写成 Option。缺失照样是 None,值不对就报错。

flatten_fix.rs 的其余部分:

// Fix 1: deny on the flattened enum, not on the outer struct.
#[derive(Deserialize, Debug)]
#[serde(tag = "kind", deny_unknown_fields)]
enum Kind {
  Image { url: String },
  Text { body: String },
}

#[derive(Deserialize, Debug)]
struct Block {
  id: u32,
  #[serde(flatten)]
  kind: Kind,
}

// Fix 2: flatten a struct whose fields are Option.
#[derive(Deserialize, Debug)]
struct LooseMeta {
  author: Option<String>,
  views: Option<u8>,
}

#[derive(Deserialize, Debug)]
struct Post {
  title: String,
  #[serde(flatten)]
  meta: LooseMeta,
}
Block      {"id":1,"kind":"Image","url":"u"}
  Ok(Block { id: 1, kind: Image { url: "u" } })
Block      {"id":1,"kind":"Image","url":"u","x":0}
  Err(unknown field `x`, expected `url` at line 1 column 39)
Post       {"title":"t"}
  Ok(Post { title: "t", meta: LooseMeta { author: None, views: None } })
Post       {"title":"t","author":"a","views":300}
  Err(invalid value: integer `300`, expected u8 at line 1 column 38)
  • 合法的 Block 能读了,多一个 x 由变体 Image 报出,带 expected,候选只有变体自己的字段 url。
  • Post 缺字段是 None,views 是 300 时报错,MaybeMeta 吞掉的错误回来了。

第一种修法和别的 flatten 字段放在一起时,结果取决于声明顺序:flatten 字段按声明顺序从 __collect 里取,struct 取走的键后面的人就看不到了。flatten_more.rs,StrictKind 就是上面加了 deny_unknown_fields 的 Kind(只留 Image 一个变体),Meta 只有 author:

#[derive(Deserialize, Debug)]
struct EnumThenStruct {
  #[serde(flatten)]
  kind: StrictKind,
  #[serde(flatten)]
  meta: Meta,
}

#[derive(Deserialize, Debug)]
struct StructThenEnum {
  #[serde(flatten)]
  meta: Meta,
  #[serde(flatten)]
  kind: StrictKind,
}
enum, struct   {"kind":"Image","url":"u","author":"a"}
  Err(unknown field `author`, expected `url` at line 1 column 39)
struct, enum   {"kind":"Image","url":"u","author":"a"}
  Ok(StructThenEnum { meta: Meta { author: "a" }, kind: Image { url: "u" } })

enum 在前时,author 还在 __collect 里,被变体当成多余的键;struct 在前时,Meta 先把 author 认领走了。所以加了 deny_unknown_fields 的 enum 要放在所有 flatten struct 之后。flatten 的 map 放在哪里都没用:它只借不删,map 想收的多余键 enum 照样看得到。src/bin/flatten_map_enum.rs 把 rest: HashMap<String, serde_json::Value> 放在 StrictKind 前面:

Ok(MapThenEnum { rest: {"kind": String("Image"), "url": String("u")}, kind: Image { url: "u" } })
Err(Error("unknown field `x`, expected `url`", line: 1, column: 32))

x 进了 rest,也被 Image 拒绝;rest 能装的只有 enum 自己用掉的那几个键。

怎么确认没中招

  • 搜同时出现 deny_unknown_fields 和 flatten 的结构体,按上面的表看 flatten 的每个字段是什么类型。
  • 对每个带 flatten 的类型写一个往返测试:to_string 一个合法值再 from_str。外层 deny 加内部标签 enum 的组合连自己写出的 JSON 都读不回来,这个测试第一次跑就会红(flatten_more.rs,Block 加上 Serialize):
roundtrip      {"id":1,"kind":"Image","url":"u"}
  Err(unknown field `kind` at line 1 column 33)
  • 对每个 flatten 的 Option 字段,喂一份「键都在、但有一个值不合法」的输入,确认它报错而不是得到 None。

小结

  • flatten 加 deny_unknown_fields 能编译,检查的是「__collect 里还剩没删的项」:struct 正常;map 只能为空;内部标签 enum 合法输入也报 unknown field `kind` ;写在内层永远不触发。
  • 这条路报的 unknown field 用 custom 造,没有 expected 列表,位置在对象末尾;看到这种格式就先查 flatten。
  • 宏在展开时不知道字段类型是 struct 还是 enum,所以拦不住;#1358 从 1.0.70 起开着。
  • flatten 内部标签 enum 时,把 deny_unknown_fields 挪到 enum 上,并把它声明在所有 flatten struct 之后。
  • flatten 的 Option<T> 把任何错误变成 None;改成 flatten 一个字段各自为 Option 的 struct。

💬 关联讨论

发起讨论
加载中...