Rust で JSON を扱う — serde デシリアライズ実践まとめ
実践講座第 3 回で、serde で JSON Lines のログを受けるところを少しだけ見ました。この記事はその serde を実践の幅に広げます。API 応答の処理、設定ファイル、データパイプラインまで、Rust で JSON に出会う場所ならどこでも通じるパターン集です。
基本形 — derive 1 行 #
cargo add serde --features derive
cargo add serde_jsonuse serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct User {
id: u64,
email: String,
active: bool,
}
let json = r#"{"id": 7, "email": "tanaka@example.com", "active": true}"#;
let user: User = serde_json::from_str(json)?; // JSON → 構造体
let back = serde_json::to_string_pretty(&user)?; // 構造体 → JSON
Deserialize が読み、Serialize が書きます。パースと同時に型の検証が終わるのがこの方式の核心的な価値です。id に文字列が来たり email がなかったりすれば from_str が Err を返すので、この行を通過した User は、以後のコードのどこでも完全な値です。Python の json.loads のように dict を受け取ってからキーの存在を 1 つずつ確認する防御コードが、丸ごと消えます。
現実の JSON 1 — ないかもしれないフィールド: Option と default #
実際の API 応答は、フィールドがあったりなかったりします。対応する道具は 2 つで、意味が違います。
#[derive(Debug, Deserialize)]
struct Profile {
email: String,
nickname: Option<String>, // なければ None — 「不在」をデータとして保つ
#[serde(default)]
retry_limit: u32, // なければ 0 (型の既定値)
#[serde(default = "default_timeout")]
timeout_secs: u64, // なければ 30
}
fn default_timeout() -> u64 { 30 }「不在そのものが意味のある情報」なら Option で残し、「なければ決まった値で埋めれば済む」なら default を使います。設定ファイルの構造体はほとんど default 系で埋めるのが使い勝手に優れ、API 応答は Option で受けて不在を明示的に処理する方が安全です。
現実の JSON 2 — 命名規則が違う: rename_all #
JSON の世界は camelCase、Rust は snake_case です。フィールドごとに個別の rename を付ける必要はなく、1 行で規則を宣言します。
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ApiResponse {
user_id: u64, // JSON の "userId" と対応
created_at: String, // JSON の "createdAt" と対応
}逆に、タイプミスやスキーマ変更を早く捕まえたい内部システム間の通信なら、#[serde(deny_unknown_fields)] を付けて「知らないフィールドが来たらエラー」に締めることもできます。外部 API には非推奨です。相手がフィールドを追加するたびに壊れるからです。
構造が分からないとき — Value で探る #
スキーマを決める前の探索段階だったり、構造が半ば自由形の JSON は serde_json::Value で受けます。
let v: serde_json::Value = serde_json::from_str(json)?;
let city = v["address"]["city"].as_str().unwrap_or("不明");Value はどんな JSON でも収める列挙型で、インデックスアクセスと as_str・as_u64 系で探ります。楽な代わりに型の保証が消え、検証がパース時点から使用時点へ散らばります。探索は Value で、コードが固まったら構造体へ昇格が健全な順序です。折衷として、大部分は構造体で受けつつ自由形の部分だけ metadata: Value フィールドで残す混合もよく使います。
形が複数ある JSON — enum で受ける #
イベントストリームのように「種類によってフィールドが違う」JSON は、Rust の列挙型と正確に対応します。
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum Event {
UserCreated { id: u64, email: String },
UserDeleted { id: u64 },
}
// {"type": "user_created", "id": 7, "email": "tanaka@example.com"} → Event::UserCreated
tag = "type" は「type フィールドを見てバリアントを選べ」という内部タグ方式で、イベント・メッセージ系 JSON の事実上の標準の形です。タグフィールドが最初からない応答(成功ならデータ、失敗ならエラーオブジェクト、のような)は #[serde(untagged)] で「形が合うバリアントを順に試す」ようにできます。untagged は便利ですが、失敗時のエラーメッセージが「どのバリアントも合わない」と鈍くなるので、タグを入れられる設計ならタグ方式が優先です。受け取った後の処理は、基礎第 5 回の match がすべてのバリアントの処理を強制してくれる、あの構造そのままです。
エラーの読み方 #
let result: Result<User, _> = serde_json::from_str(broken);
if let Err(e) = result {
eprintln!("{e}"); // invalid type: string "7", expected u64 at line 1 column 9
}serde_json のエラーは、何が違ったのかと一緒に line・column の位置を持っています。大きなファイルの処理でこの位置情報はデバッグ時間に直結するので、エラーを握りつぶさず anyhow の context に乗せて上へ運ぶのが定石です。「何行目のどのフィールドがなぜ」まで出るエラー報告は、JSON パイプラインの品質そのものです。
まとめ #
- 基本形は
derive(Serialize, Deserialize)+from_str・to_stringです。パースを通過した構造体は、以後のコード全体で完全な値です。 - ないかもしれないフィールドは意味で区別します。不在が情報なら
Option、埋めれば済むならdefault。 - 命名規則の違いは
rename_all = "camelCase"の 1 行で解決します。内部通信はdeny_unknown_fieldsで締められます。 - 探索段階は
Value、固まったら構造体へ昇格します。自由形の部分だけ Value で残す混合形も実用的です。 - 種類ごとに形が違う JSON はタグ方式の enum(
tag = "type")が標準で、untagged はタグが本当にないときの次善です。 - エラーには line・column が入っています。context に乗せて運び、「どこがなぜ」の出るエラー報告を作るところまでが実践です。