ข้ามไปยังเนื้อหา

The JS↔WASM boundary

บทก่อน ส่งแค่ number กับ string ข้าม boundary CRDT ต้องยื่น op ทั้งก้อนให้ JavaScript — record แบบ tagged เช่น insert หรือ delete — และรับ batch กลับมา merge บทนี้ต่อสาย serde-wasm-bindgen เพื่อให้ Rust enum กลายเป็น JS object ธรรมดาและกลับมาได้ และปักหมุดชัด ๆ ว่าอะไร copy ข้ามเส้น และใครรับผิดชอบ free อะไร

เราจะนิยาม type Op ที่แอปทั้งหมดสร้างขึ้นรอบ ๆ — shape เดียวกับที่เก็บใน IndexedDB และ push ไป sync server — แล้วพิสูจน์ว่า round-trip ได้ CRDT ที่ ผลิต op เหล่านี้จะมาใน module ถัดไป; ที่นี่เราทำ wire format ให้แน่นก่อน

wasm-bindgen marshal primitive และ string ได้เอง แต่ Rust struct หรือ enum ไม่มี representation ที่เป็นธรรมชาติใน JavaScript — memory layout เป็นเรื่องภายในของ Rust คุณมีสองทางข้าม: serialize เป็น JSON string แล้ว JSON.parse อีกฝั่ง หรือ convert ตรงเป็น JS value (object, array, number) โดยไม่มี string คั่นกลาง serde-wasm-bindgen ทำอย่างหลัง และเป็นทางที่ถูก: ไม่มี double-encoding, โค้ดเล็กกว่า และผลลัพธ์เป็น JS object ที่ note store ตรวจได้ทันที

op format เป็น enum แบบ internally tagged: ทุก op เป็น object ที่มี discriminator t`{ "t": "ins", ... }`, `{ "t": "del", ... }`, `{ "t": "title", ... }` #[serde(tag = "t")] ของ serde ผลิตออกมาแบบนั้นเป๊ะ และ serde-wasm-bindgen เข้าใจ tagging เดียวกันตอนขากลับ นิยาม Rust เดียวขับทั้งสองทิศ

อีกครึ่งคือ ownership Rust struct ที่ export ไป JS (เช่น NoteDoc) อยู่บน WASM heap และเข้าถึงผ่าน pointer ที่ JS object ถือ; op ต่างออกไป — เพราะเป็น copy ที่ถูก serialize เป็น JS data ธรรมดาที่ไม่มี Rust lifetime ผูกอยู่ การรู้ว่าอันไหนเป็นอันไหนบอกคุณว่าเมื่อไหร่ต้องเรียก .free() และเมื่อไหร่ garbage collector จัดการให้แล้ว

serde-wasm-bindgen vs JSON strings across the boundary

  • Pros: ไม่มี JSON.stringify/parse ทั้งสองฝั่ง; convert ตรงเป็น JS object/array; โค้ด generate เล็กกว่าและงานต่อ op น้อยกว่า ค่าที่คุณได้ใน JS ใช้ได้ทันที
  • Cons: Rust dependency เพิ่มอีกหนึ่ง และ mapping มีกฎ (enum tagging, map เป็น object) ที่คุณต้องเคารพ ไม่งั้น deserialization fail ตอน runtime สำหรับ debug string ครั้งเดียว JSON ง่ายกว่า

Copying ops across vs handing JS a pointer to a live Rust value

  • Pros of copying: ฝั่ง JS ได้ data ที่ GC จัดการเองแบบเป็นอิสระ เก็บใน IndexedDB หรือส่งข้าม network ได้โดยไม่กังวลเรื่อง Rust lifetime; ไม่มี .free() ให้จำ
  • Cons of copying: ทุกการข้าม serialize และ allocate สำหรับ batch ใหญ่นั่นเป็นงานจริง — เราจึงข้าม boundary เป็น batch (array ของ op ทั้งชุดต่อ call) ไม่ใช่ทีละตัวอักษร เพื่อ amortize ต้นทุนนั้น
[dependencies]
wasm-bindgen = "0.2"
serde = { version = "1", features = ["derive"] }
serde-wasm-bindgen = "0.6"

element id กับ timestamp ต่างเป็น pair (counter, actor) — Rust tuple ซึ่ง serde serialize เป็น JS array สองสมาชิก `[counter, actor]` เรา alias ไว้เพื่อให้อ่านง่าย #[serde(tag = "t")] และ rename ต่อ variant ผลิต wire shape เป๊ะที่คอร์สที่เหลือพึ่งพา

use wasm_bindgen::prelude::*;
use serde::{Serialize, Deserialize};
/// (Lamport counter, actor id) — serializes as `[counter, actor]`.
pub type Id = (u32, String);
/// The unit of change. Stored in IndexedDB, pushed to the sync server,
/// applied by `NoteDoc::merge`. Internally tagged on `t`.
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(tag = "t")]
pub enum Op {
/// { "t": "title", "value": string, "ts": [counter, actor] }
#[serde(rename = "title")]
SetTitle { value: String, ts: Id },
/// { "t": "ins", "id": [c,a], "after": [c,a] | null, "ch": string }
#[serde(rename = "ins")]
Insert { id: Id, after: Option<Id>, ch: String },
/// { "t": "del", "id": [counter, actor] }
#[serde(rename = "del")]
Delete { id: Id },
}

serde_wasm_bindgen::to_value(&value) รับ reference — อ่านค่า Rust ของคุณแล้วสร้าง JS value ใหม่ ทิ้งตัวเดิมให้ Rust เป็นเจ้าของ from_value(js) consume JsValue แล้วผลิต Rust data ที่เป็นเจ้าของ; การใช้ ? เปลี่ยน shape ที่ผิดให้เป็น JS error ที่ throw สังเกตว่า from_value ให้คุณเป็น copyVec<Op> ที่คืนมาเป็นอิสระจากอะไรก็ตามที่ JS ยังถืออยู่

/// Rust → JS: returns an array of ops as a live JS value.
#[wasm_bindgen]
pub fn demo_ops() -> JsValue {
let ops = vec![
Op::SetTitle { value: "Groceries".into(), ts: (1, "A".into()) },
Op::Insert { id: (2, "A".into()), after: None, ch: "H".into() },
];
// borrows `ops`; `ops` is still owned by Rust and dropped at end of scope
serde_wasm_bindgen::to_value(&ops).unwrap()
}
/// JS → Rust: consumes the JsValue, deserializes to owned Rust data (a copy).
#[wasm_bindgen]
pub fn count_ops(val: JsValue) -> Result<usize, JsValue> {
let ops: Vec<Op> = serde_wasm_bindgen::from_value(val)?;
Ok(ops.len())
}

object ที่คุณได้จาก demo_ops() เป็น JS ธรรมดาที่คุณ log, store หรือส่งได้ ยื่นกลับให้ count_ops ตรง ๆ แล้ว deserialize ได้สะอาด เพราะทั้งสองทิศอ่านนิยาม serde เดียวกัน

import init, { demo_ops, count_ops } from '../../crates/crdt/pkg/crdt.js';
export async function boundarySmoke(): Promise<void> {
await init();
const ops = demo_ops();
console.log(ops);
// [ { t: 'title', value: 'Groceries', ts: [1, 'A'] },
// { t: 'ins', id: [2, 'A'], after: null, ch: 'H' } ]
console.log('count', count_ops(ops)); // 2
}

Ownership ในบรรทัดเดียว: ops ตรงนี้คือ JS data ที่ GC จัดการ — ไม่มี .free() ส่วน NoteDoc (module ถัดไป) จะเป็น JS handle สู่ live Rust struct และตัวนั้นคุณต้อง .free()

Build crate เพื่อให้ export ใหม่ลงใน pkg/:

Terminal window
cd crates/crdt
wasm-pack build --target web

สำหรับ boundary check ที่ไม่ต้องใช้ browser ให้ assert ว่า op round-trip ผ่าน serde ใน Rust test:

#[cfg(test)]
mod tests {
use super::*;
#[test]
fn op_json_shape_is_stable() {
let op = Op::Insert { id: (2, "A".into()), after: None, ch: "H".into() };
let json = serde_json::to_string(&op).unwrap();
assert_eq!(json, r#"{"t":"ins","id":[2,"A"],"after":null,"ch":"H"}"#);
}
}

เพิ่ม serde_json = "1" ใต้ [dev-dependencies] แล้วรัน:

Terminal window
cargo test

คาดหวัง — wire shape เป็นสิ่งที่ sync server และ IndexedDB จะเก็บพอดี:

running 1 test
test tests::op_json_shape_is_stable ... ok
test result: ok. 1 passed; 0 failed

Check your understanding:

  1. ทำไม serde-wasm-bindgen ชนะการ serialize op เป็น JSON string แล้วเรียก JSON.parse ใน JS?
  2. #[serde(tag = "t")] ผลิตอะไรใน JS object และทำไม attribute เดียวกันจึงใช้ได้ทั้ง to_value และ from_value?
  3. to_value รับ &T แต่ from_value รับ JsValue แบบ by value นั่นบอกอะไรคุณเกี่ยวกับว่าฝั่งไหนเป็นเจ้าของ data หลังจากนั้น?
  4. Vec<Op> ที่คืนจาก from_value ไม่ต้อง .free() แต่ handle NoteDoc ต้อง ทำไมจึงต่างกัน?

เรานิยาม enum Op — wire format ของแอป — และย้าย batch ของ op ทั้งชุดข้าม boundary ด้วย serde_wasm_bindgen::to_value/from_value โดยไม่มี JSON string คั่นกลาง เราปักหมุด ownership: op เป็น copy ที่ GC จัดการ; Rust struct ที่ export เป็น live handle ที่คุณ free การข้ามเป็น batch ทำให้ต้นทุน copy ถูก amortize

เมื่อ toolchain และ boundary แน่นแล้ว เราสร้างสิ่งที่ผลิต op เหล่านี้ได้: A CRDT in Rust →