🧰 เตรียมเครื่องก่อนอ่าน — ทุก 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 เป็น struct | encoding/json | json:"..." | *json.UnmarshalTypeError / *errors.errorString |
| 2. ตรวจกติกาของค่า | validator/v10 | binding:"..." | 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 · ช่องข้อความพิมพ์ว่าง ("") · ช่องติ๊กพิมพ์ "ไม่ติ๊ก" (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 ที่ผู้ใช้อาจตั้งใจส่ง และ "" ที่แปลว่า "ล้างค่าเดิมทิ้ง"
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"}
มันมีชื่อ 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 ว่างเปล่า ไม่มีคำอธิบายสักตัว
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 body | c.ShouldBindJSON | json:"..." | field ไม่ถูกเติม |
| query string | c.ShouldBindQuery | form:"..." | field ไม่ถูกเติม |
| path param | c.ShouldBindUri | uri:"..." | 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 สักตัว
-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 ไม่อยู่ในลิสต์ คนละเรื่องกัน
เป้าหมายไม่ใช่ "ทำให้ POST ผ่าน" — เป้าหมายคือ ทำให้มันปฏิเสธข้อมูลที่ถูกต้องให้ดูก่อน แล้วค่อยแก้ และเขียนเทสที่จับการปฏิเสธนั้นได้
- ตั้งโปรเจกต์: สร้างโฟลเดอร์ใหม่ ·
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"ไว้ก่อน - ทำให้มันพัง: ยิง
{"title":"ผัดกะเพรา","servings":2,"published":false}
ต้องได้ 400 — ถ้าได้ 200 แปลว่าคุณเขียน tag ไม่ครบ ย้อนไปดูข้อ 1
แล้วยิง{"title":"ผัดกะเพรา","servings":2}(ตัดpublishedทิ้งไปเลย) เทียบ
ข้อความ error สองอันนี้จะออกมาเหมือนกันเป๊ะ — ตอบให้ได้ว่าทำไม ทั้งที่เคสแรกผู้ใช้ส่งpublishedมาจริง ๆ ส่วนเคสหลังไม่ได้ส่ง - แก้ด้วย pointer: เปลี่ยนเฉพาะ field ที่ zero value มีความหมาย
แล้วพิสูจน์สองเคส: ส่ง
falseมา → 200 · ไม่ส่ง key มาเลย → 400
ตอบให้ได้ว่าทำไมTitleไม่ควรเปลี่ยนเป็น*string - เก็บ error ให้สุภาพ: ใช้
errors.Asแยกvalidator.ValidationErrorsออกจาก JSON parse error แล้วตอบเป็น{"fields":{"Title":"required"}}แทนerr.Error()ดิบ ๆ (key คือfe.Field()· value คือfe.Tag())
ยิง body พัง ๆ อย่าง{"title":ดูว่าตกทางไหน - เขียน middleware สองตัว:
RequestIDที่c.Set("rid", ...)ขาไป และTimingที่ logc.Writer.Status()ขากลับ
พิมพ์ลำดับให้เห็นด้วยตา ว่าอันไหนขึ้นก่อน-หลัง แล้วอธิบายว่าทำไมTimingอ่าน status ได้ทั้งที่ตัวเองรันก่อน handler - auth middleware: ผูกกับ group
/adminเท่านั้น · ตรวจ headerX-Token· ไม่ผ่านให้c.AbortWithStatusJSON(401, ...)
แล้วลองเปลี่ยนเป็นc.Abort()เฉย ๆ ดูหนึ่งรอบ — curl แล้วดู status ที่ได้ · เปลี่ยนกลับ - CORS: ใส่
cors.Newที่อนุญาต origin เดียวและ headerAuthorization· ยิง preflight ด้วยcurl -X OPTIONS -H "Origin: ..." -H "Access-Control-Request-Method: POST"
แล้วย้ายบรรทัดr.Use(cors...)ไปไว้ใต้r.POSTแล้วยิงซ้ำ — อธิบายว่าทำไม preflight ยังผ่านอยู่ - เขียนเทสพิสูจน์ (ต่อยอด บทเรียน 16):
table-driven test ที่ยิง body หลายแบบเข้า router เดียวกันแล้ว assert
status ทุกเคส โดยเฉพาะเคส
"published":falseต้องได้ 200
เทสนี้ต้อง fail ถ้าคุณเปลี่ยน*boolกลับเป็นbool— ถ้าเปลี่ยนแล้วยังผ่าน แปลว่าเทสยังไม่ได้เทสอะไร
(ยิง request ในเทสด้วยhttptest.NewRecorder()+r.ServeHTTP(w, req)— บทเรียน 40 จะลงลึกเรื่องนี้) - ท้าทาย: เขียน 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 เท่านั้น
อ้างอิง
- Gin — Model binding and validation — ต้นทางของ
ShouldBindJSONและตารางShould*vsMust* - pkg.go.dev — validator/v10 — อ่านหัวข้อ Baked-in Validations: รายชื่อ tag ทั้งหมด และนิยามของ
requiredที่ว่า "ไม่เป็น zero value ของชนิดนั้น" - Gin — Bind Uri · Bind body into different structs — ที่มาของ tag
uriและShouldBindBodyWith - Gin — Using middleware · Custom middleware —
r.Use,c.Next,c.Set - pkg.go.dev —
gin.Context— เอกสารของNext/Abort/AbortWithStatusJSON/MustGet - github.com/gin-contrib/cors · pkg.go.dev — ค่า default ของ
cors.Default()และทุกช่องในcors.Config - MDN — Cross-Origin Resource Sharing — อ่านหัวข้อ Preflight requests: เงื่อนไขว่า request แบบไหนถึงจะโดน
OPTIONSก่อน - Cheat sheet Day 8 — tag ที่ใช้บ่อยและตารางกับดักรวมทั้งวัน