← レッスン一覧に戻る レッスン 37 / 48
中級 data-structures
Wasm向けSerdeシリアライゼーション
WasmでSerdeが重要な理由
Rust/WasmがJavaScriptと通信する際、データはWasm境界を越える必要があります。Rustの構造体はリニアメモリに存在し、JavaScriptオブジェクトはJSヒープに存在します。Serdeは2つの表現間を変換することで、このギャップを橋渡しします。
Rust/Wasm側 JS側
┌────────────────┐ ┌────────────────┐
│ struct User { │ │ { name: "Ali", │
│ name: String │ serde │ age: 30, │
│ age: u32 │ <======> │ role: "admin"}│
│ role: Role │ │ │
│ } │ │ (JSオブジェクト) │
└────────────────┘ └────────────────┘
リニアメモリ JSヒープ2つのアプローチ: serde-wasm-bindgen と serde_json
アプローチ1: serde-wasm-bindgen(推奨)
JSON文字列を経由せず、Rustの型とJsValueを直接変換します:
use serde::{Serialize, Deserialize};
use wasm_bindgen::prelude::*;
#[derive(Serialize, Deserialize)]
struct Point { x: f64, y: f64 }
#[wasm_bindgen]
pub fn process_point(val: JsValue) -> Result<JsValue, JsValue> {
let point: Point = serde_wasm_bindgen::from_value(val)?;
let result = Point { x: point.x * 2.0, y: point.y * 2.0 };
Ok(serde_wasm_bindgen::to_value(&result)?)
}アプローチ2: serde_json(JsValue文字列経由)
JSON文字列にシリアライズし、反対側でパースします:
#[wasm_bindgen]
pub fn process_point_json(json: &str) -> String {
let point: Point = serde_json::from_str(json).unwrap();
let result = Point { x: point.x * 2.0, y: point.y * 2.0 };
serde_json::to_string(&result).unwrap()
}比較
serde-wasm-bindgenの経路:
Rust構造体 ──> JsValue (直接、1ステップ)
serde_jsonの経路:
Rust構造体 ──> JSON文字列 ──> JS JSON.parse() ──> JSオブジェクト
^ ^
シリアライズ パース
(Rust) (JSエンジン)| 特徴 | serde-wasm-bindgen | serde_json |
|---|---|---|
| 中間形式 | なし(直接) | JSON文字列 |
| Map, Set, Dateのサポート | あり(ネイティブJS型) | なし(JSONサブセットのみ) |
| BigIntサポート | あり | なし |
| undefined vs nullの区別 | あり | なし(両方nullになる) |
| バイナリサイズのオーバーヘッド | ~3 KB | ~15-25 KB |
| 速度(小さなオブジェクト) | 高速 | 同程度 |
| 速度(大きな配列) | より高速 | 低速(文字列アロケーション) |
| デバッグの容易さ | 難しい(不透明) | 容易(可読なJSON) |
SerializeとDeserializeのderive
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Debug)]
struct GameState {
score: u64,
level: u32,
player: Player,
inventory: Vec<Item>,
settings: HashMap<String, String>,
}
#[derive(Serialize, Deserialize, Debug)]
struct Player {
name: String,
position: (f64, f64),
health: f32,
}
#[derive(Serialize, Deserialize, Debug)]
struct Item {
id: u32,
name: String,
quantity: u32,
}この単一の#[derive]で、serde対応のあらゆる形式への変換に必要なコードがすべて生成されます。
enumのシリアライゼーション戦略
Serdeは属性を使った複数のenum表現をサポートしています:
// デフォルト: 外部タグ付き
#[derive(Serialize)]
enum Shape {
Circle { radius: f64 },
Rect { w: f64, h: f64 },
}
// -> {"Circle": {"radius": 5.0}}
// 内部タグ付き
#[derive(Serialize)]
#[serde(tag = "type")]
enum Shape {
Circle { radius: f64 },
Rect { w: f64, h: f64 },
}
// -> {"type": "Circle", "radius": 5.0}
// 隣接タグ付き
#[derive(Serialize)]
#[serde(tag = "type", content = "data")]
enum Shape {
Circle { radius: f64 },
Rect { w: f64, h: f64 },
}
// -> {"type": "Circle", "data": {"radius": 5.0}}
// タグなし
#[derive(Serialize)]
#[serde(untagged)]
enum Shape {
Circle { radius: f64 },
Rect { w: f64, h: f64 },
}
// -> {"radius": 5.0} (タグなし — デシリアライズ時に推論)比較表
| 戦略 | JSON出力 | JS親和性? | 識別可能? |
|---|---|---|---|
| 外部(デフォルト) | {"Variant": {...}} |
扱いにくい | はい |
内部 tag |
{"type": "Variant", ...} |
自然 | はい |
| 隣接 | {"type": "Variant", "data": {...}} |
明確 | はい |
| タグなし | {...}(フィールドのみ) |
最もクリーン | 常にではない |
JavaScriptとの相互運用では、内部タグ付き(
#[serde(tag = "type")])が通常最良の選択です — TypeScriptの判別共用体に一致します。
OptionとResultの処理
#[derive(Serialize, Deserialize)]
struct UserProfile {
name: String,
bio: Option<String>, // JSONでnullまたは欠落
#[serde(default)]
verified: bool, // 欠落時はfalseにデフォルト
#[serde(skip_serializing_if = "Option::is_none")]
avatar_url: Option<String>, // Noneの場合は完全に省略
} シリアライゼーション動作:
フィールド 値 JSON出力
─────────────────────────────────────────────────
bio Some("Hi") "bio": "Hi"
bio None "bio": null
avatar_url Some("url") "avatar_url": "url"
avatar_url None (フィールド省略)
verified(欠落時) - falseにデフォルトよく使うSerde属性
| 属性 | 効果 |
|---|---|
#[serde(rename = "camelCase")] |
単一フィールドのリネーム |
#[serde(rename_all = "camelCase")] |
すべてのフィールドをリネーム(構造体/enum) |
#[serde(default)] |
フィールド欠落時にDefault::default()を使用 |
#[serde(skip)] |
シリアライズもデシリアライズもしない |
#[serde(skip_serializing_if = "...")] |
シリアライゼーション時に条件付きでスキップ |
#[serde(flatten)] |
ネストされた構造体のフィールドをインライン化 |
#[serde(with = "module")] |
フィールドのカスタム(デ)シリアライゼーション |
#[serde(deny_unknown_fields)] |
予期しないJSONキーでエラー |
ネストされた複雑な型
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Dashboard {
user_name: String, // -> "userName"
widgets: Vec<Widget>,
layout: HashMap<String, Position>,
#[serde(flatten)]
metadata: Metadata, // フィールドがインライン化
}
#[derive(Serialize, Deserialize)]
#[serde(tag = "kind")]
enum Widget {
Chart { data_source: String, chart_type: String },
Table { columns: Vec<String>, row_count: u32 },
Text { content: String },
}
#[derive(Serialize, Deserialize)]
struct Position { x: i32, y: i32, w: u32, h: u32 }
#[derive(Serialize, Deserialize)]
struct Metadata {
created_at: String,
version: u32,
}結果のJSON:
{
"userName": "alice",
"widgets": [
{"kind": "Chart", "data_source": "api/sales", "chart_type": "bar"},
{"kind": "Text", "content": "Welcome!"}
],
"layout": {
"widget-0": {"x": 0, "y": 0, "w": 6, "h": 4}
},
"created_at": "2025-01-01",
"version": 3
}パフォーマンス: シリアライゼーションコストの最小化
1. 不要なコピーを避ける
// 悪い例 — 毎フレームゲーム状態全体をシリアライズ
fn update(state: &GameState) -> JsValue {
serde_wasm_bindgen::to_value(state).unwrap()
}
// 良い例 — 変更されたものだけをシリアライズ
#[derive(Serialize)]
struct StateDelta {
updated_entities: Vec<EntityUpdate>,
removed_ids: Vec<u32>,
}2. 大きなデータにはバイナリ形式を使う
// JSON: 人間が読めるが大きい
let json = serde_json::to_vec(&data)?; // ~500バイト
// bincode: コンパクトなバイナリ形式
let bin = bincode::serialize(&data)?; // ~120バイト
// Uint8ArrayとしてデータチャネルやpostMessageで転送3. バッファの事前アロケート
// 悪い例 — 呼び出しごとに新しいStringをアロケート
fn serialize(data: &Data) -> String {
serde_json::to_string(data).unwrap()
}
// 良い例 — バッファを再利用
fn serialize_into(data: &Data, buf: &mut Vec<u8>) {
buf.clear();
serde_json::to_writer(buf, data).unwrap();
}Wasm向けフォーマット比較
| フォーマット | 人間が読める | サイズ | 速度 | Wasmバイナリオーバーヘッド |
|---|---|---|---|---|
| serde_json | はい | 大きい | 中程度 | ~20 KB |
| serde-wasm-bindgen | N/A(直接) | N/A | 高速 | ~3 KB |
| bincode | いいえ | 小さい | 高速 | ~5 KB |
| postcard | いいえ | 最小 | 最速 | ~3 KB |
| rmp (msgpack) | いいえ | 小さい | 高速 | ~8 KB |
エラーハンドリング
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn parse_config(val: JsValue) -> Result<JsValue, JsValue> {
// from_valueは形状が一致しない場合に明確なエラーを返す
let config: Config = serde_wasm_bindgen::from_value(val)
.map_err(|e| JsValue::from_str(&format!("無効な設定: {}", e)))?;
// 処理...
let result = process(config);
serde_wasm_bindgen::to_value(&result)
.map_err(|e| JsValue::from_str(&format!("シリアライゼーション失敗: {}", e)))
}まとめ
Wasmプロジェクトでは、serde-wasm-bindgenが推奨デフォルトです — 最小限のオーバーヘッドでRustの型とJavaScriptの値を直接変換します。人間が読める出力が必要な場合や、データを文字列として渡す場合(例:localStorage、HTTPボディ)はserde_jsonを使用しましょう。enumには#[serde(tag = "type")]を、JS親和的なフィールド名には#[serde(rename_all = "camelCase")]を使用しましょう。大量のデータ転送にはbincodeやpostcardなどのバイナリ形式を検討してください。