บทเรียน 39 · Day 8 · Gin

binding · validation · middleware · CORS

~18 นาที · ต้องมี: บทเรียน 38 (Gin, *gin.Context), บทเรียน 13 (struct tag)

บทที่แล้วเรารับ request ได้แล้ว บทนี้คือสามอย่างที่อยู่ระหว่าง request กับ handler: แปลง body เป็น struct · เช็คว่าข้อมูลใช้ได้ไหม · แทรกโค้ดที่ต้องทำกับทุก request
และผมจะพิสูจน์ให้ดูสามเรื่องที่ทำงาน "ถูกตามที่เขียน" แต่ผิดตามที่คุณตั้งใจ: binding:"required" ที่ปฏิเสธข้อมูลที่ผู้ใช้ตั้งใจส่งมาจริง · c.Abort() ที่ตอบ 200 OK · และ CORS ที่ preflight (request ที่เบราว์เซอร์ยิงไปถามก่อนเองโดยเราไม่ได้สั่ง) ผ่านสวยงามแต่ request จริงโดนบล็อก

🧰 เตรียมเครื่องก่อนอ่าน — ทุก output ในบทนี้มาจากการรันจริงบน

go version go1.26.5 darwin/arm64
github.com/gin-gonic/gin v1.12.0
github.com/go-playground/validator/v10 v10.30.1
github.com/gin-contrib/cors v1.7.7

ถ้าจะพิมพ์ตาม: สร้างโฟลเดอร์เปล่าใหม่ · cd เข้าไป · แล้วสั่งสามบรรทัดนี้ในโฟลเดอร์นั้น
go mod init check · go get github.com/gin-gonic/gin@v1.12.0 · go get github.com/gin-contrib/cors
ตัว validator/v10 ติดมากับ gin เอง ไม่ต้อง go get แยก — มันคือเครื่องมือที่อ่าน tag binding ให้เรา

1 · จาก c.Param มาถึง body — ShouldBindJSON ทำอะไรให้บ้าง

บทที่แล้วเราอ่านค่าจาก URL ด้วย c.Param ซึ่งได้ string เสมอ แต่ POST /recipes ส่งข้อมูลมาเป็น JSON ใน body — ก้อนไบต์ที่ยังไม่มีรูปร่าง ถ้าทำมือเองต้องเขียนสามขั้น: อ่านสตรีมออกมา → json.Unmarshal → ไล่เช็คทีละ field ว่าครบไหม

❌ ทำมือ — ยาวและลืมง่าย

var in Recipe
body, err := io.ReadAll(c.Request.Body)
if err != nil { /* 400 */ }
if err := json.Unmarshal(body, &in); err != nil {
    /* 400 */
}
if in.Title == "" { /* 400 */ }
if in.Servings < 1 { /* 400 */ }
// ...อีก 6 field

เช็คไม่ครบเมื่อไหร่ ข้อมูลพังก็ไหลลง database

✅ ให้ gin ทำให้

type Recipe struct {
    Title    string `json:"title" binding:"required"`
    Servings int    `json:"servings" binding:"min=1,max=20"`
}

var in Recipe
if err := c.ShouldBindJSON(&in); err != nil {
    c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
    return
}

กติกาย้ายไปอยู่บน struct — อ่านครั้งเดียวเห็นครบ

ShouldBindJSON ทำสองงานรวดเดียว และสองงานนี้ล้มเหลวได้คนละแบบ ซึ่งเป็นเรื่องที่ต้องแยกให้ออกตั้งแต่ต้น:

ขั้นใครทำtag ที่ใช้ชนิดของ error เมื่อพัง
1. แปลง JSON เป็น structencoding/jsonjson:"..."*json.UnmarshalTypeError / *errors.errorString
2. ตรวจกติกาของค่าvalidator/v10binding:"..."validator.ValidationErrors

ประเด็นที่ต้องจำจากตารางนี้: json กับ binding เป็นคนละ tag ทำคนละหน้าที่ · json:"title" บอกว่า "key ใน JSON ชื่อ title" (บทเรียน 13) ส่วน binding:"required" บอกว่า "ค่านี้ต้องผ่านกติกา" — ลบตัวหนึ่งทิ้ง อีกตัวยังทำงานอยู่

ผมรันให้ดูว่าสองแบบนี้ออกมาไม่เหมือนกันจริง (struct คือ ReqAge{ Age int `json:"age" binding:"required"` }):

📖 วิธีอ่านกล่องผลรันในบทนี้ — บรรทัด ===== 6b ... ===== คือชื่อเคสที่ ผมตั้งเองในโปรแกรมทดสอบ ไม่ใช่ข้อความจาก gin
ERR_TYPE = ชนิดของ error ที่ได้ · ERR_STR = ข้อความของ error ตัวนั้น · STATUS / BODY = สิ่งที่ client ได้รับจริง

// JSON พัง — ไม่ถึงขั้นตรวจกติกาเลย
===== 6 broken JSON syntax  POST /should  body={"name": =====
ERR_TYPE=*errors.errorString
ERR_STR=unexpected EOF

===== 6b wrong type  POST /should  body={"age":"abc"} =====
ERR_TYPE=*json.UnmarshalTypeError
ERR_STR=json: cannot unmarshal string into Go struct field ReqAge.age of type int

===== 6c empty body  POST /should =====
ERR_TYPE=*errors.errorString
ERR_STR=EOF

สังเกต EOF เฉย ๆ ในเคสสุดท้าย — นั่นคือ ผู้ใช้ยิงมาโดยไม่มี body เลย ถ้าคุณส่งข้อความนี้กลับไปตรง ๆ ฝั่ง frontend จะเห็นคำว่า EOF แล้วงงทั้งวัน (เดี๋ยวหัวข้อ 3 จะจัดการเรื่องนี้)

2 · หัวใจของบท — required ไม่ได้แปลว่า "ต้องส่งมา"

คำว่า required อ่านแล้วทุกคนแปลตรงกันว่า "ต้องมี field นี้ใน JSON"
ผิด — validator (ตัวตรวจกติกาที่ติดมากับ gin) ไม่รู้เลยว่า JSON เดิมมี key อะไรบ้าง มันเห็นแค่ struct หลัง unmarshal เสร็จแล้ว (unmarshal = ขั้นตอนแปลง JSON ให้กลายเป็น struct)
และมันตัดสินด้วยกฎเดียว: ค่านี้เป็น zero value ของชนิดตัวเองหรือเปล่า — zero value คือค่าที่ Go เติมให้เองเมื่อไม่มีใครกำหนดค่า: int เป็น 0 · string เป็น "" · bool เป็น false

ถ้าเป็น zero value → ไม่ผ่าน · จบ
ไม่สนว่ามันเป็นศูนย์เพราะ "ไม่ได้ส่งมา" หรือเพราะ "ส่งเลขศูนย์มาจริง ๆ" — สองอย่างนี้หน้าตาเหมือนกันเป๊ะในหน่วยความจำ

🧾 เทียบให้เห็นภาพ — ใบสั่งอาหารที่โรงพิมพ์พิมพ์เลข 0 ไว้ให้แล้ว

นึกถึงร้านอาหารที่ใช้ใบสั่งพิมพ์สำเร็จ ทุกช่องมีค่าพิมพ์ติดมาตั้งแต่โรงพิมพ์ — ช่องตัวเลขพิมพ์ 0 · ช่องข้อความพิมพ์ว่าง ("") · ช่องติ๊กพิมพ์ "ไม่ติ๊ก" (false) · ลูกค้าอยากได้เท่าไหร่ก็ขีดฆ่าแล้วเขียนทับ
พอใบเดินทางถึงครัว คนในครัวเห็นแค่ตัวเลขบนใบ — เขาไม่มีทางรู้ว่าเลข 0 ในช่อง "พริก" เป็นเลขที่ลูกค้าเขียนเอง (ตั้งใจสั่งไม่เผ็ด) หรือเป็นเลขที่ติดมากับกระดาษ (ลูกค้าไม่ได้แตะช่องนั้นเลย)

กฎของครัวคือ "ช่องไหนเป็น 0 = ยังสั่งไม่ครบ ตีกลับ" · ลูกค้าที่ตั้งใจสั่ง "พริก 0 เม็ด" จึงโดนตีกลับทุกครั้งทั้งที่กรอกมาถูกต้อง — นี่คือสิ่งที่ binding:"required" ทำเป๊ะ ๆ
ทางแก้ของร้านคือเปลี่ยนไปใช้ใบที่เป็นช่องว่างเปล่า ไม่พิมพ์เลขอะไรไว้ล่วงหน้า: ว่าง = ไม่ได้สั่ง · เขียน 0 = สั่งจริง สองอย่างนี้แยกออกจากกันได้แล้ว นี่คือสิ่งที่ pointer ทำให้เรา (เดี๋ยวลงรายละเอียดหัวข้อถัดไป)

ที่ไม่เหมือนกัน: ครัวเดินออกไปถามลูกค้าซ้ำได้ แต่ validator ทำไม่ได้เลย — มันเห็นแค่ struct ที่แปลงเสร็จแล้ว ไม่มีสิทธิ์ย้อนกลับไปดู JSON ต้นฉบับ
และ "ใบช่องว่างเปล่า" ก็ไม่ได้แยกทุกอย่างออกจากกัน: ถ้าลูกค้าเขียนคำว่า "ไม่เอา" ลงในช่อง (ฝั่ง client ส่ง "servings": null มาตรง ๆ) ครัวจะเห็นช่องนั้นเป็น nil เหมือนกับตอนลูกค้าไม่แตะช่องเลยเป๊ะ ๆ · pointer แยก "ส่ง 0 มา" ออกจาก "ไม่ได้ส่ง" ได้ แต่แยก "ไม่ได้ส่ง" ออกจาก "ส่ง null" ไม่ได้

ทายก่อนรัน — struct ของสูตรอาหารเป็นแบบนี้

type ReqRecipe struct {
    Title     string `json:"title" binding:"required"`
    Servings  int    `json:"servings" binding:"required"`
    Published bool   `json:"published" binding:"required"`
}

ผู้ใช้กรอกครบทุกช่องจริง ๆ แล้วกดบันทึกเป็นฉบับร่าง — body ที่ส่งมาคือ {"title":"ผัดกะเพรา","servings":2,"published":false}
ได้ 200 หรือ 400? แล้วถ้าเปลี่ยนเป็น "servings":0 ล่ะ? และ "title":"" ล่ะ?

ผมยิงจริงทั้งสี่เคสผ่าน httptest — เครื่องมือของ Go ที่ยิง request เข้า router ตรง ๆ ในโปรแกรมเดียวกัน ไม่ต้องเปิดพอร์ตจริง (บทเรียน 40 ลงลึก) — ได้แบบนี้:

POST /plain body={"title":"ผัดกะเพรา","servings":2,"published":true}
  -> STATUS=200 BODY={"title":"ผัดกะเพรา","servings":2,"published":true}
POST /plain body={"title":"","servings":2,"published":true}
  -> STATUS=400 BODY={"error":"Key: 'ReqRecipe.Title' Error:Field validation for 'Title' failed on the 'required' tag"}
POST /plain body={"title":"ผัดกะเพรา","servings":0,"published":true}
  -> STATUS=400 BODY={"error":"Key: 'ReqRecipe.Servings' Error:Field validation for 'Servings' failed on the 'required' tag"}
POST /plain body={"title":"ผัดกะเพรา","servings":2,"published":false}
  -> STATUS=400 BODY={"error":"Key: 'ReqRecipe.Published' Error:Field validation for 'Published' failed on the 'required' tag"}

ผ่านเคสเดียว — เคสที่บังเอิญไม่มี zero value เลย
ฉบับร่าง (published:false) บันทึกไม่ได้ตลอดกาล และไม่มีทางบันทึกได้ด้วย เพราะ false คือ zero value ของ bool
เช่นเดียวกับ "servings":0 ที่ผู้ใช้อาจตั้งใจส่ง และ "" ที่แปลว่า "ล้างค่าเดิมทิ้ง"

⚠️ อาการที่จะเจอใน production

bug แบบนี้ไม่โผล่ตอนเทสด้วยมือ เพราะเวลาเราลองเอง เราพิมพ์ข้อมูลสวย ๆ ที่ไม่มีศูนย์ ไม่มีค่าว่าง ไม่มี false
มันจะโผล่วันที่ลูกค้าคนแรกกด "ปิดการแจ้งเตือน" (false) หรือสั่ง "ส่วนลด 0 บาท" แล้วได้ 400 กลับมาโดยที่ log ฝั่งเราบอกว่า "required tag" — ซึ่งอ่านแล้วเหมือนผู้ใช้ผิด ทั้งที่ผู้ใช้กรอกครบ

แล้วมันแยกไม่ออกจริงหรือ? จริง — ผมยิง {"age":0} กับ {} เข้า struct เดียวกัน ได้ error ตัวเดียวกันเป๊ะทุกตัวอักษร:

===== 2 required + zero value  POST /should  body={"age":0} =====
ERR_STR=Key: 'ReqAge.Age' Error:Field validation for 'Age' failed on the 'required' tag

===== 2b required + missing field  POST /should  body={} =====
ERR_STR=Key: 'ReqAge.Age' Error:Field validation for 'Age' failed on the 'required' tag

ทางแก้: pointer

*int มี zero value เป็น nil ไม่ใช่ 0 — และ nil เกิดขึ้นได้ทางเดียวคือ JSON ไม่มี key นั้นเลย (บทเรียน 7)
พอ key มีอยู่จริง encoding/json จะจองที่ให้ค่าแล้วชี้ไปหา แม้ค่านั้นจะเป็น 0 ก็ตาม → pointer ไม่ nil → required ผ่าน

body ที่ส่งมา            Servings int          Servings *int
─────────────────────    ─────────────────     ────────────────────
{"servings": 2}          2    ✅ ผ่าน           &2   ✅ ผ่าน
{"servings": 0}          0    ❌ ไม่ผ่าน  ←     &0   ✅ ผ่าน  ← จุดที่ต่างกัน
{}  (ไม่ส่งมาเลย)         0    ❌ ไม่ผ่าน         nil  ❌ ไม่ผ่าน
                         └─ สองแถวล่างแยกไม่ออก   └─ แยกออก

รันจริงด้วย struct ชุดเดิมแต่เปลี่ยนเป็น pointer ทั้งสามช่อง:

type ReqRecipePtr struct {
    Title     *string `json:"title" binding:"required"`
    Servings  *int    `json:"servings" binding:"required"`
    Published *bool   `json:"published" binding:"required"`
}
POST /ptr body={"title":"ผัดกะเพรา","servings":0,"published":false}
  -> STATUS=200 BODY={"published":false,"servings":0,"title":"ผัดกะเพรา"}

POST /ptr body={"servings":0,"published":false}          # ไม่มี title เลย
  -> STATUS=400 BODY={"error":"Key: 'ReqRecipePtr.Title' Error:Field validation for 'Title' failed on the 'required' tag"}

ได้สิ่งที่ต้องการพอดี — "ส่ง 0 มาจริง" ผ่าน · "ไม่ส่งมาเลย" ไม่ผ่าน
ราคาที่จ่ายคือโค้ดหลังจากนั้นต้องเขียน *in.Servings (อ่านค่าที่ pointer ชี้อยู่) ทุกครั้ง
และถ้าเผลอใส่ * กับ field ที่ไม่ได้ใส่ required ไว้ ตอนที่ผู้ใช้ไม่ส่ง key นั้นมา ค่ามันจะเป็น nil แล้วโปรแกรม panic ทันที (panic = โปรแกรมหยุดกลางคันเพราะเจอสิ่งที่ไปต่อไม่ได้)

🧭 ใช้ pointer ตอนไหน — อย่าเปลี่ยนทุก field เป็น pointer เพราะเท่ ใช้เมื่อ zero value เป็นค่าที่มีความหมายจริง เท่านั้น: bool ทุกตัว · จำนวนที่เป็น 0 ได้ (ส่วนลด, ยอดคงเหลือ) · string ที่ล้างค่าได้
ส่วน Title ที่ยังไงก็ห้ามว่าง ใช้ string ธรรมดา + required ก็จบ — ตรงนั้น "zero value = ผิด" เป็นความจริงพอดีอยู่แล้ว
ทางเลือกอื่นสำหรับตัวเลขคือใช้ binding:"min=0" แทน required (ไม่ต้อง pointer) — มันกันค่าติดลบให้ แต่ไม่บังคับว่า key ต้องมี ดังนั้น "ไม่ส่งมาเลย" จะกลายเป็น 0 เงียบ ๆ ซึ่งอาจไม่ใช่สิ่งที่คุณต้องการ

🗣 ศัพท์ที่เพิ่งเรียนไป

สี่คำนี้ทำให้คุณถามได้ตรงจุด แทนที่จะถามว่า "ทำไม API ไม่ยอมรับข้อมูลผม"

zero value
ค่าที่ Go เติมให้เองเมื่อไม่มีใครกำหนดค่า — int ได้ 0 · string ได้ "" · bool ได้ false · pointer ได้ nil
พูดกับ senior ว่า “field นี้ตกเพราะ zero value ชนกับ required” จะสื่อได้ในประโยคเดียวว่าเจอปัญหาอะไร
unmarshal / marshal
unmarshal = แปลง JSON เป็น struct · marshal = แปลง struct กลับเป็น JSON · ประโยคที่ใช้จริงเวลา debug: “มันพังตอน unmarshal หรือพังตอน validate” — เพราะสองขั้นนี้คนละเรื่องกันตามตารางในหัวข้อ 1
validation tag
ข้อความหลังชนิดของ field ที่บอกกติกา เช่น binding:"required,email" · เวลาถาม AI ให้พิมพ์ว่า “gin binding แยก ‘ส่ง 0 มา’ กับ ‘ไม่ได้ส่ง key มา’ ยังไง” จะได้คำตอบตรงกว่าถามว่า “ทำไม required ไม่ทำงาน”
อย่าไปถามหา tag ที่เช็ค “key มีอยู่จริงใน JSON ไหม” — validator/v10 ไม่มี tag แบบนั้น เพราะมันทำงานหลัง unmarshal ตามที่พิสูจน์ไปในหัวข้อ 2 · ถามแบบนั้น AI มักเดาชื่อ tag ปลอมมาให้
zero value vs absent
ชื่อของปัญหาทั้งหัวข้อนี้: แยก “ส่งค่าศูนย์มาจริง” ออกจาก “ไม่ได้ส่งมาเลย” · ทางแก้มาตรฐานของ Go คือ pointer field (*int, *bool) เพราะมันมีสถานะ nil เพิ่มมาอีกหนึ่งสถานะ · ภาษาอื่นแก้ปัญหาเดียวกันด้วยกลไกคนละตัว — Java ใช้ Optional · TypeScript แยก undefined กับ null · C# ใช้ nullable · Rust ใช้ Option · อย่าไปบอกทีมที่ไม่ได้เขียน Go ว่า “ต้องใช้ pointer”
ข้อจำกัดที่ต้องรู้: nil ไม่ได้แปลว่า “ไม่ได้ส่งมา” เสมอไป — ถ้า client ส่ง {"servings": null} มาตรง ๆ ก็ได้ nil เหมือนกันเป๊ะ (ผมยิงทั้งสองเคสแล้ว ได้ 400 เหมือนกันทั้งคู่) · ถ้าต้องแยกให้ครบสามสถานะจริง ๆ ต้องใช้ json.RawMessage หรือเขียน UnmarshalJSON เอง · พิมพ์คำว่า zero value vs absent ถาม AI ได้ตรง ๆ เลย

3 · tag ที่ใช้จริง และ error ที่ห้ามส่งให้ผู้ใช้ตรง ๆ

required เป็นแค่ตัวเดียวใน tag หลายสิบตัวของ validator/v10 สามตัวที่เจอบ่อยที่สุดในงาน API จริงคือ email · min/max · oneof

type ReqEmail struct {
    Email string `json:"email" binding:"required,email"`
}

type ReqRange struct {
    Level      int    `json:"level" binding:"min=1,max=5"`
    Difficulty string `json:"difficulty" binding:"oneof=easy medium hard"`
}

หลาย tag ต่อกันด้วย comma และตรวจตามลำดับซ้ายไปขวา — ตัวแรกที่ตกคือตัวที่รายงาน ผมรันเทียบสองเคสให้ดู:

===== 4 email invalid  POST /email  body={"email":"not-an-email"} =====
ERR_STR=Key: 'ReqEmail.Email' Error:Field validation for 'Email' failed on the 'email' tag

===== 4b email empty  POST /email  body={} =====
ERR_STR=Key: 'ReqEmail.Email' Error:Field validation for 'Email' failed on the 'required' tag

ค่าว่างติด required ก่อน ไม่เดินไปถึง email — นี่คือเหตุผลที่ควรเขียน required ไว้หน้าสุดเสมอ ไม่งั้นข้อความ error จะบอกว่า "รูปแบบอีเมลผิด" ทั้งที่ผู้ใช้แค่ยังไม่ได้กรอก

และเมื่อผิดหลาย field พร้อมกัน validator รายงานทุกตัว ต่อกันด้วย \n ในสตริงเดียว:

===== 5 min/max + oneof  POST /range  body={"level":9,"difficulty":"insane"} =====
ERR_STR=Key: 'ReqRange.Level' Error:Field validation for 'Level' failed on the 'max' tag
Key: 'ReqRange.Difficulty' Error:Field validation for 'Difficulty' failed on the 'oneof' tag
STATUS=400
BODY={"error":"Key: 'ReqRange.Level' Error:Field validation for 'Level' failed on the 'max' tag\nKey: 'ReqRange.Difficulty' Error:Field validation for 'Difficulty' failed on the 'oneof' tag"}
⚠️ อ่าน body ตัวสุดท้ายอีกรอบ — นี่คือสิ่งที่ผู้ใช้ได้รับ

มันมีชื่อ struct ภายในของเรา (ReqRange) · มีคำว่า tag · มี \n โผล่กลางสตริง JSON
และที่แย่ที่สุด: มันไม่บอกว่าอะไรถึงจะถูก — ไม่มีคำว่า easy medium hard อยู่ในข้อความเลยสักตัว ทั้งที่เราเขียนไว้ใน tag ชัด ๆ
ผู้ใช้จึงรู้แค่ว่า "ผิด" แต่ไม่รู้ว่าต้องแก้เป็นอะไร ส่วนคนโจมตีได้ผังภายในของเราไปฟรี ๆ

ทางออกคือ อย่าโยน err.Error() ออกไปตรง ๆ ใน production ให้แปลงเป็นข้อความของเราเองก่อน · validator.ValidationErrors เป็น slice (รายการที่เก็บของหลายชิ้นเรียงกัน) จึงวนอ่านทีละตัวได้ และแยกออกจาก parse error ได้ด้วย errors.As (บทเรียน 12)
โค้ดข้างล่างต้อง import สองตัวเพิ่ม: errors ของ Go เอง และ github.com/go-playground/validator/v10 (ตัวที่ติดมากับ gin อยู่แล้ว ไม่ต้อง go get):

var ve validator.ValidationErrors
if errors.As(err, &ve) {
    out := make(map[string]string, len(ve))
    for _, fe := range ve {
        out[fe.Field()] = fe.Tag()   // "Difficulty" -> "oneof"
    }
    c.JSON(http.StatusBadRequest, gin.H{"fields": out})
    return
}
// มาถึงตรงนี้ = JSON เองพัง ไม่ใช่ค่าไม่ผ่านกติกา
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"})

ผมรันโค้ดชุดนี้กับ ReqRange จริง ๆ สี่เคส ได้ response ที่ผู้ใช้อ่านรู้เรื่องแล้ว:

body={"level":9,"difficulty":"insane"} -> 400 {"fields":{"Difficulty":"oneof","Level":"max"}}
body={"level":0,"difficulty":"easy"}   -> 400 {"fields":{"Level":"min"}}
body={"level":3,"difficulty":"hard"}   -> 200 {"ok":true}
body={"title":                         -> 400 {"error":"invalid JSON body"}   ← ไปทางอื่น

สังเกตบรรทัดสุดท้าย: JSON ที่พังตกไปคนละทางกับค่าที่ไม่ผ่านกติกาแล้ว
สังเกตบรรทัดที่สอง: level=0 ติด 'min' ไม่ใช่ 'required' — เพราะชุดนี้ผมไม่ได้ใส่ required ไว้ · min=1 ทำหน้าที่กันศูนย์ให้อยู่แล้ว ซึ่งเป็นทางเลือกที่ตรงกว่า required เมื่อสิ่งที่คุณหมายถึงจริง ๆ คือ "ต้องมากกว่าศูนย์"

📌 ทำไมต้องแยกสองทาง — เพราะสองอย่างนี้คนละความหมายสำหรับคนเรียกใช้ API
"JSON พัง" = โปรแกรมฝั่งคุณเขียนผิด แก้ที่โค้ด · "ค่าไม่ผ่านกติกา" = โปรแกรมถูกแล้ว แต่ข้อมูลที่ผู้ใช้กรอกยังไม่ได้ ต้องเอาไปแสดงใต้ช่องกรอก
ยัดสองอย่างนี้ใน error ก้อนเดียวเมื่อไหร่ ฝั่ง frontend แยกไม่ออกทันที

4 · ShouldBind* vs Bind* — และ query/uri ใช้ tag คนละตัว

Gin มีเมธอด bind สองตระกูลที่ทำงานเหมือนกันเป๊ะ ต่างกันแค่ตอนที่มัน error:
ShouldBindJSON คืน error แล้วไม่แตะ response เลย — เราตัดสินใจเองทั้งหมด
BindJSON คืน error แล้วเขียน 400 ให้เสร็จสรรพ พร้อม c.Abort() ให้ด้วย

ฟังดูเหมือน BindJSON สะดวกกว่า จนกระทั่งเราเขียน response ต่อจากมัน
handler ที่ผมใช้ทดสอบหน้าตาแบบนี้ — นี่คือเคส 1c · เคส 1b ต่างแค่เปลี่ยน 422 เป็น 400 · เคส 1d คือลบทั้งก้อน if ทิ้ง เรียก BindJSON แล้วจบเลยไม่เขียนอะไรต่อ:

r.POST("/bind422", func(c *gin.Context) {
    if err := c.BindJSON(&ReqAge{}); err != nil {
        c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
        return
    }
    c.JSON(http.StatusOK, gin.H{"ok": true})
})

ยิง {"age":0} เข้าไปทั้งสามเคส — ReqAge ใส่ required ไว้ ค่า 0 จึงไม่ผ่านตามหัวข้อ 2 · ในผลรัน WrittenBefore คือค่าจาก c.Writer.Written() ที่แปลว่า "มีคนเขียน response ลงสายไปแล้วหรือยัง":

===== 1b BindJSON + c.JSON(400)  body={"age":0} =====
IsAborted=true WrittenBefore=true Status=400
STATUS=400
BODY="{\"error\":\"Key: 'ReqAge.Age' Error:Field validation for 'Age' failed on the 'required' tag\"}"

===== 1c BindJSON + c.JSON(422)  body={"age":0} =====
[GIN-debug] [WARNING] Headers were already written. Wanted to override status code 400 with 422
STATUS=400
BODY="{\"error\":\"Key: 'ReqAge.Age' Error:Field validation for 'Age' failed on the 'required' tag\"}"

===== 1d BindJSON only, handler returns  body={"age":0} =====
STATUS=400
CTYPE=""
BODY=""

เคส 1b ดูปกติดี — แต่ปกติเพราะบังเอิญเราขอ 400 ซึ่งตรงกับที่ gin เขียนไปแล้วพอดี
เคส 1c คือของจริง: ขอ 422 แต่ได้ 400 เพราะ HTTP status เขียนลงสายได้ครั้งเดียว พอ BindJSON เขียนไปแล้ว ใครมาทีหลังก็เปลี่ยนไม่ได้ ได้แค่ warning ปลอบใจ
เคส 1d คือสิ่งที่เกิดถ้าเราคิดว่า "gin จัดการให้แล้ว ไม่ต้องเขียนอะไรต่อ" — ผู้ใช้ได้ 400 กับ body ว่างเปล่า ไม่มีคำอธิบายสักตัว

⚠️ warning นี้เห็นเฉพาะ debug mode

gin มีสองโหมด: debug (โหมด default ตอนคุณ go run เฉย ๆ — พิมพ์บรรทัด [GIN-debug] ให้เพียบ) กับ release (สั่งเปิดด้วย gin.SetMode(gin.ReleaseMode) ซึ่งคือสิ่งที่ทำบน production)
บรรทัด [GIN-debug] [WARNING] ข้างบนออกมาจาก debugPrint ซึ่งเงียบสนิทใน release mode — แต่ผลลัพธ์เหมือนเดิมทุกประการ:

== release mode: BindJSON + c.JSON(422) ==
STATUS=400 BODY="{\"error\":\"Key: 'ReqAge.Age' Error:Field validation for 'Age' failed on the 'required' tag\"}"

แปลว่าบน production คุณจะได้ status ผิดโดยไม่มีอะไรเตือนเลย

ทำไมถึงเป็นแบบนั้น? BindJSON เป็นแค่เปลือกบาง ๆ ที่ส่งต่อให้ MustBindWith ทำงานจริง ซึ่งข้างในเป็นแบบนี้ (จาก context.go ของ gin v1.12.0):

func (c *Context) MustBindWith(obj any, b binding.Binding) error {
    err := c.ShouldBindWith(obj, b)
    if err != nil {
        // ...
        c.AbortWithError(http.StatusBadRequest, err).SetType(ErrorTypeBind)
        return err
    }
    return nil
}

บรรทัดที่ทำให้เกิดเรื่องทั้งหมดคือ c.AbortWithError(http.StatusBadRequest, err) — มันเขียน status 400 ลงสายไปเรียบร้อยตั้งแต่ตอนนั้น (คำว่า Abort แปลว่าอะไรกันแน่ ค่อยเจาะในหัวข้อ 6)

กติกาที่จำง่ายที่สุดของบทนี้: ใช้ ShouldBind* เสมอ
ตระกูล Bind* ตัดสินใจแทนคุณไปแล้วว่าจะตอบ 400 เปล่า ๆ ซึ่งไม่ใช่ response ที่ API ที่ดีควรให้ · การที่มันสั้นกว่าไม่คุ้มกับการเสียสิทธิ์เลือก status และ body

bind จาก query กับ URI — tag เปลี่ยนไปอีก

ข้อมูลไม่ได้มาแค่ทาง body · GET /recipes?q=go&page=2 ส่งมาทาง query string และ GET /recipes/42 ส่งมาทาง path · gin แยกเมธอดให้คนละตัว และดู tag คนละชื่อ ซึ่งเป็นจุดที่คนใหม่เสียเวลามากที่สุด

ข้อมูลมาจากเมธอดtag ที่ต้องใส่ถ้าใส่ tag ผิด
JSON bodyc.ShouldBindJSONjson:"..."field ไม่ถูกเติม
query stringc.ShouldBindQueryform:"..."field ไม่ถูกเติม
path paramc.ShouldBindUriuri:"..."field ไม่ถูกเติม

ที่หลอกคือ "field ไม่ถูกเติม" ไม่ใช่ compile error และไม่ใช่ error ตอนรันด้วย — มันแค่เงียบ ๆ ปล่อยให้ค่าเป็น zero value แล้วไปตกที่ required ทีหลัง ทำให้ข้อความ error ชี้ไปผิดที่:

===== 7 ShouldBindQuery form tag  GET /query?q=go&page=2 =====
STATUS=200
BODY="{\"page\":2,\"q\":\"go\"}"

===== 7c ShouldBindQuery with json tag only  GET /queryjsontag?q=go =====
ERR_STR=Key: 'ReqQueryJSONTag.Q' Error:Field validation for 'Q' failed on the 'required' tag
STATUS=400

===== 7d ShouldBindUri uri tag  GET /uri/42/hello =====
STATUS=200
BODY="{\"id\":\"42\",\"slug\":\"hello\"}"

===== 7e ShouldBindUri with form tag only  GET /urifromtag/42 =====
ERR_STR=Key: 'ReqUriFormTag.ID' Error:Field validation for 'ID' failed on the 'required' tag
STATUS=400

เคส 7c คือ ?q=go ที่ส่งมาถูกต้องทุกอย่าง แต่ server บอกว่า "ไม่ได้ส่ง q มา" เพราะ struct เขียน json:"q" แทน form:"q" — debug ตรงนี้ยากมากถ้าไม่รู้ว่ามันมี tag คนละตัว

🔍 เกร็ดสองข้อที่ตรวจแล้วเซอร์ไพรส์
1. c.ShouldBind() (ไม่มี JSON ต่อท้าย) ดู Content-Type ก่อนเลือกวิธี bind — ยิง JSON มาแต่ header เป็น text/plain มันจะไป fallback เป็น form แล้วได้ zero value ทั้งก้อน จบที่ 400 ทั้งที่ JSON ถูกทุกตัวอักษร
ส่วน ShouldBindJSON ไม่สน Content-Type เลย เคสเดียวกันได้ STATUS=200 BODY="{\"age\":30}"
2. เรียก ShouldBindJSON สองครั้งใน handler เดียว ครั้งที่สองได้ EOF (first=<nil> second err=EOF) เพราะ body เป็นสตรีมที่อ่านได้หนเดียว
ถ้าจำเป็นต้องอ่านซ้ำ (เช่นเดา type จาก field เดียวก่อน) ใช้ c.ShouldBindBodyWith(&obj, binding.JSON) ซึ่ง cache body ไว้ให้ (binding ตรงนี้คือ package github.com/gin-gonic/gin/binding ต้อง import เพิ่มอีกบรรทัด) — ผมรันแล้วได้ first=<nil> second=<nil> a.Age=30 b.Age=30

5 · middleware — handler ที่ต่อคิว และ c.Next() คือการปล่อยคิวเดินต่อ

ทุก API มีงานที่ต้องทำกับทุก request: บันทึก log · เช็ค token · จับเวลา · ใส่ request ID ถ้าก๊อปโค้ดพวกนี้ไปวางในทุก handler ก็จะลืมสักตัวแน่นอน — middleware คือที่ที่งานพวกนี้ควรอยู่

หน้าตาของ middleware ไม่ต่างจาก handler เลยแม้แต่นิดเดียว: มันคือ func(c *gin.Context) ตัวหนึ่ง · สิ่งเดียวที่ทำให้มันเป็น "middleware" คือมันถูก ลงทะเบียนด้วย r.Use() ให้อยู่ในคิวก่อน handler จริง

gin เก็บทุกอย่างที่ต้องรันสำหรับ route หนึ่งไว้เป็นคิวเดียว (slice ของฟังก์ชัน) แล้วเดินทีละตัว · c.Next() แปลว่า "หยุดฉันไว้ตรงนี้ ไปรันตัวถัดไปในคิวจนจบก่อน แล้วค่อยกลับมาทำบรรทัดถัดไปของฉัน"
ผลคือโค้ดเหนือ c.Next() รันขาไป และโค้ดใต้ c.Next() รันขากลับ — ซึ่งเป็นเหตุผลว่าทำไม middleware ถึงจับเวลาทั้ง request ได้

ทายก่อนรัน — r.Use(A) แล้ว r.Use(B) ตามลำดับนี้

func A(c *gin.Context) {
    fmt.Println("A: before Next")
    c.Next()
    fmt.Println("A: after Next")
}
func B(c *gin.Context) {
    fmt.Println("B: before Next")
    c.Next()
    fmt.Println("B: after Next")
}

r.Use(A)
r.Use(B)
r.GET("/ping", func(c *gin.Context) {
    fmt.Println("HANDLER: /ping")
    c.String(200, "pong")
})

ยิง GET /ping หนึ่งครั้ง — ห้าบรรทัดนี้พิมพ์ออกมาเรียงยังไง?
(คำใบ้: มันไม่ใช่ A ทั้งตัวจบแล้วค่อย B)

$ go run ./order
A: before Next
B: before Next
HANDLER: /ping
B: after Next
A: after Next
--- status: 200 body: pong

เป็นหัวหอม ไม่ใช่รายการงานเรียงต่อกัน — ตัวที่ Use() ก่อนอยู่ชั้นนอกสุด เข้าจากนอกเข้าใน แล้วออกจากในออกนอก

            ขาไป (บรรทัดเหนือ c.Next)          ขากลับ (บรรทัดใต้ c.Next)
request ──▶ ┌──────────── A ────────────────────────────────────┐ ──▶ response
            │ "A: before"                          "A: after"   │
            │  ┌───────── B ─────────────────────────────────┐  │
            │  │ "B: before"                     "B: after"  │  │
            │  │   ┌────── handler ──────────────────────┐   │  │
            │  │   │        "HANDLER: /ping"             │   │  │
            │  │   └─────────────────────────────────────┘   │  │
            │  └─────────────────────────────────────────────┘  │
            └───────────────────────────────────────────────────┘
              ลำดับจริง:  A before → B before → HANDLER → B after → A after
🏢 เทียบให้เห็นภาพ — เดินเข้าตึกออฟฟิศ แล้วเดินกลับออกมา

เข้าตึกออฟฟิศทีหนึ่งคุณผ่านหลายด่านกว่าจะถึงที่หมาย: รปภ. หน้าประตูแลกบัตรให้ → พนักงานต้อนรับชั้นล่างจดชื่อลงสมุด → ถึงจะได้เข้าห้องประชุมที่คุณตั้งใจมา
ขากลับคุณเดินย้อนทางเดิมทุกด่าน: ออกจากห้องประชุม → บอกลาพนักงานต้อนรับ → คืนบัตรให้ รปภ. ที่ประตู แล้วถึงจะออกจากตึกได้

สังเกตว่า รปภ. ที่เจอเป็นคนแรก กลายเป็นคนสุดท้ายที่คุณเจอตอนออก — ตรงกับผลรันข้างบนเป๊ะ (A ก่อน → B ก่อน → handler → B หลัง → A หลัง)
ส่วน c.Next() คือประโยคที่ รปภ. พูดว่า "เชิญเข้าไปข้างในก่อนครับ เดี๋ยวขากลับค่อยแวะคืนบัตร" — เขาไม่ได้เลิกงาน เขายืนค้างอยู่ตรงนั้นจนคุณเดินกลับออกมา
และเหตุผลที่ รปภ. บอกได้ว่าคุณอยู่ในตึกกี่นาที ก็เพราะเขาอยู่ทั้งตอนคุณเข้าและตอนคุณออก — เหมือน middleware ที่จับเวลาทั้ง request ได้

ที่ไม่เหมือนกัน: รปภ. ตัวจริงปล่อยคุณเข้าไปแล้วเดินไปทำอย่างอื่นต่อได้ แต่ middleware ค้างอยู่ที่บรรทัด c.Next() จริง ๆ รอจนคิวที่เหลือทำงานจบก่อน ถึงจะเดินบรรทัดถัดไปของตัวเอง
อีกอย่าง: ตึกมี รปภ. ชุดเดียวใช้กับทุกคนที่เดินเข้ามา แต่ gin ก๊อปคิวแยกให้แต่ละ route ตั้งแต่ตอนลงทะเบียน route นั้น — ซึ่งเป็นที่มาของกับดักท้ายหัวข้อ 6

รูปหัวหอมนี้อธิบายทุกอย่างที่ middleware ทำได้ในประโยคเดียว: ขาไปคือ "ก่อนรู้คำตอบ" ขากลับคือ "หลังรู้คำตอบแล้ว"
เช็ค token ต้องอยู่ขาไป (ต้องกันไว้ก่อน) · จับเวลาและ log status ต้องคร่อมทั้งสองขา:

func Timing() gin.HandlerFunc {
    return func(c *gin.Context) {
        start := time.Now()          // ขาไป: ยังไม่รู้ว่า handler จะตอบอะไร
        c.Next()
        // ขากลับ: ตรงนี้ handler ทำงานจบแล้ว จึงอ่าน status ได้
        log.Printf("%s %s -> %d in %v",
            c.Request.Method, c.Request.URL.Path,
            c.Writer.Status(), time.Since(start))
    }
}

📌 ทำไมต้อง func() gin.HandlerFunc ครอบอีกชั้น — เพื่อรับ config เข้ามาตอนสร้าง เช่น Auth(secret string) หรือ Timing(logger *slog.Logger) · ตัวที่ไม่ต้องการ config จะเขียนเป็น func(c *gin.Context) ตรง ๆ แล้วส่งเข้า r.Use() ก็ได้ ทั้งสองแบบใช้ได้เหมือนกัน
อีกเรื่อง: ถ้า middleware ของคุณไม่เรียก c.Next() เลย แต่ก็ไม่ได้ Abort() — gin จะเดินคิวต่อให้เองหลังฟังก์ชันคุณจบ c.Next() จึงจำเป็นเฉพาะตอนที่คุณมีโค้ดที่ต้องรันขากลับ

🗣 ศัพท์ที่เพิ่งเรียนไป
middleware
"ด่าน" ที่ทุก request ต้องผ่านก่อนถึง handler จริง · หน้าตาเหมือน handler ทุกประการ — ต่างกันที่ตำแหน่งในคิว ไม่ใช่ชนิดของฟังก์ชัน · ผูกได้ทั้ง r.Use() (ทั้งเซิร์ฟเวอร์) · group.Use() (เฉพาะกลุ่ม อย่างหัวข้อ 6) · หรือใส่ต่อ route ตรง ๆ เป็น r.GET("/x", mw, handler) โดยตัวสุดท้ายในลิสต์คือ handler จริง
ประโยคที่ใช้จริง: “เอา auth ไปทำเป็น middleware ดีกว่าก๊อปไปทุก handler”
handler chain (คิวของ handler)
รายชื่อฟังก์ชันทั้งหมดที่ต้องรันสำหรับ route หนึ่ง เรียงกันเป็นคิวเดียว · เวลาไล่บั๊กให้ถามว่า “route นี้ chain มีใครอยู่บ้าง และเรียงยังไง”
onion model (รูปหัวหอม)
ชื่อที่วงการใช้เรียกลำดับ เข้าจากนอกเข้าใน–ออกจากในออกนอก อย่างในภาพข้างบน · คำที่คู่กันคือ pre-processing (โค้ดเหนือ c.Next()) และ post-processing (โค้ดใต้ c.Next())
request scope
"ของที่มีอายุแค่ request นี้ครั้งเดียว" เช่น request ID หรือข้อมูลผู้ใช้ที่เพิ่งตรวจ token เสร็จ · ใน gin เก็บด้วย c.Set / c.Get
ถ้าจะถามว่าเก็บตัวแปรตรงไหนดี ให้พูดว่า “ค่านี้เป็น request-scoped หรือ global”

6 · c.Abort() — จุดที่คนใหม่พลาดเยอะที่สุด

middleware ที่กันคนแปลกหน้าต้องทำสองอย่าง: ตอบว่าทำไมไม่ให้ผ่าน และ สั่งหยุดคิว · ถ้าทำอย่างเดียวจะพังคนละแบบ และทั้งสองแบบเงียบมาก

ผมรันครบสี่เคสด้วยคิว A (ชั้นนอก) → B (ชั้นใน) → handler · A คือตัวที่พิมพ์ before Next / after Next คร่อมไว้เฉย ๆ ส่วน B คือตัวที่ทำหน้าที่ปฏิเสธ ซึ่งเปลี่ยนไปทีละเคส
(ในเคส 3 ไม่มีบรรทัด B: ขึ้นมาเพราะโค้ดของ B เคสนั้นมีแค่ c.AbortWithStatus(401) บรรทัดเดียว ไม่ได้สั่งพิมพ์อะไร — ไม่ใช่ว่า B ไม่ทำงาน):

========= CASE 1: c.Abort() alone, no response written =========
A : before Next
B: aborting with plain c.Abort()
A : after Next  (IsAborted = true )
--- status=200 contentType="" bodyLen=0 body=""

========= CASE 2: c.AbortWithStatusJSON(401, ...) =========
A : before Next
B: AbortWithStatusJSON(401)
A : after Next  (IsAborted = true )
--- status=401 contentType="application/json; charset=utf-8" bodyLen=24 body="{\"error\":\"unauthorized\"}"

========= CASE 3: c.AbortWithStatus(401) (no body) =========
A : before Next
A : after Next  (IsAborted = true )
--- status=401 contentType="" bodyLen=0 body=""

========= CASE 4: c.JSON(401) WITHOUT Abort (classic bug) =========
A : before Next
B: c.JSON(401) but forgot Abort
HANDLER RAN  <-- yes, it does
A : after Next  (IsAborted = false )
--- status=401 contentType="application/json; charset=utf-8" bodyLen=26 body="{\"error\":\"unauthorized\"}ok"

ยืนยันอีกชั้นด้วยการเปิด server จริงบนพอร์ตหนึ่ง แล้วยิงด้วย curl ผ่านเน็ตเวิร์กจริง — ไม่ใช่แค่ httptest ที่เรียกกันอยู่ในโปรแกรมเดียวกัน (curl -i คือสั่งให้พิมพ์ header ของ response ออกมาด้วย):

❌ c.Abort() เฉย ๆ

$ curl -s -i http://127.0.0.1:39181/abort-only
HTTP/1.1 200 OK
Date: Thu, 20 Aug 2026 14:49:43 GMT
Content-Length: 0

ปฏิเสธสำเร็จ แต่ client เห็น 200 OK — frontend จะแปลว่า "สำเร็จแต่ไม่มีข้อมูล"

✅ c.AbortWithStatusJSON(401, ...)

$ curl -s -i http://127.0.0.1:39181/abort-json
HTTP/1.1 401 Unauthorized
Content-Type: application/json; charset=utf-8
Date: Thu, 20 Aug 2026 14:49:43 GMT
Content-Length: 24

{"error":"unauthorized"}

บอกทั้งรหัสและเหตุผล · หยุดคิวให้ด้วยในคำสั่งเดียว

⚠️ สองบรรทัดที่ต้องจำให้ขึ้นใจ

Abort() = หยุดคิว · ไม่ใช่ = ตอบ error — มันไม่เขียน status ให้เลย ค่า default ของ net/http คือ 200 จึงได้ 200
c.JSON(401) = ตอบ error · ไม่ใช่ = หยุดคิว — CASE 4 คือหลักฐาน: HANDLER RAN ขึ้นจริง แล้ว body สองก้อนต่อกันเป็น {"error":"unauthorized"}ok ซึ่งไม่ใช่ JSON ที่ parse ได้อีกต่อไป
ใน middleware ให้ใช้ c.AbortWithStatusJSON(...) เป็น default เสมอ

อีกจุดที่ต้องรู้: Abort() หยุดเฉพาะสิ่งที่ยังไม่ได้รัน ส่วนโค้ดใต้ c.Next() ของ middleware ชั้นนอกยังทำงานเสมอ (ในผลรันข้างบน A : after Next ขึ้นทุกเคส) — เพราะ A ค้างรออยู่ตรงนั้นอยู่แล้ว
นี่คือเหตุผลที่ logger middleware ยังบันทึก request ที่โดน auth ปฏิเสธได้ครบ และเป็นเหตุผลที่โค้ดขากลับควรเช็ค c.IsAborted() ก่อนทำงานหนัก

ส่งของจาก middleware ไปให้ handler: c.Set / c.Get

middleware ที่ตรวจ token แล้วรู้ว่าเป็นใคร ต้องส่งข้อมูลนั้นต่อให้ handler · ช่องทางคือ c.Set(key, value) แล้วอีกฝั่ง c.Get(key) ซึ่งเก็บเป็น any จึงต้อง type-assert เอง (บทเรียน 11)

$ go run ./ctxdata
=== GET /ok ===
Get("user")     -> value=main.User{Name:"bingza"} exists=true
Get("nope")     -> value=<nil> exists=false
MustGet("user") -> main.User{Name:"bingza"}
MustGet("user").(string) comma-ok -> "" ok=false

=== GET /panic ===
RECOVERED panic type=string value=key missing-key does not exist

c.Get คืน (any, bool) — ไม่เจอก็แค่ nil, false
c.MustGet ถ้าไม่มี key จะ panic ด้วยข้อความ key missing-key does not exist (missing-key คือชื่อ key ที่เราเรียกหาในการรันนั้น ไม่ใช่คำคงที่ของ gin) · ถ้ามี gin.Recovery() อยู่ (ซึ่ง gin.Default() ใส่ให้) panic นี้จะกลายเป็น 500 body ว่าง
ใช้ MustGet ได้เมื่อคุณแน่ใจว่า middleware ที่ Set ค่านั้นถูกผูกไว้กับ route นี้แน่นอน — ซึ่งพาไปสู่เรื่องถัดไปพอดี

middleware ผูกกับ group ไม่ใช่ผูกกับเวลา

r.Group จาก บทเรียน 38 ไม่ได้ใช้แค่ตัด prefix ซ้ำ ๆ — มันมี Use() เป็นของตัวเอง ทำให้ผูก middleware เข้ากับเฉพาะ route ในกลุ่มนั้นได้
โค้ดที่ผมรันเป็นแบบนี้:

r := gin.New()
r.GET("/public", /* ... */)

admin := r.Group("/admin")
admin.Use(func(c *gin.Context) {          // ผูกกับ group นี้ตัวเดียว
    fmt.Println("  [authMW] ran for", c.Request.URL.Path)
    if c.GetHeader("X-Token") != "secret" {
        c.AbortWithStatusJSON(401, gin.H{"error": "no token"})
        return
    }
    c.Next()
})
admin.GET("/stats", /* ... */)

r.GET("/other", /* ... */)     // เขียนทีหลัง admin.Use แต่คนละ group
$ go run ./group
=== GET /public (X-Token="") ===
  -> status=200 body="public ok"

=== GET /other (X-Token="") ===
  -> status=200 body="other ok"

=== GET /admin/stats (X-Token="") ===
  [authMW] ran for /admin/stats
  -> status=401 body="{\"error\":\"no token\"}"

=== GET /admin/stats (X-Token="secret") ===
  [authMW] ran for /admin/stats
  -> status=200 body="admin stats"

[authMW] ran ขึ้นเฉพาะใน /admin/* เท่านั้น — /other ที่ลงทะเบียนหลัง admin.Use(...) ก็ไม่โดน เพราะมันอยู่คนละ group
แต่ในกลุ่มเดียวกัน ลำดับบรรทัดมีผลเต็ม ๆ:

=== group.Use AFTER route registered in same group ===
  GET /g/early -> status=200 body="early"          # ไม่มี [g mw] ขึ้นเลย
  [g mw] ran for /g/late
  GET /g/late  -> status=200 body="late"

/g/early ลงทะเบียนก่อน g.Use(...) จึงไม่ได้ middleware ติดไปด้วย
เหตุผลคือ gin ก๊อปคิว ณ วินาทีที่ลงทะเบียน route ไม่ใช่ตอนมี request เข้ามา — Use() ที่มาทีหลังจึงย้อนไปแก้ route เก่าไม่ได้ · จำสั้น ๆ ว่า Use() ต้องมาก่อน GET/POST เสมอ ซึ่งจะกลับมาหลอกเราอีกครั้งในหัวข้อถัดไป

🗣 ศัพท์ที่เพิ่งเรียนไป

กลับไปที่ตึกออฟฟิศในหัวข้อ 5 อีกที: c.Abort() คือ รปภ. ที่กดปิดประตูเงียบ ๆ โดยไม่บอกอะไรสักคำ · c.AbortWithStatusJSON(401, ...) คือ รปภ. ที่ ปิดประตูพร้อมบอกเหตุผล — สองอย่างนี้ต้องทำคู่กันเสมอ

abort / short-circuit
"ตัดจบคิว ไม่ให้เดินต่อ" · ไม่ได้แปลว่าตอบ error ตามที่พิสูจน์ใน CASE 1 · ฝรั่งเรียกพฤติกรรมนี้ว่า short-circuit the chain — พิมพ์ถาม AI ว่า “how to short-circuit a gin middleware chain” ได้เลย
status code
ตัวเลขสามหลักที่บอกผลของ request (200 สำเร็จ · 400 ข้อมูลที่ส่งมาไม่ถูก · 401 ยังไม่ได้ล็อกอิน/token ไม่ผ่าน · 403 ล็อกอินแล้วแต่ไม่มีสิทธิ์ · 500 ฝั่ง server พัง)
ค่า default ของ Go คือ 200 ซึ่งเป็นเหตุผลที่ Abort() เปล่า ๆ ได้ 200
headers already written
ข้อความ warning ที่แปลว่า "ส่ง status ลงสายไปแล้ว แก้ไม่ได้แล้ว" · เจอเมื่อไหร่ให้เดาไว้ก่อนเลยว่ามีใครเขียน response ไปก่อนหน้าคุณ (เช่น Bind*) · เวลาเล่าให้คนอื่นฟังให้พูดว่า “status ถูกเขียนไปก่อนแล้ว เลย override ไม่ได้”
context value (c.Set / c.Get)
ช่องฝากของระหว่าง middleware กับ handler · เก็บเป็น any จึงต้อง type-assert ตอนหยิบ · MustGet = "ถ้าไม่มีให้ panic ไปเลย" ใช้เมื่อแน่ใจว่า middleware ที่ Set ถูกผูกกับ route นี้แน่นอน
route group
กลุ่มของ route ที่ใช้ prefix และ middleware ชุดเดียวกัน · ประโยคที่ใช้จริง: “auth middleware ผูกไว้ที่ group /admin ไม่ได้ผูกทั้ง engine”

7 · CORS — กฎของเบราว์เซอร์ ไม่ใช่ยามหน้าประตูของ server

อาการคลาสสิกที่ทุกคนต้องเจอ: Postman ยิงผ่าน · curl ผ่าน · แต่เว็บของตัวเองเรียกไม่ได้ ขึ้น error สีแดงว่าโดน CORS บล็อก
สาเหตุคือ CORS ไม่ได้ทำงานที่ server เลย · มันเป็นกฎที่ เบราว์เซอร์ บังคับใช้กับโค้ด JavaScript ที่มันรันอยู่

คำที่ต้องรู้ก่อนหนึ่งคำ: origin = โปรโตคอล + โดเมน + พอร์ต รวมกันสามอย่าง · https://app.example.com กับ http://app.example.com คนละ origin (โปรโตคอลต่าง) และ https://api.example.com ก็คนละ origin (โดเมนต่าง)

กฎเดิมของเว็บชื่อ same-origin policy: JS ที่โหลดมาจาก https://app.example.com อ่านคำตอบจาก origin อื่นไม่ได้ ไม่งั้นเว็บร้ายเปิดค้างไว้ จะแอบยิงไปที่ธนาคารของคุณ (ซึ่งมี cookie ติดไปด้วย) แล้วอ่านยอดเงินได้เลย
CORS คือช่องทางที่ server ใช้บอกเบราว์เซอร์ว่า "ฉันอนุญาตเว็บนี้" ผ่าน response header (บรรทัดข้อมูลกำกับที่แนบไปกับคำตอบ คนละส่วนกับ body) · เบราว์เซอร์เป็นคนอ่าน header นั้นแล้วตัดสินใจ — ไม่ใช่ server

🚸 เทียบให้เห็นภาพ — พ่อแม่ที่ยืนดูลูกอยู่ตลอด

ลูกเดินไปรับของที่บ้านหลังอื่นในหมู่บ้าน · เจ้าของบ้านนั้นเต็มใจให้ ยื่นของให้เรียบร้อย
แต่พ่อแม่ยืนดูอยู่ข้างหลังตลอด และมีกฎประจำบ้านว่า "บ้านไหนไม่ติดป้ายหน้าบ้านว่ายินดีให้บ้านเรามารับ ห้ามถือของกลับบ้าน"
· ลูก = โค้ด JavaScript ที่รันอยู่ในแท็บ
· พ่อแม่ = เบราว์เซอร์ — คนที่บังคับใช้กฎจริง ๆ
· ป้ายหน้าบ้าน = header Access-Control-Allow-Origin ที่ server ติดไว้
· การวิ่งไปถามหน้าบ้านก่อนว่า "ขอรับของแบบนี้ได้ไหม" = preflight (OPTIONS) — พ่อแม่สั่งให้ไปถาม เจ้าของบ้านไม่ได้ขอ

และนี่คือคำตอบว่าทำไม curl กับ Postman ถึงยิงผ่านสบาย ๆ: พวกนั้นคือผู้ใหญ่ที่ขับรถไปเอง ไม่มีใครยืนดูอยู่ข้างหลัง เลยไม่มีกฎข้อนี้บังคับ

ที่ไม่เหมือนกัน: ภาพนี้ชวนให้คิดว่า "กฎมีไว้ปกป้องลูก" ซึ่งกลับด้าน — same-origin policy ไม่ได้ปกป้องเว็บที่ JS ของเรารันอยู่ แต่ปกป้องข้อมูลของบ้านหลังอื่น (ธนาคารที่ผู้ใช้ล็อกอินค้างไว้) จากลูกที่อาจเป็นเว็บร้าย · อย่าจำว่า "CORS มีไว้ปกป้องเว็บของเรา"
และพ่อแม่ห้ามลูกได้ตั้งแต่ยังไม่ออกจากบ้าน แต่เบราว์เซอร์ปล่อยให้ request วิ่งไปถึง server จริง ๆ แล้ว ถ้าเป็น request ง่าย ๆ ที่ไม่ต้อง preflight (GET หรือ POST แบบ form ธรรมดา ที่ไม่มี header แปลก ๆ) — server ทำงานไปเรียบร้อย บันทึกข้อมูลไปแล้วก็ได้ เบราว์เซอร์แค่ไม่ยอมให้ JS อ่านคำตอบ
CORS จึงกัน "การอ่านคำตอบ" ไม่ได้กัน "การยิงไปถึง" — ย้ำอีกครั้งว่ามันไม่ใช่ระบบความปลอดภัยของ server

🧠 สรุปที่ต้องติดตัวไป — CORS ไม่ใช่ระบบความปลอดภัยของ server
คนที่ไม่ใช้เบราว์เซอร์ (curl, Postman, สคริปต์, มือถือ native) ไม่โดน CORS เลยสักนิด ยิงเข้ามาได้ปกติทุกอย่าง
ดังนั้น authentication ยังต้องมีเสมอ — CORS กันได้แค่ JS ในแท็บของคนอื่น ไม่ได้กันคนที่ตั้งใจจะยิง API ของคุณ

preflight — request ที่เบราว์เซอร์ยิงเองโดยที่เราไม่ได้สั่ง

สำหรับ request ที่ "ไม่ธรรมดา" (มี Content-Type: application/json, มี header Authorization, หรือใช้ method อย่าง PUT/DELETE) เบราว์เซอร์จะยิง OPTIONS ไปถามก่อนว่าอนุญาตไหม แล้วค่อยยิงของจริง
แปลว่า API ของคุณต้องตอบ OPTIONS ให้เป็น — ซึ่งเป็นงานของ cors middleware:

$ curl -s -i -X OPTIONS -H "Origin: https://evil.example.com" \
    -H "Access-Control-Request-Method: POST" http://127.0.0.1:39182/data
HTTP/1.1 204 No Content
Access-Control-Allow-Headers: Origin,Content-Length,Content-Type
Access-Control-Allow-Methods: GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS
Access-Control-Allow-Origin: *
Access-Control-Max-Age: 43200
Date: Thu, 20 Aug 2026 14:50:43 GMT

เซิร์ฟเวอร์ตัวนี้ใช้แค่ a.Use(cors.Default()) บรรทัดเดียว สังเกตสามอย่าง:
1. status เป็น 204 No Content ไม่ใช่ 200
2. เราไม่ต้องลงทะเบียน route OPTIONS เอง — cors middleware ตอบแล้ว Abort จบให้เลย
3. Max-Age: 43200 คือ 12 ชั่วโมง (หน่วยวินาที) แปลว่าเบราว์เซอร์จำคำตอบนี้ไว้ ไม่ต้องยิง OPTIONS ถามซ้ำทุก request

ส่วน request จริง (ไม่ใช่ preflight) ได้ header น้อยกว่ามาก — แค่ตัวเดียว:

$ curl -s -i -H "Origin: https://evil.example.com" http://127.0.0.1:39182/data
HTTP/1.1 200 OK
Access-Control-Allow-Origin: *
Content-Type: application/json; charset=utf-8
Content-Length: 11

{"ok":true}

# ยิงโดยไม่ส่ง Origin มาเลย
$ curl -s -i http://127.0.0.1:39182/data
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 11

{"ok":true}          ← ไม่มี CORS header สักตัว
⚠️ เทส CORS ด้วย curl ต้องใส่ -H "Origin: ..." เสมอ

ผลรันข้างบนพิสูจน์แล้ว: ไม่ส่ง Origin ไป cors middleware ก็ไม่ใส่ header ให้
หลายคนจึงสรุปผิดว่า "ตั้ง CORS แล้วแต่ไม่ทำงาน" ทั้งที่มันทำงานปกติ — แค่ไม่มีใครถามมันว่ามาจากไหน

สองกับดักของ cors.Default() และ cors.New()

cors.Default() อนุญาตทุก origin (*) ซึ่งพอสำหรับตอนพัฒนา แต่มีรูที่ทำให้ระบบที่มี login ใช้ไม่ได้ ผมลองขอ Authorization ตอน preflight:

$ curl -s -i -X OPTIONS -H "Origin: https://evil.example.com" \
    -H "Access-Control-Request-Method: POST" \
    -H "Access-Control-Request-Headers: Content-Type, Authorization" http://127.0.0.1:39182/data
HTTP/1.1 204 No Content
Access-Control-Allow-Headers: Origin,Content-Length,Content-Type
Access-Control-Allow-Methods: GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS
Access-Control-Allow-Origin: *
Access-Control-Max-Age: 43200

ตอบ 204 เหมือนเดิม แต่ในลิสต์ ไม่มีคำว่า Authorization — เว็บที่ส่ง JWT (รูปแบบ token ที่ใช้บอกว่าคนยิงเข้ามาเป็นใคร) ผ่าน Authorization: Bearer ... จะโดนเบราว์เซอร์บล็อก ทั้งที่ server ตอบ 204 สวยงาม

ทีนี้พอเปลี่ยนไปใช้ cors.New() เพื่อระบุ origin เจาะจง ก็เจอกับดักที่สอง — cors.New() ไม่ merge ค่า default ให้ ใส่อะไรได้อย่างนั้น:

❌ ใส่แต่ AllowOrigins

cors.New(cors.Config{
    AllowOrigins: []string{"https://app.example.com"},
})
$ curl -X OPTIONS -H "Origin: https://app.example.com" \
    -H "Access-Control-Request-Method: POST" ...
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Vary: Origin
← ไม่มี Allow-Methods / Allow-Headers เลย

status 204 ดูเหมือนผ่าน แต่เบราว์เซอร์จะบล็อก POST เพราะไม่มีใครบอกว่า POST ทำได้

✅ ใส่ให้ครบ

r.Use(cors.New(cors.Config{
    AllowOrigins:     []string{"https://app.example.com"},
    AllowMethods:     []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
    AllowHeaders:     []string{"Origin", "Content-Type", "Authorization"},
    ExposeHeaders:    []string{"Content-Length"},
    AllowCredentials: true,
    MaxAge:           12 * time.Hour,
}))
HTTP/1.1 204 No Content
Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: Origin,Content-Type,Authorization
Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Max-Age: 43200

12 * time.Hour ออกมาเป็น 43200 (หน่วยวินาที) เท่ากับ default พอดี

ข้อดีของ gin-contrib/cors คือมันตัดจบที่ 403 เลยเมื่อ origin ไม่อยู่ในลิสต์ ไม่ใช่ตอบ 200 แล้วไม่ใส่ header แบบ lib บางตัว — ทำให้ debug ง่ายกว่ามาก:

$ curl -s -i -H "Origin: https://evil.example.com" http://127.0.0.1:39183/data
HTTP/1.1 403 Forbidden
Date: Thu, 20 Aug 2026 14:50:48 GMT
Content-Length: 0

💥 cors.New() panic ได้ตอน start server ซึ่งเป็นเรื่องดี — เจอเร็ว
cors.New(cors.Config{}) เปล่า ๆ → PANIC value=conflict settings: all origins disabled
ลืมใส่ https:// นำหน้า origin → PANIC value=bad origin: origins must contain '*' or include http://,https://
ทั้งคู่เกิดตอน cors.New() คือตอนบูต ไม่ใช่ตอนมี request เข้ามา

ทายก่อนรัน — โค้ดนี้ลงทะเบียน route ก่อน แล้วค่อย Use

c := gin.New()
c.GET("/before", func(ctx *gin.Context) {
    ctx.JSON(200, gin.H{"which": "before"})
})
c.Use(cors.Default())          // <-- มาทีหลัง
c.GET("/after", func(ctx *gin.Context) {
    ctx.JSON(200, gin.H{"which": "after"})
})

เว็บที่ https://x.example.com ยิง POST /before — preflight OPTIONS /before ได้อะไร และ request จริงได้อะไร? เบราว์เซอร์จะปล่อยผ่านไหม?
(คำใบ้: ลองไล่ดูว่าในโค้ดนี้มีใครลงทะเบียน route OPTIONS /before ไว้บ้าง)

preflight ผ่านสบาย ๆ ได้ header ครบทุกตัว:

$ curl -s -i -X OPTIONS -H "Origin: https://x.example.com" \
    -H "Access-Control-Request-Method: POST" http://127.0.0.1:39184/before
HTTP/1.1 204 No Content
Access-Control-Allow-Headers: Origin,Content-Length,Content-Type
Access-Control-Allow-Methods: GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS
Access-Control-Allow-Origin: *
Access-Control-Max-Age: 43200

แต่ request จริง ไม่มี Access-Control-Allow-Origin เลย:

$ curl -s -i -H "Origin: https://x.example.com" http://127.0.0.1:39184/before
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 18

{"which":"before"}          ← ไม่มี CORS header

$ curl -s -i -H "Origin: https://x.example.com" http://127.0.0.1:39184/after
HTTP/1.1 200 OK
Access-Control-Allow-Origin: *
Content-Type: application/json; charset=utf-8

{"which":"after"}

ทำไม preflight ถึงผ่าน? เพราะเราไม่เคยลงทะเบียน OPTIONS /before มันจึงตกไปสาย NoRoute — คิวสำรองที่ gin ใช้ตอบเวลาไม่มี route ไหนตรงเลย (ปกติคือตัวที่ตอบ 404) ซึ่ง gin สร้างคิวนี้ใหม่ตอน Use() — สายนั้นจึงมี cors อยู่
ยืนยันได้ด้วย path ที่ไม่มีอยู่จริง ซึ่งก็มี header เหมือนกัน:

$ curl -s -i -H "Origin: https://x.example.com" http://127.0.0.1:39184/totally-unknown
HTTP/1.1 404 Not Found
Access-Control-Allow-Origin: *
Content-Type: text/plain

404 page not found

อาการที่คุณจะเจอจริง: แท็บ Network ในเบราว์เซอร์แสดง OPTIONS เป็นสีเขียว 204 แต่ request จริงแดง — แล้วคุณจะนั่งแก้ config CORS ทั้งวันโดยที่ config ถูกอยู่แล้ว
ปัญหาอยู่ที่ลำดับบรรทัด: r.Use() ต้องมาก่อน r.GET() เสมอ (เรื่องเดียวกับ /g/early ในหัวข้อ 6)

🗣 ศัพท์ที่เพิ่งเรียนไป

CORS เป็นเรื่องที่ถามผิดคำแล้วได้คำตอบผิดทางทันที ห้าคำนี้ช่วยให้ถามตรงจุด

origin
โปรโตคอล + โดเมน + พอร์ต รวมกันสามอย่าง · ต่างกันแม้แค่ข้อเดียวก็คนละ origin แล้ว
เวลาแจ้งปัญหาให้บอกครบทั้งสองฝั่ง: “หน้าเว็บอยู่ origin ไหน ยิงไป origin ไหน” ไม่ใช่แค่ “เรียก API ไม่ได้”
same-origin policy
กฎดั้งเดิมของเบราว์เซอร์ที่ห้าม JS อ่านคำตอบข้าม origin · CORS คือทางผ่อนผันของกฎนี้ ไม่ใช่กฎคนละเรื่อง
preflight
OPTIONS ที่เบราว์เซอร์ยิงไปถามก่อนเองโดยที่เราไม่ได้สั่ง · ไม่ได้เกิดกับทุก request — เกิดเมื่อ request ไม่ใช่ของง่าย ๆ เช่นเป็น PUT/DELETE/PATCH · ส่ง Content-Type: application/json · หรือมี header ที่เราเซ็ตเองอย่าง Authorization · ส่วน GET กับ POST แบบ form ธรรมดาวิ่งตรงไปเลย ไม่มี preflight
ประโยคที่ทำให้ senior เข้าใจสถานการณ์ทันที: “preflight ผ่าน 204 แล้ว แต่ request จริงไม่มี Allow-Origin” — บอกแค่นี้เขารู้เลยว่าให้ไปดูลำดับของ r.Use()
CORS header
กลุ่ม header ที่ขึ้นต้นด้วย Access-Control- · ตัวที่ต้องจำคือ Allow-Origin (ใครเรียกได้) · Allow-Methods (method ไหนได้) · Allow-Headers (แนบ header อะไรมาได้ เช่น Authorization)
credentials
ในสเปก CORS คำนี้หมายถึงของที่เบราว์เซอร์แนบให้เองเท่านั้น — cookie · TLS client certificate · และ HTTP authentication (ช่อง user/password ที่เบราว์เซอร์จำไว้) · AllowCredentials: true คือการบอกเบราว์เซอร์ว่ายอมให้ของพวกนี้ข้าม origin มาได้
ที่คนสับสนบ่อยที่สุด: Authorization: Bearer ... ที่เราเซ็ตเองใน fetch() ไม่ใช่ credentials ตามนิยามนี้ — มันเป็น header ธรรมดาที่คุมด้วย AllowHeaders ต่างหาก · ส่ง JWT อย่างเดียวไม่ต้องเปิด AllowCredentials
ข้อบังคับ: เปิด AllowCredentials แล้วจะใช้ AllowOrigins: []string{"*"} ไม่ได้ ต้องระบุ origin เจาะจงเสมอ · ตัวไลบรารีไม่ได้ห้าม (ผมลองแล้ว มันตอบ Allow-Origin: * กับ Allow-Credentials: true มาให้พร้อมกันเฉย ๆ) แต่เบราว์เซอร์จะบล็อกทิ้ง
เวลาถามให้ระบุไปเลยว่า “ต้องส่ง cookie ข้าม origin ด้วยไหม” เพราะคำตอบเปลี่ยน config ทั้งชุด

8 · กับดักทั้งบทในตารางเดียว

สิ่งที่เขียนสิ่งที่คิดว่าจะได้สิ่งที่ได้จริง (รันแล้ว)ทางแก้
Published bool + required ส่ง false มาก็ผ่าน STATUS=400 ตลอดกาล ใช้ *bool
BindJSON แล้ว c.JSON(422) ผู้ใช้ได้ 422 STATUS=400 + warning เฉพาะ debug mode ใช้ ShouldBindJSON
ส่ง err.Error() กลับไปเลย ผู้ใช้รู้ว่าต้องแก้อะไร ได้ชื่อ struct ภายใน แต่ไม่ได้ค่าที่ยอมรับ errors.As + แปลงเอง
json:"q" กับ ShouldBindQuery อ่าน ?q=go ได้ field ว่าง → ตกที่ required ใช้ tag form:"q"
c.Abort() เฉย ๆ client ได้ 401 หรือ 403 HTTP/1.1 200 OK body ว่าง c.AbortWithStatusJSON(...)
c.JSON(401) แต่ลืม Abort หยุดแค่นั้น handler รันต่อ · body {"error":"unauthorized"}ok c.AbortWithStatusJSON(...)
cors.New ใส่แต่ AllowOrigins ได้ default ที่เหลือแถมมา ไม่มี Allow-Methods → เบราว์เซอร์บล็อก ใส่ทุกช่องเอง
r.Use(cors...) หลัง r.GET ผูกให้ทุก route preflight ผ่าน 204 · request จริงไม่มี header Use() ก่อน route เสมอ

เส้นเรื่องเดียวที่ร้อยทั้งแปดแถว — ทุกกับดักในบทนี้ ไม่มีตัวไหน compile ไม่ผ่าน และไม่มีตัวไหน crash
ทุกตัวรันได้ ตอบ status สวย ๆ กลับมา แล้วผิดเงียบ ๆ
เครื่องมือที่จับพวกนี้ได้จึงไม่ใช่ compiler แต่เป็น เทสที่ยิง request จริงแล้วอ่าน status กับ header — ซึ่งคือบทเรียน 40 พอดี

ทดสอบความเข้าใจ

1. struct มี Servings int `json:"servings" binding:"required"` แล้วผู้ใช้ส่ง {"servings":0} มา จะเกิดอะไรขึ้น?

ผมรันจริงได้ STATUS=400 พร้อม Key: 'ReqRecipe.Servings' Error:Field validation for 'Servings' failed on the 'required' tag
required ไม่ได้ถาม "JSON มี key นี้ไหม" — validator เห็นแค่ struct หลัง unmarshal จึงตัดสินจากค่าเป็น zero value หรือเปล่า เท่านั้น
ตัวเลือกที่ 1 คือพฤติกรรมของ *int (ผมรันแล้วได้ STATUS=200) ไม่ใช่ int · ตัวเลือกที่ 3 ผิดเพราะ min เป็นคนละ tag — ผมรันแยกให้ดูแล้วว่า binding:"min=1,max=5" กับ level=0 ต่างหากที่ได้ 'min' tag · ตัวเลือกที่ 4 ผิดเพราะ validator ไม่เคยแก้ค่าให้ มันตรวจอย่างเดียว
ทางแก้จริงคือเปลี่ยนเป็น *int ซึ่งแยก "ส่ง 0 มา" (&0) ออกจาก "ไม่ส่งมา" (nil) ได้

2. handler เรียก c.BindJSON(&in) แล้ว error จึงเขียน c.JSON(422, ...) ต่อ — client ได้อะไร?

ผลรันจริง: [GIN-debug] [WARNING] Headers were already written. Wanted to override status code 400 with 422 แล้วได้ STATUS=400 กับ body ที่เราเขียนไปครบถ้วน
เพราะ BindJSON เรียก AbortWithError(400, ...) ให้ตั้งแต่ตอน error → header ถูกส่งลงสายไปแล้ว · HTTP เขียน status ได้ครั้งเดียวเท่านั้น
ตัวเลือกที่ 1 ผิด เพราะที่เขียนทับได้คือ body ไม่ใช่ status · ตัวเลือกที่ 2 คือเคส 1d ที่ bind แล้ว return เลยโดยไม่เขียนอะไรต่อ · ตัวเลือกที่ 4 ผิดเพราะไม่มี panic เลย มีแค่บรรทัด warning
และ warning นั้นเงียบใน release mode — ผมรันยืนยันแล้วว่าได้ STATUS=400 เหมือนเดิมโดยไม่มีอะไรเตือน · ทางแก้คือใช้ ShouldBindJSON เสมอ

3. auth middleware ตรวจ token ไม่ผ่าน แล้วเรียก c.Abort() เฉย ๆ โดยไม่เขียน response — client ได้อะไร?

ยิงผ่าน socket จริงด้วย curl ได้ HTTP/1.1 200 OK กับ Content-Length: 0
Abort() ทำหน้าที่เดียวคือหยุดคิว — มันไม่เขียน status ให้เลย และเมื่อไม่มีใครเขียน net/http ก็เติม 200 ให้เป็น default
ผลคือ frontend อ่านว่า "สำเร็จแต่ไม่มีข้อมูล" ซึ่งเป็นอาการที่ debug ยากที่สุดแบบหนึ่ง · ตัวเลือกที่ 2 คือผลของ c.AbortWithStatus(401) ซึ่งผมรันเทียบไว้ใน CASE 3 · ตัวเลือกที่ 3 ผิดเพราะ 500 เกิดจาก panic ที่ gin.Recovery() จับได้ ไม่ใช่จาก Abort · ตัวเลือกที่ 4 ผิดเพราะ gin ไม่เคยเดา 204 ให้ใคร
กับดักฝาแฝดคือเขียน c.JSON(401) แต่ลืม Abort — handler จะรันต่อจริง ผมรันแล้วได้ body {"error":"unauthorized"}ok

4. โค้ดเขียน r.GET("/before", h) ก่อน แล้วค่อย r.Use(cors.Default()) ทีหลัง — เว็บที่เรียก /before จะเจออะไร?

ผมยิงจริงทั้งสองแบบ: OPTIONS /before ได้ 204 พร้อม Access-Control-Allow-Origin: * และ Allow-Methods ครบ แต่ GET /before ได้ 200 ที่ไม่มี CORS header สักตัว → เบราว์เซอร์บล็อก
สาเหตุ: gin ก๊อปคิว middleware ณ ตอนลงทะเบียน route — /before จึงไม่มี cors ติดไป · ส่วน OPTIONS /before ไม่เคยถูกลงทะเบียนเลย มันจึงตกไปสาย NoRoute (คิวสำรองสำหรับ path ที่ไม่มี route ตรง) ที่ gin สร้างใหม่ตอน Use() ซึ่งมี cors อยู่
ตัวเลือกที่ 1 ผิดเพราะ preflight ได้ 204 ไม่ใช่ 404 (แต่ path มั่ว ๆ อย่าง /totally-unknown ได้ 404 ที่มี header — หลักฐานของสาย NoRoute) · ตัวเลือกที่ 2 ผิดตรงคำว่า "ย้อนหลัง" ซึ่ง gin ทำไม่ได้ · ตัวเลือกที่ 3 คือผลของ cors.New ที่ origin ไม่อยู่ในลิสต์ คนละเรื่องกัน

🛠️ ภารกิจ — ทำ endpoint สร้างสูตรอาหารที่ไม่ปฏิเสธผู้ใช้ที่ทำถูก

เป้าหมายไม่ใช่ "ทำให้ POST ผ่าน" — เป้าหมายคือ ทำให้มันปฏิเสธข้อมูลที่ถูกต้องให้ดูก่อน แล้วค่อยแก้ และเขียนเทสที่จับการปฏิเสธนั้นได้

  1. ตั้งโปรเจกต์: สร้างโฟลเดอร์ใหม่ · cd เข้าไป · go mod init recipes · go get github.com/gin-gonic/gin · สร้าง main.go ที่มี func main() เปิด server ด้วย r := gin.Default() + r.Run(":8080") (บทเรียน 38)
    แล้วทำ POST /recipes ที่รับ title (string) · servings (int) · published (bool) ทั้งสามช่องใส่ binding:"required" ไว้ก่อน
  2. ทำให้มันพัง: ยิง {"title":"ผัดกะเพรา","servings":2,"published":false}
    ต้องได้ 400 — ถ้าได้ 200 แปลว่าคุณเขียน tag ไม่ครบ ย้อนไปดูข้อ 1
    แล้วยิง {"title":"ผัดกะเพรา","servings":2} (ตัด published ทิ้งไปเลย) เทียบ
    ข้อความ error สองอันนี้จะออกมาเหมือนกันเป๊ะ — ตอบให้ได้ว่าทำไม ทั้งที่เคสแรกผู้ใช้ส่ง published มาจริง ๆ ส่วนเคสหลังไม่ได้ส่ง
  3. แก้ด้วย pointer: เปลี่ยนเฉพาะ field ที่ zero value มีความหมาย แล้วพิสูจน์สองเคส: ส่ง false มา → 200 · ไม่ส่ง key มาเลย → 400
    ตอบให้ได้ว่าทำไม Title ไม่ควรเปลี่ยนเป็น *string
  4. เก็บ error ให้สุภาพ: ใช้ errors.As แยก validator.ValidationErrors ออกจาก JSON parse error แล้วตอบเป็น {"fields":{"Title":"required"}} แทน err.Error() ดิบ ๆ (key คือ fe.Field() · value คือ fe.Tag())
    ยิง body พัง ๆ อย่าง {"title": ดูว่าตกทางไหน
  5. เขียน middleware สองตัว: RequestID ที่ c.Set("rid", ...) ขาไป และ Timing ที่ log c.Writer.Status() ขากลับ
    พิมพ์ลำดับให้เห็นด้วยตา ว่าอันไหนขึ้นก่อน-หลัง แล้วอธิบายว่าทำไม Timing อ่าน status ได้ทั้งที่ตัวเองรันก่อน handler
  6. auth middleware: ผูกกับ group /admin เท่านั้น · ตรวจ header X-Token · ไม่ผ่านให้ c.AbortWithStatusJSON(401, ...)
    แล้วลองเปลี่ยนเป็น c.Abort() เฉย ๆ ดูหนึ่งรอบ — curl แล้วดู status ที่ได้ · เปลี่ยนกลับ
  7. CORS: ใส่ cors.New ที่อนุญาต origin เดียวและ header Authorization · ยิง preflight ด้วย curl -X OPTIONS -H "Origin: ..." -H "Access-Control-Request-Method: POST"
    แล้วย้ายบรรทัด r.Use(cors...) ไปไว้ใต้ r.POST แล้วยิงซ้ำ — อธิบายว่าทำไม preflight ยังผ่านอยู่
  8. เขียนเทสพิสูจน์ (ต่อยอด บทเรียน 16): table-driven test ที่ยิง body หลายแบบเข้า router เดียวกันแล้ว assert status ทุกเคส โดยเฉพาะเคส "published":false ต้องได้ 200
    เทสนี้ต้อง fail ถ้าคุณเปลี่ยน *bool กลับเป็น bool — ถ้าเปลี่ยนแล้วยังผ่าน แปลว่าเทสยังไม่ได้เทสอะไร
    (ยิง request ในเทสด้วย httptest.NewRecorder() + r.ServeHTTP(w, req) — บทเรียน 40 จะลงลึกเรื่องนี้)
  9. ท้าทาย: เขียน handler ที่เรียก c.ShouldBindJSON สองครั้งติดกัน — ทายก่อนรัน ว่าครั้งที่สอง error ว่าอะไร แล้วแก้ด้วย c.ShouldBindBodyWith ให้ได้ค่าครบทั้งสองครั้ง

เพราะ c.Next() ไม่ใช่ "คำสั่งจบงาน" แต่เป็น "หยุดฉันไว้ตรงนี้ ไปรันคิวที่เหลือจนจบ แล้วค่อยกลับมา"
โค้ดที่อยู่ใต้ c.Next() จึงรันหลัง handler เขียน response เสร็จแล้ว ตอนนั้น c.Writer.Status() จึงมีค่าจริงให้อ่าน · ผมรันยืนยันลำดับไว้แบบนี้:

A: before Next
B: before Next
HANDLER: /ping
B: after Next
A: after Next

สังเกตว่า A ที่ Use() ก่อน กลับเป็นตัวที่พิมพ์ ขากลับทีหลังสุด — เพราะมันเป็นชั้นนอกสุดของหัวหอม
ถ้าคุณย้าย log ไปไว้เหนือ c.Next() คุณจะได้ status เป็น 200 ทุกครั้งไม่ว่าจริง ๆ จะตอบอะไร เพราะตอนนั้นยังไม่มีใครเขียน response

💬 ข้อ 8 คือข้อที่ผมอยากเห็นผลมากที่สุดของบทนี้ — เพราะเทสที่ยังผ่านหลังจากเปลี่ยน *bool กลับเป็น bool คือเทสที่กำลังหลอกคุณอยู่ ซึ่งเป็นบทเรียนเดียวกับเรื่อง mock ใน บทเรียน 28
ถ้ารันแล้วได้ output ไม่ตรงกับในบทนี้ ส่ง output มาได้เลย ผมอยากรู้ว่าเครื่องคุณต่างจากเครื่องผมตรงไหน — โดยเฉพาะถ้าคุณใช้ gin คนละเวอร์ชัน เพราะทุกบรรทัดในหน้านี้วัดบน gin v1.12.0 + validator v10.30.1 + cors v1.7.7 เท่านั้น

อ้างอิง