Skip to content

Latest commit

 

History

History
389 lines (280 loc) · 16 KB

File metadata and controls

389 lines (280 loc) · 16 KB

09 — Config & serde_json (อ่าน JSON แบบค่อย ๆ tighten schema)

TOC · Prev · Next

Keywords: config, serde, error handling, tooling

อ่านแบบคน Python:

  • ถ้าอยากเอา “ภาพรวม” ก่อน: จำ 4 ชั้น I/O → JSON → schema → semantics (พังชั้นไหนต้องบอกชั้นนั้น)
  • ถ้าอยาก “ลงมือทำ”: เริ่มจาก Value เพื่อสำรวจ แล้วค่อยย้ายมา typed struct ทีละส่วน
  • ถ้าติด: เปิด 12-learning-playbook.md

บทนี้คือ “ทางเดินมาตรฐาน” สำหรับอ่าน config JSON แบบที่โตเป็นโปรเจกต์ได้จริง:

  • เริ่มจากอ่านให้ได้ (I/O)
  • parse JSON ให้ผ่าน (syntax)
  • deserialize ให้ schema ชัด (shape/type)
  • validate ความหมายเชิงธุรกิจ (semantics)
  • ค่อย ๆ เพิ่มความเข้ม (tighten) แบบมีจังหวะ

เป้าหมาย: ลดการพังแบบ “เงียบ ๆ” และทำให้ error บอกได้ชัดว่า พังชั้นไหน และ ไฟล์ไหน


Prereq (dependencies)

เพิ่มใน Cargo.toml:

[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"

Output (example):

(no output — you updated Cargo.toml)

อ้างอิง:


0) ตัวอย่าง config ที่เราจะใช้

ลองนึกภาพว่าโปรแกรมมี endpoint หนึ่งชุด และต้องรับ list ของ address:

endpoint.json:

{
  "addrlists": ["127.0.0.1:8080", "127.0.0.1:8081"]
}

Output (example):

(no output — this is a sample file)

แนวคิดสำคัญ: ภายนอก (JSON) ยืดหยุ่น แต่ภายใน (Rust) อยากให้ type แน่น และ ผิดแล้วดัง


1) ทำไม “โหลดตอน import/ตอนเริ่มทุกอย่าง” ทำให้ยุ่ง

คนมาจาก Python มักคุ้นกับการโหลด config กระจัดกระจาย หรือโหลดตั้งแต่เริ่มโปรแกรมทันที

ปัญหาเวลามันพังตั้งแต่ยังไม่ทันตั้งระบบ:

  • stack trace/บริบทกระจาย (ไม่รู้ว่าพังจากไฟล์ไหน)
  • ยังไม่ทัน parse args (เช่น --config path) ก็พังแล้ว
  • ยังไม่ทันตั้ง logging ให้ดี ก็ต้อง debug ด้วยการเดา

ตัวอย่างจากโค้ดจริงที่เจอบ่อย (สิ่งที่ควรเลี่ยง):

  • โหลด/แก้ไฟล์ config แบบวนลูปตลอดเวลา และสร้างไฟล์ default เองแบบเงียบ ๆ
  • เขียนทับไฟล์ *.json ระหว่างที่ระบบยังรัน → ถ้าเกิด crash ระหว่างเขียน จะเหลือไฟล์ “ครึ่งก้อน” แล้วรอบต่อไปอ่านพัง

แนวที่ Rust ชอบและดูแลง่าย:

  • โหลด config ใน main() (หรือ “จุดเดียว” ที่ชัด)
  • ถ้าพัง: พิมพ์ error ที่มี context แล้วจบโปรแกรมแบบเจตนา
  • ถ้าต้อง update config/runtime: เขียนไฟล์แบบ atomic (เขียนไฟล์ใหม่ แล้ว rename) และมี schema version ชัด

2) คิดเป็น “error surface” 4 ชั้น (จำแล้วเอาไปใช้ได้เลย)

เวลาอ่าน config ให้แยกชั้นความผิดพลาดออกจากกัน:

  1. I/O
  • อ่านไฟล์ไม่ได้ (path ผิด / permission / ไม่พบไฟล์)
  • เนื้อหาไม่ใช่ UTF-8 (ไม่บ่อย แต่เป็นไปได้)
  1. JSON syntax
  • comma/quote ขาด
  • JSON ไม่ valid
  1. Schema (shape/type)
  • type ไม่ตรง (เช่น list กลายเป็น string)
  • field หาย
  • field สะกดผิด
  1. Semantics (ความหมายเชิงธุรกิจ)
  • list ว่างทั้งที่ต้องมีค่า
  • format ไม่ถูก (เช่น "127.0.0.1:abc")
  • ค่า out-of-range

ข้อดีของการแยกชั้น:

  • คุณรู้ทันทีว่าควรแก้ตรงไหน
  • error message ทำ UX ได้ดีขึ้น
  • เขียน test แยกชั้นได้ง่าย (especially semantics)

3) เริ่มจาก serde_json::Value (สำรวจเร็ว แต่ต้องรู้กับดัก)

Value เหมาะตอนคุณยัง “ไม่มั่นใจ schema” หรือ config หลายเวอร์ชัน เพราะมันเร็วและยืดหยุ่น

กับดักที่พบบ่อย (สำคัญ):

  • v["key"] ถ้า key หาย จะได้ Null (ไม่ error) → bug หลุดเงียบได้
  • ถ้าไม่เช็กชนิด (as_array, as_str) คุณอาจหลุดไปสู่ default โดยไม่ตั้งใจ

ตัวอย่าง “explore แบบตั้งใจ default” (โอเคช่วงแรก):

use serde_json::Value;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let text = std::fs::read_to_string("endpoint.json")?;
    let v: Value = serde_json::from_str(&text)?;

    let count = v["addrlists"].as_array().map(|a| a.len()).unwrap_or(0);
    println!("addrlists count = {count}");
    Ok(())
}

Output (example):

addrlists count = 2

แต่พอเริ่มจริงจัง แนะนำให้เลิก index แบบ [] แล้วเปลี่ยนเป็น get() เพื่อทำให้ “หายแล้วดัง” ได้ง่ายขึ้น:

use serde_json::Value;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let text = std::fs::read_to_string("endpoint.json")?;
    let v: Value = serde_json::from_str(&text)?;

    let addrlists = v
        .get("addrlists")
        .and_then(|x| x.as_array())
        .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "missing or invalid 'addrlists'"))?;

    println!("addrlists count = {}", addrlists.len());
    Ok(())
}

Output (example):

addrlists count = 2

สรุปเชิง workflow:

  • explore: default ได้ (แต่ต้อง “ตั้งใจ”)
  • production: ให้ missing/type mismatch “ดัง” ตั้งแต่ต้น

4) ย้ายไป typed struct (tighten schema รอบแรก)

จุดเปลี่ยนที่สำคัญคือ: แทนที่จะ “อ่านแล้วเดาเอง” ให้ serde enforce schema ขั้นพื้นฐาน

use serde::Deserialize;

#[derive(Deserialize)]
struct EndpointConfigRaw {
    addrlists: Vec<String>,
}

fn load_endpoint_config_raw(path: &str) -> Result<EndpointConfigRaw, Box<dyn std::error::Error>> {
    let text = std::fs::read_to_string(path)?;
    Ok(serde_json::from_str(&text)?)
}

Output (example):

(no output — this snippet defines types/functions)

ได้อะไรทันที:

  • ถ้า addrlists ไม่ใช่ array → deserialize error
  • ถ้า field หาย → fail (ตราบเท่าที่ไม่ได้ทำเป็น Option หรือ default)

4.1 ทำให้ “key พิมพ์ผิดแล้วไม่หลุด” ด้วย deny_unknown_fields

use serde::Deserialize;

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct EndpointConfigStrict {
    addrlists: Vec<String>,
}

Output (example):

(no output — type definition only)

ข้อควรระวัง: deny_unknown_fields เป็นดาบสองคม

  • ข้อดี: จับ typo ได้ไวมาก
  • ข้อเสีย: ถ้า config เพิ่ม field ใหม่ แล้วโปรแกรมรุ่นเก่าอ่าน ต้องพังทันที

แนวบาลานซ์ที่ใช้จริง:

  • ช่วง migrate/หลายเวอร์ชัน: ใช้แบบหลวม (Option/default) เพื่อรองรับของเก่า
  • ช่วงนิ่ง/ควบคุมได้: ค่อยเปิด strict ในจุดสำคัญ

4.2 รองรับของเก่าแบบ “ตั้งใจ” ด้วย Option/default

ตัวอย่าง: เพิ่ม port ที่อาจจะยังไม่มีในไฟล์เก่า

use serde::Deserialize;

fn default_port() -> u16 {
    8080
}

#[derive(Deserialize)]
struct EndpointConfigCompat {
    addrlists: Vec<String>,

    #[serde(default = "default_port")]
    port: u16,
}

Output (example):

(no output — type definition only)

หลักคิด: default ไม่ใช่ “ของฟรี”

  • ถ้า default ทำให้ระบบเงียบและผิดความคาดหมาย → ให้พิจารณา fail แทน

5) แยก “deserialize” ออกจาก “validate + convert” (tighten รอบที่คุ้มที่สุด)

เหตุผลที่ต้องแยก 2 ขั้นนี้:

  • JSON มักใช้ string แทนของจริง (เช่น "127.0.0.1:8080") แต่ภายในอยากได้ SocketAddr
  • schema ผ่าน ≠ ใช้ได้จริง (semantics ยังต้องเช็ก)

แนวที่ใช้จริง:

  1. Deserialize เป็น *Raw ที่สะท้อน JSON
  2. Validate/convert เป็น type ภายในที่โปรแกรมมั่นใจ
use serde::Deserialize;
use std::net::SocketAddr;

#[derive(Deserialize)]
struct EndpointConfigRaw {
    addrlists: Vec<String>,
}

struct EndpointConfig {
    addrs: Vec<SocketAddr>,
}

fn validate_and_convert(raw: EndpointConfigRaw) -> Result<EndpointConfig, String> {
    if raw.addrlists.is_empty() {
        return Err("addrlists must not be empty".to_string());
    }

    let mut addrs = Vec::with_capacity(raw.addrlists.len());
    for s in raw.addrlists {
        let trimmed = s.trim();
        if trimmed.is_empty() {
            return Err("addrlists must not contain empty strings".to_string());
        }

        let addr: SocketAddr = trimmed
            .parse()
            .map_err(|_| format!("invalid socket address: {trimmed}"))?;
        addrs.push(addr);
    }

    Ok(EndpointConfig { addrs })
}

Output (example):

(no output — this snippet defines types/functions)

สังเกตว่าเรา “tighten” ได้จริงตรงนี้:

  • ไม่ใช่แค่ Vec<String> แต่กลายเป็น Vec<SocketAddr>
  • semantics ถูกบังคับก่อนเข้าระบบหลัก

6) ใส่ context ของ path ลงใน error (เพื่อ debug ที่ไวขึ้น)

serde_json ให้ line/column ดีอยู่แล้ว แต่ในงานจริง “ไฟล์ไหน” สำคัญมาก

ตัวอย่างแนวคิดแบบเบา ๆ (ไม่เพิ่ม dependency):

use serde::Deserialize;

#[derive(Deserialize)]
struct EndpointConfigRaw {
    addrlists: Vec<String>,
}

fn load_raw_with_path(path: &str) -> Result<EndpointConfigRaw, String> {
    let text = std::fs::read_to_string(path)
        .map_err(|e| format!("{path}: failed to read file: {e}"))?;

    serde_json::from_str(&text)
        .map_err(|e| format!("{path}: invalid json/schema: {e}"))
}

Output (example):

(no output — this snippet defines types/functions)

แนวคิดนี้ช่วยมากเวลาโปรแกรมมีหลาย config หรือ user ส่งไฟล์ผิดมา


7) กลยุทธ์ “ค่อย ๆ tighten schema” (tighten แบบมีจังหวะ)

แนวทางที่ทำให้ refactor ไม่เจ็บ:

  1. เริ่มจาก Value เพื่อ inspect/log และเข้าใจ shape
  2. ย้าย field ที่ใช้จริงเข้า typed struct (เริ่มจากน้อยที่สุด)
  3. แยก Raw ออกจาก Validated/Converted ให้เร็ว
  4. ค่อยเพิ่มความเข้ม:
  • deny_unknown_fields (จับ typo)
  • Option/default (รองรับของเก่าแบบตั้งใจ)
  • version ใน JSON ถ้าเริ่มมีหลายสคีมา

ข้อสังเกต:

  • tight แค่ไหนไม่ใช่เรื่องสวยงาม แต่เป็นเรื่อง “คุณควบคุม ecosystem ได้แค่ไหน”

8) Safe config writes (atomic write + version)

ถ้าคุณต้อง “เขียน config กลับลงไฟล์” หรือมีระบบ update ระหว่างรัน:

  • อย่าเขียนทับไฟล์เดิมตรง ๆ (ถ้าระบบ crash กลางทาง ไฟล์จะเหลือครึ่งก้อน)

แนวทางที่ใช้จริง:

  1. เขียนไฟล์ใหม่ เช่น endpoint.json.tmp
  2. fsync ถ้าจำเป็น (ขึ้นกับ OS/ความเข้มงวด)
  3. rename/replace ไปเป็นไฟล์จริง (endpoint.json) แบบ atomic

อีกจุดที่ช่วยลดความเจ็บตอนโต:

  • ใส่ version ใน JSON ตั้งแต่เริ่มมีการเปลี่ยน schema
  • เวลาอ่าน: switch ด้วย version แล้วค่อย migrate/convert

9) แบบฝึกหัด

  1. Explore ด้วย Value
  • ลองทำให้ addrlists หาย / เป็น string / เป็น list ว่าง
  • ดูว่าโค้ดของคุณ “เงียบ” หรือ “ดัง” ตรงไหน
  1. ทำ typed struct รอบแรก
  • สร้าง EndpointConfigRaw { addrlists: Vec<String> }
  • ตั้งเป้าว่า missing field ต้อง fail
  1. ทำ validate_and_convert
  • เช็คอย่างน้อย 2 ข้อ: list ห้ามว่าง, ห้ามมี string ว่าง
  • แปลงเป็น Vec<SocketAddr>
  1. (ต่อยอด) เปิด deny_unknown_fields
  • ลองพิมพ์ key ผิด 1 ตัว แล้วดูว่า error ช่วยจับ typo ได้เร็วแค่ไหน