JSON 转 Rust 结构体:用 serde 派生省掉手写 struct
粘一段 JSON 就拿到带 serde derive 的 Rust 结构体,讲清 Deserialize 派生、Option 可选字段、嵌套子 struct 与 serde rename,帮你接 API 时不再手敲一行 struct。
JSON 转 Rust 结构体:用 serde 派生省掉手写 struct
在 Rust 里接一个第三方 API,最磨人的不是写业务逻辑,而是照着响应体一个字段一个字段地敲 struct。一个 GitHub user 对象 30 多个字段,嵌套里还有 plan、permissions,手抄一遍再核对类型,半小时就没了。其实这一步完全可以交给工具:把 JSON 粘进 /zh/t/json-to-rust/,直接拿到一组带 #[derive(Serialize, Deserialize)] 的结构体声明。
serde 的 Deserialize 派生到底替你做了什么
Rust 不像动态语言那样能随手 data["user"]["id"],它要求你先有一个类型。serde 的做法是给结构体加派生宏:
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct User {
pub id: i64,
pub login: String,
}
#[derive(Deserialize)] 这一行,会在编译期为 User 自动生成一套把 JSON 字节填进字段的代码。之后 serde_json::from_str::<User>(s) 就能把字符串直接变成类型化的值,每一次字段访问都被编译器检查过,不会在生产环境因为一次 .unwrap() 而 panic。Serialize 是反方向,把结构体写回 JSON。这两个派生一起加,数据就能干净地往返。
手写这套结构体没有难度,只有体量。字段越多、嵌套越深,抄错类型、漏掉一个 key 的概率越高。把推断交给工具,你只保留真正需要人判断的部分。
一段真实的输入输出
假设后端发来这样一段 JSON:
{
"userId": 7,
"isActive": true,
"score": 4.5,
"tags": ["rust", "serde"],
"profile": { "city": "Beijing" },
"deletedAt": null
}
工具吐出的 Rust 是这样:
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct Root {
#[serde(rename = "userId")]
pub user_id: i64,
#[serde(rename = "isActive")]
pub is_active: bool,
pub score: f64,
pub tags: Vec<String>,
pub profile: RootProfile,
#[serde(rename = "deletedAt")]
pub deleted_at: Option<serde_json::Value>,
}
#[derive(Serialize, Deserialize)]
pub struct RootProfile {
pub city: String,
}
几个细节值得留意。userId 转成了符合 Rust 习惯的 user_id,同时挂上 #[serde(rename = "userId")],这样线上格式一字不差,clippy 也不会再警告 non_snake_case。整数推断成 i64,带小数的 score 推断成 f64,这是永远能编译、永远能解析的安全默认。tags 是统一标量数组,精确推断成 Vec<String>。嵌套的 profile 自动抽成 RootProfile 子 struct,而不是塞进一个让你后面用脆弱索引去够的 Value。
Option 与可空字段
deletedAt 这次是 null,所以它落到了 Option<serde_json::Value>。因为 null 不带任何类型信息,工具不会替你瞎猜里面是什么,只能给一个动态值兜底。
更有意思的是对象数组。当你把若干条 webhook payload 作为 JSON 数组一起粘进去,有的元素带 cancelled_at、有的不带,工具会判断这个字段时有时无,把它包成 Option<String>。这样你就能用
match event.cancelled_at {
Some(ts) => println!("已取消 {ts}"),
None => println!("仍有效"),
}
干净地区分两种状态,而不是在反序列化时因为缺 key 直接报错。要从单个样本探索这种可选性,把它包成 [{...}] 再粘,缺失信号才出得来。
我自己怎么用它
上周我给一个 Rust 小服务接一个支付回调,对方文档残缺,我只能先打日志收了几条真实 payload。我把这几条原样拼成数组粘进去,工具一次性折叠出根 struct 和两层嵌套子 struct,还顺手把那个偶尔出现的退款时间字段标成了 Option。我做的唯一手动改动,是把我确知非负的余额字段从 i64 收窄成 u64。原本预计要半小时的对照抄写,实际花了不到三分钟,而且没抄错任何一个 key。
配套工具
如果你的栈不止 Rust,同一段 JSON 也可以转成别的语言:/zh/t/json-to-go-struct/ 出 Go struct,/zh/t/json-to-typescript-interface/ 出 TS 接口,/zh/t/json-to-python-dataclass/ 出 Python dataclass,/zh/t/json-to-java/ 出 Java 类。粘之前如果 JSON 格式有问题,先过一道 /zh/t/json-formatter/,因为转换用的是严格的 JSON.parse,注释和末尾逗号都会报错。
需要提醒的是,生成的代码要能编译,Cargo.toml 里得有 serde 并打开 derive feature;如果输出里出现了 serde_json::Value 或 HashMap 兜底,还得加上 serde_json。其余的就交给编译器替你把关。
Made by Toolora · Updated 2026-06-13