Rust in Everything
基于 serde 1.0.229

invalid type: string "...", expected a borrowed string

主线位置:反序列化主线上 'de 落地的那一步。格式按字符串实际活多久选 visit_borrowed_str 或 visit_str,&str 只接得住前一种。机制见 生命周期 'de 与借用。

flowchart TB
  field{"字段类型"} -->|"&str"| esc{"输入有转义吗"}
  esc -->|没有| ok["借到,零拷贝"]
  esc -->|有| err["运行期报错"]
  field -->|"Cow 加 borrow"| cow["能借就借,否则拷贝"]
  field -->|String| copy["总是拷贝"]
  rd["from_reader"] --> ce["编译期:not general enough"]

为什么有转义就借不成、Reference::Borrowed 和 Copied 怎么来、Cow 为什么默认不借,生命周期 'de 与借用 都讲过、跑过。这一页只讲怎么认出它、怎么改、怎么查。

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

现象

同一个类型、同一个 from_str,有的输入能读,有的输入运行期失败。src/bin/bs_escape.rs:

use std::collections::HashMap;
use std::fmt::Debug;

use serde::Deserialize;

#[derive(Deserialize, Debug)]
#[allow(dead_code)]
struct Post<'a> {
  title: &'a str,
}

fn show<T: Debug>(label: &str, r: serde_json::Result<T>) {
  match r {
    Ok(v) => println!("{label:<24} Ok({v:?})"),
    Err(e) => println!("{label:<24} Err({e})"),
  }
}

fn main() {
  // a JSON unicode escape: backslash, 'u', 00e9 (Python's json.dumps writes these by default)
  let unicode = format!(r#"{{"title":"caf{}u00e9"}}"#, '\\');
  for input in [r#"{"title":"hello"}"#, r#"{"title":"a/b"}"#, r#"{"title":"say \"hi\""}"#, &unicode] {
    show(input, serde_json::from_str::<Post>(input));
  }
  // map keys borrow too
  show(r#"{"a\nb":1} as map"#, serde_json::from_str::<HashMap<&str, u32>>(r#"{"a\nb":1}"#));
}
{"title":"hello"}        Ok(Post { title: "hello" })
{"title":"a/b"}          Ok(Post { title: "a/b" })
{"title":"say \"hi\""}   Err(invalid type: string "say \"hi\"", expected a borrowed string at line 1 column 21)
{"title":"caf\u00e9"}    Err(invalid type: string "café", expected a borrowed string at line 1 column 20)
{"a\nb":1} as map        Err(invalid type: string "a\nb", expected a borrowed string at line 1 column 7)
  • 只要字符串里出现一个反斜杠,&str 就读不出来。报错里的字符串是解码后的值,第四行显示成 café,看不出输入里其实是一个 \u 转义。
  • 第四行最容易在线上遇到。输入里根本没有引号和换行,只有一个非 ASCII 字符;但 Python 的 json.dumps 默认 ensure_ascii=True,本机 python3 -c 'import json; print(json.dumps({"title":"café"}))' 输出的是 {"title": "caf\u00e9"}。同样的数据用 Node 的 JSON.stringify 写出来是原样的 café,能借。所以「测试都过、换了个客户端就失败」很常见。
  • map 的键也一样:HashMap<&str, _> 的键带转义时报同一句。

编译期还有一个兄弟。&str 字段配 from_reader,src/bin/bs_reader.rs:

use serde::Deserialize;

#[derive(Deserialize, Debug)]
struct Post<'a> {
  title: &'a str,
}

fn main() {
  let file = std::fs::File::open("post.json").unwrap();
  let p: Post = serde_json::from_reader(file).unwrap();
  println!("{p:?}");
}
error: implementation of `Deserialize` is not general enough
  --> src/bin/bs_reader.rs:10:17
   |
10 |   let p: Post = serde_json::from_reader(file).unwrap();
   |                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ implementation of `Deserialize` is not general enough
   |
   = note: `Post<'_>` must implement `Deserialize<'0>`, for any lifetime `'0`...
   = note: ...but `Post<'_>` actually implements `Deserialize<'1>`, for some specific lifetime `'1`

error: could not compile `exp-serde-pit3` (bin "bs_reader") due to 1 previous error

报错里没有 DeserializeOwned 这个词,两句 note 是它展开后的 for<'de> Deserialize<'de>。axum 的 Json<T> 也要求 T: DeserializeOwned,借用的类型同样不能放进去(Json 提取器)。

第三种不报错,只是没有借到:Option<Cow<'a, str>> 加了 #[serde(borrow)],能编译、能读,但值永远是 Owned(下面「修法」的实验第一行 opt_cow=Owned)。

出处

运行期那句来自 &'a str 的 StrVisitor:它的 expecting 写的是 a borrowed string,只覆盖了 visit_borrowed_str 和 visit_borrowed_bytes。格式调 visit_str 时落到 Visitor 的默认实现:

    fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
    where
        E: Error,
    {
        Err(Error::invalid_type(Unexpected::Str(v), &self))
    }

调哪一个由 serde_json 决定。值走 deserialize_str,键走 MapKey::deserialize_any,两处都是同一个分派:

        match tri!(self.de.read.parse_str(&mut self.de.scratch)) {
            Reference::Borrowed(s) => visitor.visit_borrowed_str(s),
            Reference::Copied(s) => visitor.visit_str(s),
        }

SliceRead::parse_str_bytes 遇到反斜杠就把解码结果拼进 scratch,返回 Copied;IoRead::parse_str 一律返回 Copied。from_reader 的编译错误来自它的签名 T: de::DeserializeOwned,而 DeserializeOwned 就是 for<'de> Deserialize<'de>。Option<Cow> 借不到,是因为 derive 的 is_cow 只看字段类型最外层是不是 Cow。

修法

按字段选:

字段写法无转义有转义代价
&'a str借报错只适合确定不会有转义的输入
String拷贝拷贝每个字段一次分配
#[serde(borrow)] Cow<'a, str>借拷贝类型带生命周期
#[serde(borrow)] Option<Cow<'a, str>>拷贝拷贝加了 'de: 'a 约束却没借到
#[serde_as(as = "Option<BorrowCow>")] 加 #[serde(borrow)]借拷贝多依赖 serde_with

src/bin/bs_fix.rs 把后四种放在一个结构体里,各读一次无转义和有转义的输入:

use std::borrow::Cow;

use serde::Deserialize;
use serde_with::{BorrowCow, serde_as};

#[serde_as]
#[derive(Deserialize)]
struct Fixed<'a> {
  owned: String,
  #[serde(borrow)]
  cow: Cow<'a, str>,
  #[serde(borrow)]
  opt_cow: Option<Cow<'a, str>>,
  #[serde_as(as = "Option<BorrowCow>")]
  #[serde(borrow)]
  opt_with: Option<Cow<'a, str>>,
  #[serde_as(as = "Vec<BorrowCow>")]
  #[serde(borrow)]
  vec_with: Vec<Cow<'a, str>>,
}

fn kind(c: &Cow<str>) -> &'static str {
  match c {
    Cow::Borrowed(_) => "Borrowed",
    Cow::Owned(_) => "Owned",
  }
}

fn main() {
  for s in ["hello", r#"say \"hi\""#] {
    let input = format!(
      r#"{{"owned":"{s}","cow":"{s}","opt_cow":"{s}","opt_with":"{s}","vec_with":["{s}"]}}"#
    );
    let f: Fixed = serde_json::from_str(&input).unwrap();
    println!(
      "{s:<12} owned={:?} cow={} opt_cow={} opt_with={} vec_with[0]={}",
      f.owned,
      kind(&f.cow),
      kind(f.opt_cow.as_ref().unwrap()),
      kind(f.opt_with.as_ref().unwrap()),
      kind(&f.vec_with[0]),
    );
  }
}
hello        owned="hello" cow=Borrowed opt_cow=Owned opt_with=Borrowed vec_with[0]=Borrowed
say \"hi\"   owned="say \"hi\"" cow=Owned opt_cow=Owned opt_with=Owned vec_with[0]=Owned
  • 两种输入都读成功了:Cow 借不成时退回拷贝,而不是报错。
  • opt_cow 在无转义时也是 Owned。Option 或 Vec 里面的 Cow 要借,用 serde_with 的 BorrowCow(字段上仍要写 #[serde(borrow)],它负责给 impl 加 'de: 'a)。
  • 不在乎那一次分配的话,String 最省心。

Option<&'a str> 不用写 borrow 也会借,但和 &str 一样,遇到转义报错。src/bin/bs_option.rs:

#[derive(Deserialize, Debug)]
#[allow(dead_code)]
struct Post<'a> {
  // no #[serde(borrow)]: Option<&str> is implicitly borrowed, like &str
  subtitle: Option<&'a str>,
}

// main 里对三种输入各调一次 from_str::<Post>,打印 Ok / Err
{"subtitle":"hello"}   Ok(Post { subtitle: Some("hello") })
{"subtitle":null}      Ok(Post { subtitle: None })
{"subtitle":"a\"b"}    Err(invalid type: string "a\"b", expected a borrowed string at line 1 column 18)
📝NOTE
冷知识:Option<&str> 自动借用是 1.0.52 才加的

1.0.0 起,derive 只把字面上的 &str 和 &[u8] 当作隐式借用,其余类型都要写 #[serde(borrow)]。issue #1029(「Recognize Option<&str> as a special case and assume it should be borrowed」)的理由是:「Option<&str> is common enough and obvious enough that we should handle it too.」提交 b2b36e1(「Accept implicitly borrowed data inside of Option」,直接推到主干,没有单独的 PR)关掉了它,首次发布于 1.0.52。v1.0.51 的 attr.rs 里判断条件还是 is_rptr(&field.ty, is_str) || is_rptr(&field.ty, is_slice_u8);今天的 is_implicitly_borrowed 是 is_implicitly_borrowed_reference(ty) || is_option(ty, is_implicitly_borrowed_reference)。特例只加了 Option<&str> / Option<&[u8]> 这一层,所以 Option<Cow<'a, str>> 至今不在里面。

from_reader 的编译错误有两种改法:要么先把整个输入读进内存,借这块内存;要么让类型不借。src/bin/bs_reader_fix.rs:

use serde::Deserialize;

#[derive(Deserialize, Debug)]
#[allow(dead_code)]
struct Post<'a> {
  title: &'a str,
}

#[derive(Deserialize, Debug)]
#[allow(dead_code)]
struct OwnedPost {
  title: String,
}

fn main() {
  std::fs::write("post.json", r#"{"title":"hello"}"#).unwrap();

  // 1. keep the borrowed type: read the whole file first, then borrow from the String
  let text = std::fs::read_to_string("post.json").unwrap();
  let p: Post = serde_json::from_str(&text).unwrap();
  println!("from_str(&text):  {p:?}");

  // 2. keep from_reader: make the type own its data
  let file = std::io::BufReader::new(std::fs::File::open("post.json").unwrap());
  let o: OwnedPost = serde_json::from_reader(file).unwrap();
  println!("from_reader:      {o:?}");
}
from_str(&text):  Post { title: "hello" }
from_reader:      OwnedPost { title: "hello" }

第一种的 Post 不能比 text 活得长,这是借用的本义;第一种里的 &str 字段遇到转义照样报错,要稳就配上面的 Cow。第二种记得包 BufReader,原因见 from_reader 读文件很慢。

📝NOTE
没有蠢问题

问: 我们的 JSON 是自己的服务写的,里面不会有引号和换行,&str 字段是不是就安全了?

答: 不一定。是否转义由写出方的编码器决定,不由数据内容决定。上面 Python 的例子里数据只有一个 é,json.dumps 默认照样写成 \u 转义;serde_json 自己写出时只转义引号、反斜杠和控制字符(序列化一侧 的 ESCAPE 表),所以「serde_json 写、serde_json 读」的测试会一直通过。只有写出方确定、并且字段内容受限(标识符、枚举名、数字串)时,&str 才是安全的。

怎么确认没中招

  • 找出所有借用的字段:&'a str、Option<&'a str>、HashMap<&'a str, _> 的键。每一个都至少喂一次带反斜杠的输入("a\"b",以及一个 \u 转义),期望要么成功、要么你明确接受这条报错。
  • 字段是 Cow<'a, str> 而你想确认真的借到了,像上面那样 match 出 Cow::Borrowed;外面包了 Option、Vec 的,大概率是 Owned。
  • from_reader、axum 的 Json 这类入口在签名上要求 DeserializeOwned,借用的类型过不了编译;from_reader 报的就是「implementation of Deserialize is not general enough」。

小结

  • &str 字段只能借输入里原样存在的字节;serde_json 遇到任何转义都只能交出 scratch 里的临时拷贝(visit_str),StrVisitor 不收,报 expected a borrowed string。
  • 有没有转义取决于写出方:Python 默认把非 ASCII 字符写成 \u 转义,同样的数据 Node 写出来能借。
  • from_reader 要 DeserializeOwned,借用的类型在编译期报「not general enough」;先读进内存再 from_str,或者换成拥有数据的类型。
  • 修法:String;#[serde(borrow)] Cow<'a, str>(能借就借);Option / Vec 里的 Cow 用 serde_with 的 BorrowCow。Option<Cow> 加 borrow 不借。
  • Option<&str> 从 1.0.52 起隐式借用(issue #1029),遇到转义同样报错。

💬 关联讨论

发起讨论
加载中...