บทเรียน 13 · Day 4 · JSON

JSON และ Struct Tags: ทำไม password ถึงหลุด (หรือหายไปเฉย ๆ)

~16 นาที · ต้องมี: บทเรียน 07 (struct & pointer), บทเรียน 12

JSON คือภาษากลางระหว่างระบบ — REST API, config, message queue, webhook, log ทั้งหมดใช้มัน Go แปลง struct ↔ JSON ให้อัตโนมัติ แต่มีกฎเงียบ ๆ หลายข้อที่ทำให้ข้อมูลหายไปโดยไม่มี error บทนี้เอามาเปิดเผยให้ครบ

1 · สองทิศทาง: Marshal และ Unmarshal

       JSON                                Go struct
  {                                    type Product struct {
    "id": 101,      ──Unmarshal()──▶       ID    int
    "name": "…",                           Name  string
    "price": 2590                          Price int
  }                 ◀──Marshal()───    }
ฟังก์ชันทิศทางจำง่าย ๆ
json.Marshal(v)Go → JSON ([]byte)ส่งออก เช่นตอบ API
json.Unmarshal(data, &v)JSON → Goรับเข้า เช่นอ่าน request body
📦 เทียบให้เห็นภาพ — แพ็คพัสดุกับแกะพัสดุ

ของที่วางอยู่บนชั้นในบ้าน จะส่งไปรษณีย์ตรง ๆ ไม่ได้ ต้องเอาลงกล่อง ปิดเทป เขียนหน้ากล่องก่อน พอปลายทางได้กล่อง เขาก็แกะกล่อง เอาของไปวางบนชั้นของเขา

· json.Marshal = แพ็คของลงกล่อง — เอา struct ในเครื่องเรา ทำให้เป็นข้อความที่ส่งข้ามเครื่องได้
· json.Unmarshal = แกะกล่องเอาของขึ้นชั้น — เอาข้อความ JSON ที่รับมา เทกลับเข้า struct ของเรา

ที่ไม่เหมือนกัน: ส่งพัสดุคือย้ายของไป ต้นทางไม่เหลือแล้ว แต่ Marshal คือคัดลอก — struct ตัวเดิมยังอยู่ครบ และของบางชิ้นจะไม่ถูกใส่ลงกล่องโดยไม่มีใครบอก ซึ่งคือเรื่องของทั้งบทนี้

🗣 ศัพท์ที่เพิ่งเรียนไป
marshal / unmarshal
คำที่ Go ใช้เรียก “แพ็คลงกล่อง” กับ “แกะกล่อง” · เวลาถามให้พูดว่า “ตอน marshal มันหายไป” หรือ “unmarshal แล้วได้ค่าว่าง” จะสื่อกว่าพูดว่า “แปลง JSON ไม่ได้” เพราะอีกฝ่ายรู้ทันทีว่าเป็นทิศทางไหน
serialize / deserialize
คำกลางที่ใช้กันทุกภาษา · ในบริบท Go ใช้แทน marshal/unmarshal ได้ · เจอคำไหนก็นึกถึงกล่องพัสดุใบเดิม
ระวัง: ใน Go คำว่า Encoder/Decoder หมายถึงตัวที่ทำงานกับสตรีมโดยเฉพาะ (เจอในหัวข้อ 8) ไม่ใช่คำพ้องของ Marshal/Unmarshal ตรง ๆ
payload
“ของที่อยู่ในกล่อง” = ตัวเนื้อข้อมูลที่ส่งไปมาระหว่างระบบ · “ขอดู payload หน่อย” แปลว่า ขอดูก้อน JSON จริงที่ส่งไป

2 · Struct Tag คือคำสั่งที่แปะไว้ท้าย field

type User struct {
    UserID   int    `json:"userId"`
    FullName string `json:"fullName"`
    Email    string `json:"email"`
}

// json.Marshal ได้:
{
  "userId": 101,
  "fullName": "Alice Smith",
  "email": "alice@email.com"
}

ถ้าไม่ใส่ tag เลย Go ใช้ชื่อ field ตรง ๆ:

// ผลรันจริงเมื่อไม่มี tag:
{"UserID":101,"FullName":"Alice Smith","Email":"alice@email.com"}

ซึ่งผิดธรรมเนียมของฝั่ง JSON — Google JSON Style Guide บอกว่า property name ควรเป็น camelCase ส่วน Go บังคับให้ field ที่จะ export ต้องขึ้นต้นตัวใหญ่ (PascalCase) struct tag คือสะพานเชื่อมสองธรรมเนียมนี้

🗣 ศัพท์ที่เพิ่งเรียนไป
field
“ช่องเก็บของ” หนึ่งช่องใน struct เช่น UserID int · พูดว่า “field ชื่อ Email” ดีกว่าพูดว่า “ตัวแปร Email” เพราะ dev จะรู้ทันทีว่าอยู่ใน struct
struct tag
ข้อความในเครื่องหมาย `…` ที่ต่อท้าย field · ถามให้ตรงว่า “field นี้ struct tag เขียนว่าอะไร”
camelCase / PascalCase
รูปแบบการตั้งชื่อ · userId = camelCase · UserID = PascalCase · ประโยคที่ใช้จริงคือ “JSON key ฝั่งนั้นเป็น camelCase หรือ snake_case” — ถามคำนี้ก่อนเขียน tag จะไม่ต้องมาแก้ทีหลัง

tag ใส่ได้หลาย package พร้อมกัน คั่นด้วยเว้นวรรค — แต่ละ library อ่านเฉพาะส่วนของตัวเอง:

UserID int `json:"userId" xml:"userId" validate:"required"`

3 · กับดักที่ 1: field ตัวเล็กหายไปเงียบ ๆ

ก่อนไปต่อ: กฎตัวพิมพ์ใหญ่-เล็กของ Go

Go ไม่มีคำว่า public/private — มันใช้ ตัวอักษรตัวแรกของชื่อ ตัดสินแทน:

ชื่อขึ้นต้นด้วยเรียกว่าใครเห็นได้ตัวอย่าง
ตัวใหญ่ Nameexportedทุก packagefmt.Println, json.Marshal
ตัวเล็ก nameunexportedเฉพาะ package ตัวเองตัวช่วยภายในไฟล์เดียวกัน

ทำไมเรื่องนี้เกี่ยวกับ JSON: โค้ดที่แปลง struct เป็น JSON ไม่ได้อยู่ในไฟล์คุณ — มันอยู่ใน package encoding/json ของ Go ดังนั้นมันเห็นได้เฉพาะ field ที่เป็น exported เท่านั้น field ตัวเล็กจึงไม่มีทางเข้าหรือออก JSON ได้เลย ต่อให้แปะ tag ไว้

🔑 เทียบให้เห็นภาพ — ห้องที่ล็อกกุญแจตอนย้ายบ้าน

วันย้ายบ้าน คุณจ้างบริษัทขนของมาช่วย เขาเก็บของเฉพาะห้องที่เปิดประตูไว้ ห้องไหนล็อกกุญแจ เขาเดินผ่านไปเฉย ๆ — ของในห้องนั้นไม่ได้ขึ้นรถ
ขากลับก็เหมือนกัน ของที่ควรไปวางในห้องล็อก เขาเอาเข้าไม่ได้ ห้องนั้นเลยไม่ถูกแตะเลย — ของเดิมที่มีอยู่ก็ยังอยู่อย่างนั้น ถ้าไม่มีอะไรอยู่ตั้งแต่แรกถึงจะว่างเปล่า (ตัวอย่างข้างล่างคือกรณีหลัง เพราะเป็น struct ตัวใหม่เอี่ยม)

· field ชื่อตัวใหญ่ = ห้องที่เปิดประตูไว้ · field ชื่อตัวเล็ก = ห้องที่ล็อกกุญแจ
· encoding/json = บริษัทขนของที่คลำหาห้องเอาเอง (กลไกชื่อ reflection — ไม่ได้รู้จักชื่อห้องมาก่อน) กติกาของ Go คือคนที่คลำหาแบบนี้ห้ามเปิดห้องตัวเล็กเด็ดขาด ต่อให้อยู่บ้านเดียวกันก็ตาม
· tag ที่แปะไว้บนห้องล็อก ไม่ช่วยอะไรเลย เพราะเขาเปิดประตูไม่ได้ตั้งแต่แรก

ที่ไม่เหมือนกัน: บริษัทขนของจริงยังโทรมาบอกว่า “ห้องนี้เข้าไม่ได้นะครับ” แต่ encoding/json ไม่บอกอะไรเลย — ไม่มี error ไม่มี warning เงียบสนิท และนี่คือจุดที่ทำให้คนพลาดกันมาแล้วจริง

type User struct {
    ID       int    `json:"id"`
    Name     string `json:"name"`
    password string `json:"password"`   // ← p ตัวเล็ก
}

user := User{ID: 101, Name: "Alice", password: "secret123"}
data, _ := json.Marshal(user)
fmt.Println(string(data))

พิมพ์อะไรออกมา?

{"id":101,"name":"Alice"} — ผมรันทดสอบแล้ว

password ขึ้นต้นด้วยตัวเล็ก = unexported field → package encoding/json ซึ่งอยู่คนละ package มองไม่เห็นเลย ต่อให้แปะ tag ไว้ก็ไม่มีผล
ไม่มี error ไม่มี warning — เงียบสนิท

ด้านกลับ: อันตรายกว่ามาก

type LoginRequest struct {
    Email    string `json:"email"`
    password string `json:"password"`   // ← ตัวเล็ก
}

// รับ JSON: {"email":"peter@email.com","password":"123456"}
// หลัง json.Unmarshal:
req.Email      // "peter@email.com"
req.password   // ""  ← ว่างเปล่า! รหัสผ่านที่ผู้ใช้ส่งมาหายไปหมด
ทำไมถึงอันตราย

Unmarshal ไม่คืน error — มันมองว่า field นั้นไม่มี แล้วปล่อยเป็น zero value (ค่าเริ่มต้นของชนิดข้อมูลนั้น เช่น string ได้ข้อความว่าง int ได้ 0)
ผลคือรหัสผ่านที่ระบบเห็นเป็นค่าว่างทุกครั้ง ถ้าโค้ดตรวจรหัสเขียนไว้หลวม ๆ ประตูเปิดพอดี
กฎ: field ที่ต้องเข้า/ออก JSON ต้องขึ้นต้นด้วยตัวใหญ่เสมอ

🗣 ศัพท์ที่เพิ่งเรียนไป
exported / unexported
“ห้องที่เปิด” กับ “ห้องที่ล็อก” · ถามให้ตรงว่า “field นี้ exported หรือเปล่า” จะได้คำตอบเร็วกว่าถามว่า “ทำไมมันไม่ออก JSON” เพราะคำนี้ชี้ไปที่สาเหตุตรง ๆ เลย
zero value
ค่าเริ่มต้นที่ Go ใส่ให้เองเมื่อไม่มีใครกำหนดค่า · 0 · false · "" · nil · ประโยคที่ใช้จริง “field นี้ได้ zero value เพราะ JSON ไม่ได้ส่งมา”
silent failure
“พังแบบเงียบ ๆ” — ทำงานผิดโดยไม่มี error โผล่มาเลย · เวลาเล่าให้ senior ฟังให้พูดว่า “มันเป็น silent failure ไม่มี error เลย” เขาจะรู้ทันทีว่าต้องไปไล่หาที่ไหน

4 · omitempty — และเหตุผลที่มันหลอกคน

type User struct {
    ID       int    `json:"id"`
    Nickname string `json:"nickname,omitempty"`
}

user := User{ID: 101, Nickname: ""}
// ได้: {"id":101}     ← nickname หายไปเพราะว่าง

ดูสมเหตุสมผล จนกระทั่งเจอกรณีนี้:

type Item struct {
    Price  int  `json:"price,omitempty"`
    Active bool `json:"active,omitempty"`
}

item := Item{Price: 0, Active: false}
data, _ := json.Marshal(item)
fmt.Println(string(data))

ได้ JSON หน้าตายังไง?

{} — ว่างเปล่าทั้งก้อน ผมรันทดสอบแล้ว

omitempty ตัดทิ้งเมื่อค่าเป็น zero value — ซึ่งรวมถึง 0, false, "", nil, slice/map ที่ว่าง
แต่ "สินค้าราคา 0 บาท" กับ "ปิดใช้งาน" เป็นข้อมูลที่มีความหมาย! ฝั่งรับจะแยกไม่ออกว่า "ไม่ได้ส่งมา" กับ "ส่งมาว่าเป็น 0"

📝 เทียบให้เห็นภาพ — ช่องที่เว้นว่าง กับ ช่องที่เขียนเลข 0

ใบสมัครมีช่อง “จำนวนบุตร” · เว้นว่างไว้ = ยังไม่ได้ตอบ · เขียนเลข 0 ลงไป = ตอบแล้วว่าไม่มีลูก
สองอย่างนี้คนละความหมายกันชัด ๆ

ทีนี้ถ้าเจ้าหน้าที่ตั้งกฎว่า “ช่องไหนเป็น 0 ให้ถือว่าไม่ได้กรอก แล้วลบทิ้ง” คำตอบ “ไม่มีลูก” จะหายไปทันที — นี่คือสิ่งที่ omitempty ทำ ส่วน pointer คือการเพิ่มช่องติ๊ก “ไม่ได้กรอก” แยกออกมาต่างหาก ตอนกรอกส่งออกไปจึงแยกได้ครบทั้ง 3 สถานะ

ที่ไม่เหมือนกัน: ฟอร์มกระดาษที่เว้นว่าง อย่างน้อยยังเห็นช่องอยู่ แต่ JSON ที่โดน omitempty ตัด key นั้นหายไปจากก้อนข้อมูลเลย ฝั่งรับไม่เห็นแม้แต่ชื่อช่อง จึงไม่มีทางรู้ว่าเคยมี field นี้อยู่

ข้อควรระวัง — ขาเข้าไม่ได้ 3 สถานะ: ตอน ขาออก (Marshal) pointer แยกได้ 3 สถานะจริง แต่ตอน ขาเข้า (Unmarshal) nil หมายได้ทั้ง “ไม่ได้ส่ง key มาเลย” และ “ส่ง key มาเป็น null” — ผมรันทดสอบแล้ว ทั้ง {} และ {"p":null} ได้ P == nil เท่ากัน ถ้าต้องแยกสองอันนี้จริง ๆ ต้องไปอ่าน JSON ดิบเอง เช่นรับเป็น map[string]json.RawMessage แล้วเช็กว่ามี key นั้นอยู่ไหม

ทางแก้: ใช้ pointer

type Item struct {
    Price  *int  `json:"price,omitempty"`
    Active *bool `json:"active,omitempty"`
}

// ไม่ต้องการส่งจริง ๆ — ปล่อยเป็น nil:
Item{}
// ได้: {}

// ต้องการส่งค่า zero จริง ๆ:
var price int
var active bool
Item{Price: &price, Active: &active}
// ได้: {"price":0,"active":false}   ← ส่งออกไปครบ

pointer แยกได้ 3 สถานะ ในขณะที่ค่าธรรมดาแยกได้แค่ 2: nil = "ไม่ได้ส่ง (หรือส่ง null มา)" · &0 = "ส่งมาว่า 0" · &5 = "ส่งมาว่า 5" — สำคัญมากตอนทำ PATCH API (API แบบ "แก้เฉพาะ field ที่ส่งมา ที่เหลือปล่อยไว้เหมือนเดิม") ที่ต้องแยก "ไม่แก้ field นี้" กับ "แก้เป็นศูนย์"
ย้ำอีกรอบ: ตอนรับเข้ามา nil ยังแยกไม่ออกระหว่าง "ไม่ได้ส่ง key มา" กับ "ส่ง null มาเพื่อสั่งล้างค่า" — ถ้าธุรกิจต้องแยกสองอันนี้ ต้องอ่าน JSON ดิบเอง

🗣 ศัพท์ที่เพิ่งเรียนไป
omitempty
ตัวเลือกใน tag ที่แปลว่า “ถ้าค่าว่างให้ตัด key นี้ทิ้ง” · เวลาโดนถามว่าทำไมข้อมูลหาย ให้ตอบว่า “field นั้นติด omitempty อยู่ พอค่าเป็นค่าว่างเลยไม่ออกไป”
“ค่าว่าง” ที่ว่านี้คือรายการเจาะจง: 0 · false · "" · nil · slice/map ที่ว่าง เท่านั้น
ระวัง: struct ว่าง กับ time.Time ที่ยังไม่ได้ตั้งค่า ไม่โดนตัด — ผมรันทดสอบแล้ว Inner ว่างยังออกมาเป็น {"s":{"A":0}} อยู่ดี ถ้าอยากให้มันหายจริงต้องเปลี่ยนเป็น pointer
optional field
field ที่ “มีก็ได้ ไม่มีก็ได้” ในข้อตกลงระหว่างสองระบบ · ประโยคที่ใช้ตอนออกแบบ API คือ “field นี้เป็น optional หรือ required” — ถามคำนี้ก่อนเขียนโค้ดจะลดปัญหาไปเยอะ
nullable
“ค่านี้เป็น null ได้” — แค่นั้น คือ 2 สถานะ (มีค่า / เป็น null) ไม่ได้แปลว่า 3 สถานะ · เวลาขอความเห็น senior ให้พูดว่า “field นี้ nullable ไหม ถ้าใช่ ผมจะเปลี่ยนเป็น *int” เขาจะเข้าใจทันที
absent vs null
การแยกระหว่าง “ไม่ได้ส่ง key นั้นมาเลย” (absent) กับ “ส่ง key มาแต่ค่าเป็น null” · เป็นคำที่คนทำ PATCH API พูดกันจริง (มาตรฐานที่อ้างกันคือ JSON Merge Patch — RFC 7386) · ประโยคที่ใช้คือ “อันนี้ต้องแยก absent กับ null ไหม” · ถ้าต้องแยกจริง *int ไม่พอ เพราะทั้งสองกรณีได้ nil เหมือนกัน

5 · json:"-" — ห้าม field นี้เข้าออก JSON

type User struct {
    ID           int    `json:"id"`
    Email        string `json:"email"`
    PasswordHash string `json:"-"`     // ← ไม่ออกไปแน่นอน
}

// ผลรันจริง: {"id":101,"email":"alice@example.com"}
แนวทางที่ปลอดภัยกว่า — แยก struct

json:"-" ใช้ได้ แต่วันไหนมีคนเผลอลบ tag ออก ข้อมูลลับหลุดทันทีโดยไม่มีอะไรเตือน ทีมส่วนใหญ่จึงแยกเป็นคนละ struct ไปเลย — แล้วตั้งกฎว่า struct ฝั่งซ้ายจะไม่ถูกส่งเข้า json.Marshal เด็ดขาด ตัวที่ handler (ฟังก์ชันที่รับ request เข้ามาแล้วตอบกลับ) หยิบไปตอบผู้เรียกใช้ มีแค่ตัวขวาเท่านั้น

ใช้ในระบบเท่านั้น — ห้ามส่งเข้า Marshal

type UserModel struct {
    ID           int
    Email        string
    PasswordHash string
}

ตัวเดียวที่ส่งออกไปข้างนอก

type UserResponse struct {
    ID    int    `json:"id"`
    Email string `json:"email"`
}

อย่าเข้าใจผิดว่า "ไม่มี tag = ไม่ออก JSON" — จากหัวข้อ 2 ถ้าไม่มี tag Go จะใช้ชื่อ field ตรง ๆ ดังนั้น json.Marshal(userModel) ยังได้ {"ID":1,"Email":"...","PasswordHash":"..."} ครบทุก field (ผมรันแล้ว) สิ่งที่กันข้อมูลหลุดคือ "ไม่มีใครเอา UserModel ไป Marshal" ไม่ใช่การไม่ใส่ tag

6 · กฎของ json.Unmarshal() ที่ต้องรู้ครบ

6.1 ลืม & = ไม่ทำงาน แต่ได้ error บอก

json.Unmarshal(data, &product)   // ✅
json.Unmarshal(data, product)    // ❌

// error จริงที่ได้:
// json: Unmarshal(non-pointer main.ProductPrice)

เหตุผลย้อนไปที่ บทเรียน 07: Go ส่งค่าแบบ pass by value 100% — ถ้าไม่ส่ง pointer ไป Unmarshal จะแก้ได้แค่ copy อันนี้ยังดีที่มี error เตือน ไม่เหมือนกับดักอื่นในบทนี้

6.2 field ที่ JSON ไม่ได้ส่งมา → zero value

// JSON: {"name":"Alice"}
type User struct {
    Name   string `json:"name"`
    Age    int    `json:"age"`
    Active bool   `json:"active"`
}

// ผลรันจริง: {Name:Alice Age:0 Active:false}  — ไม่มี error

6.3 field ส่วนเกินใน JSON → ถูกทิ้งเงียบ ๆ

// JSON: {"email":"alice@example.com","admin":true}
type LoginRequest struct {
    Email string `json:"email"`
}

// ผลรันจริง: err = <nil>  ← ผ่านฉลุย "admin" หายไปเฉย ๆ

6.4 type ไม่ตรง → อันนี้มี error

// Struct ต้องการ int แต่ JSON ส่ง string มา
type Product struct {
    Price int `json:"price"`
}
// JSON: {"price":"2590"}

// error จริง:
// json: cannot unmarshal string into Go struct field
//       ProductPrice.price of type int

7 · เรื่องที่สไลด์ไม่ได้บอก: การจับคู่ key ไม่ สนตัวพิมพ์

type StrictUser struct {
    ID   string `json:"id"`
    Name string `json:"name"`
}

// JSON ส่งมาเป็นตัวพิมพ์ใหญ่:
json.Unmarshal([]byte(`{"ID":"P001","NAME":"Keyboard"}`), &u)

ได้ค่าเข้า struct ไหม หรือได้ค่าว่าง?

ได้ครบ — {ID:P001 Name:Keyboard} ผมรันทดสอบแล้ว

encoding/json จับคู่แบบ case-insensitive เมื่อหา exact match ไม่เจอ ข้อดีคือทนต่อ API ที่ตั้งชื่อไม่สม่ำเสมอ ข้อเสียคือมันปิดบังบั๊ก — โค้ดทำงานได้ทั้งที่ contract ระหว่างสองระบบไม่ตรงกันจริง ๆ วันหนึ่งอีกฝั่งเปลี่ยนไปใช้ชื่ออื่น คุณจะเพิ่งรู้ว่าไม่เคยตกลงกันชัดเจน

8 · json.NewDecoder() — เมื่อ Unmarshal ไม่พอ

Unmarshal ต้องได้ข้อมูลครบทั้งก้อนก่อนถึงจะเริ่มแกะได้ ส่วน NewDecoder อ่านทีละส่วนไปแกะไป — วิธีหลังนี้เรียกว่าอ่านแบบ stream

🎬 เทียบให้เห็นภาพ — ดูหนังออนไลน์ที่ดูไปโหลดไป

ดูหนังออนไลน์ไม่ต้องรอโหลดจบทั้งเรื่องก่อนถึงจะกดเล่นได้ เครื่องเล่นดึงข้อมูลมาทีละก้อนเรื่อย ๆ จึงไม่ต้องเก็บไฟล์ทั้งเรื่องไว้ในเครื่องพร้อมกัน
· os.ReadFile + Unmarshal = โหลดหนังทั้งเรื่องลงเครื่องก่อน แล้วค่อยเปิดดู
· json.NewDecoder = ดึงมาทีละก้อนจากสายที่ต่ออยู่

ที่ไม่เหมือนกัน: ดูหนังคือเห็นฉากแรกได้เลยทั้งที่ยังโหลดไม่จบ แต่ Decode(&v) กับ JSON ก้อนเดียว ยังต้องอ่านจนจบก้อนนั้นก่อน ถึงจะได้ struct ที่เติมค่าครบให้เอาไปใช้ — ห้ามคิดว่าอ่าน field แรก ๆ ออกมาใช้ก่อนได้
ที่ประหยัดจริงคือ ไม่ต้องแบก []byte ทั้งก้อนไว้ในแรม และ อ่าน JSON หลายก้อนต่อกันในสายเดียวได้ — ผมรันทดสอบแล้ว ป้อน {"Pub":1}{"Pub":2}{"Pub":3} เข้าไป เรียก Decode ซ้ำ ๆ ได้ทีละก้อนจริง

os.ReadFile + Unmarshaljson.NewDecoder
อ่านข้อมูลโหลดทั้งไฟล์เข้า RAM ก่อนอ่านทีละส่วนแบบ stream
ไฟล์ 2 GBกิน RAM 2 GBกิน RAM คงที่
รับจาก networkต้องรออ่านให้ครบก่อนเริ่มแกะได้เลย
กัน field แปลกปลอม❌ ไม่มีทางเลือก✅ DisallowUnknownFields()
file, err := os.Open("product.json")
if err != nil {
    return err
}
defer file.Close()          // ← บทเรียน 08

var product Product
decoder := json.NewDecoder(file)

if err := decoder.Decode(&product); err != nil {
    return err
}

ปิดช่องโหว่ 6.3 ด้วย DisallowUnknownFields()

type User struct {
    ID   string `json:"id"`
    Name string `json:"name"`
}

raw := `{"name":"admin","isAdmin":true}`

decoder := json.NewDecoder(strings.NewReader(raw))
decoder.DisallowUnknownFields()      // ← เปิดโหมดเข้มงวด

if err := decoder.Decode(&user); err != nil {
    fmt.Println("error", err)
    return
}
// ผลรันจริง: error json: unknown field "isAdmin"
ควรเปิดเมื่อไหร่

เปิดเสมอสำหรับ config file และ request body ที่รับจากภายนอก — ผู้ใช้พิมพ์ "tiemout" แทน "timeout" ใน config ถ้าไม่เปิดโหมดนี้ ระบบจะใช้ค่า default เงียบ ๆ แล้วคุณจะหาสาเหตุไม่เจอ ส่วนตอนรับ response จาก API ภายนอก ไม่ควรเปิด เพราะเขาอาจเพิ่ม field ใหม่เมื่อไหร่ก็ได้ แล้วระบบคุณจะพังทันที

9 · ทดสอบความจำ

1. field ชื่อ password (ตัวเล็ก) ที่มี json:"password" จะเป็นยังไง?

field ตัวเล็ก = unexported → encoding/json ซึ่งอยู่คนละ package เข้าถึงไม่ได้ ไม่มี error ไม่มี warning ขา Marshal ก็หาย ขา Unmarshal ก็ได้ค่าว่าง — เคยเป็นสาเหตุของช่องโหว่ login มาแล้วจริง

2. Price int + omitempty เมื่อค่าเป็น 0 จะได้ JSON แบบไหน?

omitempty ตัดทิ้งทุกอย่างที่เป็น zero value — 0, false, "", nil ทั้งที่บางครั้งค่าเหล่านี้มีความหมาย ทางแก้: เปลี่ยนเป็น *int ซึ่งแยก "ไม่ส่ง" (nil) ออกจาก "ส่งค่า 0" ได้

3. JSON มี field เกินกว่าที่ struct ประกาศไว้ — Unmarshal ทำยังไง?

เป็นค่าเริ่มต้นของ encoding/json — ออกแบบมาให้ทนต่อ API ที่เพิ่ม field ใหม่ ถ้าต้องการเข้มงวด ต้องใช้ json.NewDecoder(...) แล้วเรียก DisallowUnknownFields() ซึ่งจะได้ json: unknown field "isAdmin"

4. ทำไมถึงควรใช้ json.NewDecoder แทน ReadFile+Unmarshal?

เหตุผลหลักคือ stream — อ่านทีละส่วน ไม่กิน RAM ตามขนาดไฟล์ เหมาะกับ HTTP request body และไฟล์ใหญ่ ของแถมคือได้ DisallowUnknownFields() มาด้วย
(Decode ยังต้องส่ง & เหมือนเดิมนะ)

10 · ลงมือทำ

ภารกิจ: ค้นหาโปรไฟล์ผู้เล่นและแปลงเป็น JSON (workshop จากสไลด์)

  1. สร้าง type Player struct ที่มี ID, UserName, NickName, Password
  2. เขียน findPlayer(id int) (Player, error) — ไม่พบให้คืน ErrPlayerNotFound (sentinel จาก บทเรียน 12)
  3. ถ้าพบ ให้แปลงเป็น JSON โดยมีเงื่อนไข:
    • key ต้องเป็น camelCase (playerId, userName)
    • Password ห้ามหลุดออกไปใน JSON
    • NickName ถ้าไม่มีค่า ไม่ต้องแสดง
  4. ผลลัพธ์ที่ต้องได้:
    findPlayer(101) → {"playerId":101,"userName":"go_master"}
    findPlayer(999) → error: player not found
  5. ตรวจงานตัวเอง: ทำข้อ "Password ห้ามหลุด" ด้วย json:"-" ไปแล้วใช่ไหม — ลองทำอีกวิธี ด้วยการแยก PlayerResponse ออกมา แล้วตอบตัวเองว่าแบบไหนที่เพื่อนร่วมทีมเผลอทำพังยากกว่า
  6. ท้าทาย 1: ทำให้ findPlayer(101) ที่มี nickname กับไม่มี nickname ให้ JSON ต่างกันจริง — พิสูจน์ว่า omitempty ทำงาน
  7. ท้าทาย 2: เขียนขากลับ — รับ JSON {"playerId":101,"userName":"go_master","isAdmin":true} แล้ว Unmarshal กลับเป็น Player
    • ใช้ json.Unmarshal ธรรมดา → เกิดอะไรกับ isAdmin?
    • เปลี่ยนไปใช้ json.NewDecoder + DisallowUnknownFields() → ต่างกันยังไง?
  8. ท้าทาย 3: ลอง Unmarshal {"PLAYERID":999} (ตัวพิมพ์ใหญ่หมด) เข้า Player — เข้าไหม? ถ้าเข้า อธิบายว่าทำไม (ย้อนดูหัวข้อ 7)

อ่านต่อจากต้นทาง

แหล่งหลักที่แนะนำ: JSON and Go — บทความทางการที่อธิบาย ทั้ง Marshal/Unmarshal/Decoder และเคสที่ไม่รู้โครงสร้างล่วงหน้า (map[string]any) ประมาณ 15 นาที
ตอนเขียนโค้ดจริงให้เปิด pkg.go.dev — json.Marshal ไว้ข้าง ๆ เพราะกฎการแปลงทุกข้อ (รวม omitempty และ -) อยู่ในหน้านั้นครบ

💬 ติดตรงไหนถามได้เลย — ถ้าเจอ JSON ที่โครงสร้าง ไม่แน่นอน (บางครั้งเป็น object บางครั้งเป็น array) บอกมาได้ มีวิธีจัดการที่ยังไม่ได้สอนในบทนี้ (json.RawMessage และ custom UnmarshalJSON)

อ้างอิง