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 บอกได้ชัดว่า พังชั้นไหน และ ไฟล์ไหน
เพิ่มใน Cargo.toml:
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"Output (example):
(no output — you updated Cargo.toml)
อ้างอิง:
- serde_json: https://docs.rs/serde_json
ลองนึกภาพว่าโปรแกรมมี 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 แน่น และ ผิดแล้วดัง
คนมาจาก 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 ชัด
เวลาอ่าน config ให้แยกชั้นความผิดพลาดออกจากกัน:
- I/O
- อ่านไฟล์ไม่ได้ (path ผิด / permission / ไม่พบไฟล์)
- เนื้อหาไม่ใช่ UTF-8 (ไม่บ่อย แต่เป็นไปได้)
- JSON syntax
- comma/quote ขาด
- JSON ไม่ valid
- Schema (shape/type)
- type ไม่ตรง (เช่น list กลายเป็น string)
- field หาย
- field สะกดผิด
- Semantics (ความหมายเชิงธุรกิจ)
- list ว่างทั้งที่ต้องมีค่า
- format ไม่ถูก (เช่น
"127.0.0.1:abc") - ค่า out-of-range
ข้อดีของการแยกชั้น:
- คุณรู้ทันทีว่าควรแก้ตรงไหน
- error message ทำ UX ได้ดีขึ้น
- เขียน test แยกชั้นได้ง่าย (especially semantics)
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 “ดัง” ตั้งแต่ต้น
จุดเปลี่ยนที่สำคัญคือ: แทนที่จะ “อ่านแล้วเดาเอง” ให้ 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)
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 ในจุดสำคัญ
ตัวอย่าง: เพิ่ม 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 แทน
เหตุผลที่ต้องแยก 2 ขั้นนี้:
- JSON มักใช้ string แทนของจริง (เช่น
"127.0.0.1:8080") แต่ภายในอยากได้SocketAddr - schema ผ่าน ≠ ใช้ได้จริง (semantics ยังต้องเช็ก)
แนวที่ใช้จริง:
- Deserialize เป็น
*Rawที่สะท้อน JSON - 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 ถูกบังคับก่อนเข้าระบบหลัก
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 ส่งไฟล์ผิดมา
แนวทางที่ทำให้ refactor ไม่เจ็บ:
- เริ่มจาก
Valueเพื่อ inspect/log และเข้าใจ shape - ย้าย field ที่ใช้จริงเข้า typed struct (เริ่มจากน้อยที่สุด)
- แยก
RawออกจากValidated/Convertedให้เร็ว - ค่อยเพิ่มความเข้ม:
deny_unknown_fields(จับ typo)Option/default(รองรับของเก่าแบบตั้งใจ)versionใน JSON ถ้าเริ่มมีหลายสคีมา
ข้อสังเกต:
- tight แค่ไหนไม่ใช่เรื่องสวยงาม แต่เป็นเรื่อง “คุณควบคุม ecosystem ได้แค่ไหน”
ถ้าคุณต้อง “เขียน config กลับลงไฟล์” หรือมีระบบ update ระหว่างรัน:
- อย่าเขียนทับไฟล์เดิมตรง ๆ (ถ้าระบบ crash กลางทาง ไฟล์จะเหลือครึ่งก้อน)
แนวทางที่ใช้จริง:
- เขียนไฟล์ใหม่ เช่น
endpoint.json.tmp fsyncถ้าจำเป็น (ขึ้นกับ OS/ความเข้มงวด)- rename/replace ไปเป็นไฟล์จริง (
endpoint.json) แบบ atomic
อีกจุดที่ช่วยลดความเจ็บตอนโต:
- ใส่
versionใน JSON ตั้งแต่เริ่มมีการเปลี่ยน schema - เวลาอ่าน: switch ด้วย version แล้วค่อย migrate/convert
- Explore ด้วย
Value
- ลองทำให้
addrlistsหาย / เป็น string / เป็น list ว่าง - ดูว่าโค้ดของคุณ “เงียบ” หรือ “ดัง” ตรงไหน
- ทำ typed struct รอบแรก
- สร้าง
EndpointConfigRaw { addrlists: Vec<String> } - ตั้งเป้าว่า missing field ต้อง fail
- ทำ
validate_and_convert
- เช็คอย่างน้อย 2 ข้อ: list ห้ามว่าง, ห้ามมี string ว่าง
- แปลงเป็น
Vec<SocketAddr>
- (ต่อยอด) เปิด
deny_unknown_fields
- ลองพิมพ์ key ผิด 1 ตัว แล้วดูว่า error ช่วยจับ typo ได้เร็วแค่ไหน