🧰 ของที่ต้องมีถ้าจะพิมพ์ตาม
- Go (บทนี้รันบน
go1.26.5 darwin/arm64) - โฟลเดอร์ใหม่ +
go mod initของตัวเอง แล้วgo get github.com/gin-gonic/gin@v1.12.0(ตัวที่ติดมาด้วยคือgithub.com/go-playground/validator/v10 v10.30.1) curl— ทุกผลรันในบทนี้มาจากเครื่องจริง ไม่ใช่ของแต่ง
บทนี้ ไม่ใช่บทสอน Gin — Gin เป็นแค่เครื่องมือที่ทำให้เห็นว่าแต่ละช่องมาถึง server ยังไง เรื่อง routing/middleware เต็ม ๆ อยู่บทถัด ๆ ไป
ถ้ายังไม่เคยเปิด HTTP server มาก่อน นี่คือ main.go เต็ม ๆ ที่โค้ดทุกชิ้นในบทนี้อยู่ข้างใน —
ต่อจากนี้ผมจะตัดมาโชว์เฉพาะบรรทัดที่กำลังพูดถึง:
package main
import "github.com/gin-gonic/gin"
func main() {
r := gin.Default() // r = router: ตัวจับคู่ "method + path" เข้ากับ handler
r.GET("/ping", func(c *gin.Context) { // handler = ฟังก์ชันที่ถูกเรียกเมื่อมี request ตรงกับ route นี้
c.JSON(200, gin.H{"ok": true}) // c = Context: กล่องที่มีทั้ง request ขาเข้าและ response ขาออก
})
r.Run("127.0.0.1:8080") // เปิดรับ request ค้างไว้ จนกว่าจะกด Ctrl+C
}
วางไฟล์นี้ในโฟลเดอร์ที่ go mod init ไว้แล้ว สั่ง go run . ในโฟลเดอร์นั้น
แล้วเปิดอีกหน้าต่างมายิง curl http://127.0.0.1:8080/ping
ถ้าตัวอย่างไหนใช้ io.ReadAll ต้องเพิ่ม "io" เข้าไปในวงเล็บ import ด้วย
1 · หนึ่ง request มีสี่ช่อง ไม่ใช่ช่องเดียว
ตอนคุณกดปุ่มในเว็บ สิ่งที่วิ่งออกไปคือข้อความธรรมดาก้อนหนึ่ง ที่มีรูปแบบตายตัว หน้าตาแบบนี้เป๊ะ ๆ:
POST /recipes?draft=true HTTP/1.1 ← บรรทัดแรก: method + path + query Host: api.example.com ┐ Authorization: Bearer abc123 │ header — บอกว่า "ใครขอ / ขอแบบไหน" Content-Type: application/json ┘ ← บรรทัดว่าง = เส้นแบ่งหัวกับตัว {"title":"ต้มยำกุ้ง","minutes":30} ← body — เนื้อข้อมูล
อ่านจากบนลงล่างแล้วจะเห็นว่า ทั้งสี่ช่องอยู่คนละที่กันทางกายภาพ:
| ช่อง | อยู่ตรงไหนของ request | ใช้ใส่อะไร | ยาวได้แค่ไหน |
|---|---|---|---|
| path | บรรทัดแรก ก่อนเครื่องหมาย ? |
ระบุว่า resource ไหน — /recipes/42 |
สั้น |
| query | บรรทัดแรก หลัง ? |
กรอง / เรียง / แบ่งหน้า — ?sort=name |
สั้น (เป็นส่วนหนึ่งของ URL) |
| header | บรรทัดถัด ๆ มา จนถึงบรรทัดว่าง | metadata ของการเชื่อมต่อ (= ข้อมูลเกี่ยวกับคำขอ ไม่ใช่ตัวข้อมูลเอง) — token, ชนิดข้อมูล | สั้น |
| body | ทุกอย่างหลังบรรทัดว่าง | เนื้อข้อมูลที่จะบันทึก — JSON ทั้งก้อน | ยาวได้ (ไหลมาเป็นสตรีม คือทยอยมาทีละส่วน ไม่ได้มาทั้งก้อนพร้อมกัน) |
ทีนี้ประเด็นที่ทำให้มือใหม่เสียเวลาเป็นชั่วโมง:
server ไม่ได้เอาสี่ช่องนี้มากองรวมกันแล้วให้คุณค้นทีเดียว
ฟังก์ชันอ่าน query อ่านได้เฉพาะ query · ฟังก์ชันอ่าน body อ่านได้เฉพาะ body
ถ้าส่งผิดช่อง คุณจะไม่ได้ error — คุณจะได้ค่าว่าง ซึ่งอ่านยากกว่ามาก
ตอนฝากส่งพัสดุ คุณไม่ได้เขียนทุกอย่างรวมกันเป็นก้อนเดียว ของแต่ละอย่างมีช่องของมันเอง:
· ช่อง "ส่งถึง" = path — ไม่มีอันนี้ ไปรษณีย์ไม่รู้จะเอาไปที่ไหน
· ช่องบริการเสริม (ส่งด่วน / เก็บเงินปลายทาง) = query — ไม่ติ๊กก็ส่งได้ แค่ได้แบบธรรมดา
· ของที่อยู่ในกล่อง = body — เนื้อของจริงที่จะไปถึงมือคนรับ
· สติกเกอร์ข้างกล่อง ("ของแตกง่าย" / "ผู้ฝาก: สมชาย") = header —
ไม่ใช่ของข้างใน แต่บอกว่าใครฝากมา และต้องปฏิบัติกับกล่องนี้ยังไง
ทีนี้ลองนึกว่าคุณเขียนที่อยู่ผู้รับลงไปในช่องหมายเหตุแทนช่อง "ส่งถึง"
คนที่หยิบพัสดุไปคัดแยกอ่านแค่ช่อง "ส่งถึง" เขาเห็นว่าง เขาก็ทำต่อไปตามที่เห็น
ไม่มีใครวิ่งมาบอกคุณว่าเขียนผิดช่อง รู้อีกทีคือของไปไม่ถึง
ที่ไม่เหมือนกัน: เจ้าหน้าที่ไปรษณีย์เป็นคน อาจเหลือบเห็นแล้วทักคุณได้ แต่ฟังก์ชันฝั่ง server ไม่ทักสักคำ มันคืนค่าว่างกลับมาเฉย ๆ เหมือนที่หัวข้อ 2 กำลังจะพิสูจน์ให้ดู
สี่ชื่อข้างล่างคือคำที่ dev ทุกคนใช้เรียกสี่ช่องนั้น ใช้ให้ตรงคำแล้วเวลาถาม senior หรือถาม AI จะได้คำตอบที่ตรงตั้งแต่ครั้งแรก
- path / path parameter
- ท่อน URL ตั้งแต่
/ไปจนถึงก่อนเครื่องหมาย?— เป็นตัวบอกว่า "ของประเภทไหน ชิ้นไหน" · ทั้งท่อน/recipes/42คือ path ส่วนเฉพาะ เลข42เรียกว่า path parameter (ย่อว่า path param) · ถามว่า “id ตัวนี้รับเข้ามาทาง path param หรือ query” จะสื่อกว่าถามว่า “เลขนี้มาจากไหน” - query string / query parameter
- ท่อนหลังเครื่องหมาย
?ใน URL · ทั้งท่อนเรียก query string ส่วนแต่ละคู่key=valueเรียก query parameter · เวลาขอสเปกจากคนอื่นให้พูดว่า “endpoint นี้รับ query param อะไรได้บ้าง” - request body
- ก้อนข้อมูลที่แนบไปกับคำขอ อยู่หลังบรรทัดว่าง · คำถามเดียวที่ตัดปัญหา “ส่งไปแล้วไม่เข้า” ได้ครึ่งหนึ่งคือ “ค่านี้ส่งมาทาง body หรือ query”
- header
- บรรทัด
ชื่อ: ค่าที่อยู่ก่อนบรรทัดว่าง · พูดว่า “ส่ง token มาทางAuthorizationheader หรือยัง” จะชัดกว่าพูดว่า “ส่ง token มาหรือยัง” เพราะระบุช่องให้ด้วยในตัว - metadata
- ข้อมูลเกี่ยวกับข้อมูล ไม่ใช่ตัวข้อมูลเอง · “ของแตกง่าย” เป็น metadata ของกล่อง ไม่ใช่ของในกล่อง · นี่คือไม้บรรทัดว่าอะไรควรอยู่ใน header
- fail silently (ล้มแบบเงียบ ๆ)
- โปรแกรมทำงานผิดโดยไม่มี error โผล่มาเลย · ประโยคที่ใช้ได้จริงเวลาไปขอความช่วยเหลือ: “มันไม่ error แต่ได้ค่าว่าง สงสัยจะ fail silently ตรงไหนสักที่” — พูดแบบนี้ senior จะเดาจุดผิดได้เร็วขึ้นมาก
2 · พิสูจน์ว่ามันแยกกันจริง
ผมเขียน handler (ฟังก์ชันที่ทำงานเมื่อ request วิ่งมาถึง route นี้) ตัวเดียว
ที่พยายามอ่านคำว่า sort จากทุกช่องพร้อมกัน
แล้วยิงเข้าไปสองรอบ — รอบแรกส่ง sort=name ทาง body
รอบสองส่งทาง query ทั้งที่ค่าเหมือนกันเป๊ะ:
r.POST("/probe", func(c *gin.Context) {
q := c.Query("sort") // อ่านจาก query string
pf := c.PostForm("sort") // อ่านจาก body แบบ form
raw, _ := io.ReadAll(c.Request.Body)
c.JSON(200, gin.H{
"Query_sort": q, "PostForm_sort": pf,
"GetHeader_ct": c.GetHeader("Content-Type"),
"rawBody_after": string(raw),
})
})
# รอบ 1 — sort อยู่ใน body ติดป้าย Content-Type: application/x-www-form-urlencoded
# (= รูปแบบ body ที่หน้าตาเหมือน query string คือ key=value คั่นด้วย & แบบที่ฟอร์ม HTML ส่งมา)
POST /probe body: sort=name
# รอบ 2 — sort อยู่ใน query
POST /probe?sort=name body: (ว่าง)
ทั้งสองรอบส่งคำว่า sort=name เท่ากัน —
Query_sort กับ PostForm_sort ของแต่ละรอบจะได้อะไร?
และแถมอีกข้อที่ตั้งใจให้ทายผิด: rawBody_after
(อ่าน body ดิบหลังเรียก PostForm ไปแล้ว) จะได้อะไร?
=== 1) ช่องทางแยกกันไหม: ส่ง sort=name ผ่าน body แบบ form ===
POST /probe -> 200 {"GetHeader_ct":"application/x-www-form-urlencoded",
"PostForm_sort":"name","Query_sort":"","rawBody_after":""}
=== 1b) ส่ง sort=name ผ่าน query ===
POST /probe?sort=name -> 200 {"GetHeader_ct":"",
"PostForm_sort":"","Query_sort":"name","rawBody_after":""}
สลับกันคนละช่องเป๊ะ ๆ — ตัวที่อ่านผิดช่องได้ "" เฉย ๆ
ไม่มี error ไม่มี log ไม่มีอะไรเตือน
นี่คือหน้าตาของบั๊ก "ส่งไปแล้วแต่ไม่เข้า": frontend ยืนยันว่าส่งแล้ว
backend ยืนยันว่าไม่ได้รับ — ถูกทั้งคู่ เพราะคนละช่อง
ส่วน rawBody_after ได้ "" ทั้งสองรอบ เพราะ body เป็นสตรีมที่อ่านได้ครั้งเดียว —
PostForm อ่านไปหมดแล้ว พออ่านซ้ำจึงไม่เหลืออะไร
(ในบทถัดไปจะเจอเรื่องเดียวกันตอนเรียก ShouldBindJSON สองครั้ง)
ในผลรันมีจุดที่คนสะดุดกันเยอะ: rawBody_after ได้ค่าว่าง
ทั้งที่รอบแรกส่ง sort=name ไปกับ body จริง ๆ
ให้นึกถึงขวดน้ำ คุณเทน้ำจากขวดลงแก้วจนหมด แล้วยกขวดใบเดิมมาเทอีกครั้ง
ขวดไม่ได้หายไปไหน มันยังอยู่ในมือคุณ แต่ไม่มีอะไรไหลออกมาแล้ว
PostForm คือการเทครั้งแรก · io.ReadAll คือการยกขวดมาเทซ้ำ
ถ้าอยากใช้น้ำสองรอบ ต้องเทเก็บใส่แก้วไว้ก่อนตั้งแต่ครั้งแรก แล้วค่อยแบ่งใช้
ไม่ใช่หวังจะเทจากขวดใบเดิมสองที
ที่ไม่เหมือนกัน: ขวดเปล่าคุณมองเห็นด้วยตาว่ามันหมดแล้ว
แต่ body ที่ถูกอ่านไปแล้วหน้าตาเหมือน body ที่ว่างมาตั้งแต่แรกเป๊ะ ๆ —
ได้ "" เท่ากัน ไม่มีอะไรบอกว่า “อันนี้อ่านไปแล้วนะ”
- stream (สตรีม)
- ข้อมูลที่ทยอยไหลมาทีละส่วน ไม่ได้กองรออยู่ให้หยิบซ้ำได้เรื่อย ๆ · request body ของ HTTP เป็นแบบนี้ ซึ่งเป็นเหตุผลทั้งหมดที่อ่านซ้ำไม่ได้
- consume (อ่านจนหมด)
- คำที่ dev ใช้เรียก “เทจนขวดเกลี้ยง” · ประโยคที่เอาไปใช้ได้เลย: “body น่าจะโดน consume ไปแล้วตอนเรียก bind รอบแรก” — สั้นกว่าและตรงกว่าการอธิบายยาว ๆ ว่าอ่านซ้ำแล้วได้ค่าว่าง
- buffer (พักไว้ก่อน)
- การอ่านสตรีมมาเก็บไว้ในตัวแปรก่อน เพื่อให้ใช้ซ้ำได้ · เวลาจะขอวิธีแก้ ให้ถามว่า “ต้อง buffer body ไว้ก่อนไหมถ้าอยากอ่านสองรอบ”
- EOF
- ย่อจาก end of file แปลว่า “หมดแล้ว ไม่เหลืออะไรให้อ่าน” · เจอบ่อยมากตอนไปอ่าน body ที่ว่างหรือที่ถูก consume ไปแล้ว — ในภารกิจข้อ 3 ท้ายบทจะได้เห็นตัวเป็น ๆ
3 · คำถามสามข้อ ตัดสินให้จบว่าข้อมูลชิ้นนี้ไปช่องไหน
หัวใจของบทนี้ไม่ใช่ชื่อฟังก์ชันของ Gin แต่คือการตัดสินใจ เวลามีข้อมูลชิ้นใหม่โผล่มาในสเปก ("อยากค้นตามความยากง่ายด้วย") ให้เดินไล่คำถามจากบนลงล่าง เจอ "ใช่" ข้อไหนก่อนก็จบที่ข้อนั้น:
ข้อมูลหนึ่งชิ้นมาถึงมือคุณ
│
├─ 1. มันระบุว่า "resource ไหน" ไหม? ─────── ใช่ ─▶ path /recipes/42
│ ถ้าไม่มีมัน server ไม่รู้จะทำกับอะไร
│
├─ 2. มันกรอง / เรียง / แบ่งหน้า ไหม? ────── ใช่ ─▶ query ?sort=name&limit=10
│ ตัดออกก็ยังตอบได้ แค่ได้ผลกว้างกว่าเดิม
│
├─ 3. มันคือเนื้อข้อมูลที่จะบันทึก ไหม? ───── ใช่ ─▶ body {"title":"ต้มยำกุ้ง"}
│ ของใหม่ที่ server ยังไม่มี / ของที่จะถูกแก้
│
└─ 4. มันเป็น metadata ของการเชื่อมต่อ ไหม? ─ ใช่ ─▶ header Authorization: Bearer …
ไม่ใช่ข้อมูลของ resource แต่บอกว่า "ใครขอ / ขอมาแบบไหน / อยากได้กลับแบบไหน"
ลองเดินคำถามกับสเปกจริงของ Day 8 (เว็บรวมสูตรอาหาร) ดู:
| ข้อมูลที่อยากส่ง | ตอบข้อไหนว่า "ใช่" | ลงเอยที่ |
|---|---|---|
| สูตรหมายเลข 42 | 1 — ไม่มีเลขนี้ก็ไม่รู้จะเปิดสูตรไหน | GET /recipes/42 |
| เรียงตามคะแนน เอา 10 อันแรก | 2 — ตัดออกก็ยังได้รายการ แค่ไม่เรียง | GET /recipes?sort=rating&limit=10 |
| ค้นคำว่า "ต้มยำ" | 2 — เป็นตัวกรองของ collection เดิม (collection = รายการทั้งกอง เช่น /recipes) | GET /recipes?q=ต้มยำ |
| ชื่อเมนู + วัตถุดิบ + ขั้นตอน ของสูตรใหม่ | 3 — เป็นของใหม่ที่จะถูกบันทึก | POST /recipes + body JSON |
| token ของคนที่กำลังล็อกอิน | 4 — ไม่ใช่ข้อมูลของสูตร แต่บอกว่าใครขอ | Authorization: Bearer … |
| "ที่ส่งไปเป็น JSON นะ" | 4 — บอกวิธีอ่านของที่แนบมา | Content-Type: application/json |
| "ขอคำตอบกลับเป็น JSON" | 4 — บอกวิธีที่อยากได้ของกลับ | Accept: application/json |
💡 เส้นแบ่งที่คนสับสนที่สุดคือข้อ 2 กับ 3 — "ค้นหา" ควรเป็น query หรือ body?
ใช้ไม้บรรทัดนี้: query คือของที่ก๊อป URL ส่งให้เพื่อนแล้วเพื่อนเห็นหน้าเดียวกัน
/recipes?q=ต้มยำ&sort=rating ส่งลิงก์ให้เพื่อนได้ · bookmark ได้ · กด back แล้วกลับมาที่เดิมได้
ถ้าเอา q ไปไว้ใน body ของ POST ทั้งสามอย่างนั้นหายหมดทันที
(มีข้อยกเว้นจริงอยู่: เงื่อนไขค้นหาที่ซับซ้อนมากจน URL ยาวเกินไป — แต่ต้องเป็นเคสที่วัดแล้วว่ายาวเกินจริง ๆ ไม่ใช่เดา)
4 · query — ตัวกรอง ไม่ใช่ตัวระบุ
ฝั่ง Gin อ่าน query ได้สามแบบ และสามแบบนี้ไม่ได้ต่างกันแค่ความสวย
ผมรันทั้งสามตัวกับสามสถานการณ์: ส่งค่ามา · ไม่ส่งเลย · ส่ง key มาแต่ค่าว่าง
handler ที่ใช้เรียกทั้งสามตัวกับคีย์ q แล้วตอบกลับเป็น JSON —
c.Query("q") → ช่อง Query ·
c.DefaultQuery("q","x") → ช่อง DefaultQuery ·
c.GetQuery("q") คืนสองค่า → ช่อง GetQuery_val กับ GetQuery_ok
# ค่า default ที่ใส่ไว้คือ "x" — ผลรันจริง (body ที่ handler ตอบกลับ)
GET /search?q=go
{"DefaultQuery":"go","GetQuery_ok":true,"GetQuery_val":"go","Query":"go","Query_len":2}
GET /search
{"DefaultQuery":"x","GetQuery_ok":false,"GetQuery_val":"","Query":"","Query_len":0}
GET /search?q=
{"DefaultQuery":"","GetQuery_ok":true,"GetQuery_val":"","Query":"","Query_len":0}
บรรทัดที่สองกับสามคือหัวใจ — "ไม่ส่งมาเลย" กับ "ส่งมาแต่ว่าง" ไม่ใช่เรื่องเดียวกัน
c.Query ได้ "" เท่ากันทั้งคู่ แยกไม่ออก ·
c.DefaultQuery("q","x") ก็แยกไม่ออกในทางที่อันตรายกว่า:
พอ client ส่ง ?q= มา ค่า default ไม่ทำงาน (ได้ "" ไม่ใช่ "x")
มีแต่ c.GetQuery ที่คืนตัวที่สองเป็น ok — ตัวเดียวที่แยกสองกรณีนี้ออกจากกันได้
ร้านกาแฟมีใบสั่งให้ติ๊ก ลองเทียบใบสั่งสองใบนี้:
· ใบที่ไม่มีช่อง "ความหวาน" อยู่เลย → บาริสต้าไม่รู้ว่าลูกค้าคิดยังไง
ก็ชงตามสูตรมาตรฐานของร้าน
· ใบที่มีช่อง "ความหวาน" แต่ลูกค้าปล่อยว่างไว้ → ใบนี้ "มีช่องนั้นอยู่"
แม้จะไม่มีตัวหนังสือในช่อง ซึ่งเป็นคนละสถานะกับใบที่ไม่มีช่องนั้นตั้งแต่แรก
ถ้าบาริสต้าดูแค่ "ตัวหนังสือที่อยู่ในช่อง" สองใบนี้เหมือนกันเป๊ะ คือว่างทั้งคู่
เขาต้องดูอีกอย่างว่า "ใบนี้มีช่องนั้นอยู่หรือเปล่า" ถึงจะแยกออก
ค่าที่สองของ c.GetQuery ก็คืออันนั้นเป๊ะ ๆ — มันไม่ได้บอกค่า
มันบอกว่า "คีย์นี้มีอยู่ใน URL ไหม"
ส่วน c.Query ดูแค่ตัวหนังสือในช่อง จึงแยกสองใบนี้ไม่ออก — ได้ค่าว่างเท่ากันทั้งคู่
ส่วน c.DefaultQuery แอบเช็คให้ว่า "มีช่องนั้นไหม" อยู่เหมือนกัน แต่มันช่วยกลับด้านกับที่คนคาด —
มันเติมสูตรมาตรฐานให้เฉพาะใบที่ไม่มีช่องนั้นเลย ส่วนใบที่มีช่องแต่ปล่อยว่าง
มันถือว่าลูกค้าตอบมาแล้ว จึงส่งค่าว่างออกไปตรง ๆ (ตรงกับผลรันบรรทัด ?q= ข้างบน
ที่ DefaultQuery ได้ "" ไม่ใช่ "x")
ที่ไม่เหมือนกัน: บาริสต้าเงยหน้าถามลูกค้าซ้ำได้ แต่ server ถามกลับไม่ได้ มันตัดสินจากสิ่งที่มาถึงเท่านั้น — เลือกฟังก์ชันผิดตั้งแต่แรกก็คือจบ
- zero value
- ค่าตั้งต้นที่ Go แจกให้ตัวแปรที่ยังไม่ได้ใส่ค่า —
stringได้""·intได้0·boolได้false· ประโยคที่จะได้ใช้บ่อยมาก: “ตรงนี้แยกไม่ออกว่าเป็น zero value หรือเป็นค่าที่ผู้ใช้ส่งมาจริง” - comma-ok idiom
- ท่าที่ Go ให้มาในตัวภาษา ใช้ได้ 3 ที่เท่านั้น: อ่าน
map·type assertion· รับค่าจากchannel— ตัวที่สองชื่อokแปลว่า "มีจริงไหม" (ไม่ใช่err·v, err := f()เป็นคนละท่า อย่าเรียกรวมกัน) · ไลบรารีอย่าง Gin ก็เลียนท่านี้ เช่นv, ok := c.GetQuery("q") - presence check
- “เช็คว่ามีไหม” ซึ่งคนละเรื่องกับ “เช็คว่าว่างไหม” · เวลาคุยสเปกให้พูดว่า “เคสนี้ต้อง presence check ไม่ใช่เช็คสตริงว่าง” — คำนี้ตัดการเถียงกันได้ทันที
- default value / fallback
- ค่าที่ใช้แทนเมื่อไม่มีค่าส่งมา = “สูตรมาตรฐานของร้าน” · จุดที่ต้องระวังคือ
DefaultQueryมอง?q=ว่ามีค่ามาแล้ว จึงไม่ fallback ให้
ไม่ส่งมา = ใช้ค่า default → c.DefaultQuery พอ (แต่รู้ไว้ว่า ?q= จะทะลุ default)
"ส่งค่าว่างมา" มีความหมายต่างจาก "ไม่ส่ง" (เช่น ?category= แปลว่า "ขอเฉพาะอันที่ไม่มีหมวด")
→ ต้อง c.GetQuery เท่านั้น
อีกสองพฤติกรรมที่ต้องรู้ก่อนไปเจอเองตอนดึกสองยาม — ผมรันทั้งคู่:
// คีย์ซ้ำได้ และ Query() คืนแค่ตัวแรก
GET /multi?tag=go&tag=web&tag=api
-> {"Query":"go", "QueryArray":["go","web","api"]}
// query แยกตัวพิมพ์เล็กใหญ่ แต่ header ไม่แยก
GET /case?sort=name&Sort=NAME (ส่ง header Authorization: Bearer abc123)
-> {"header_AUTHORIZATION":"Bearer abc123","header_Authorization":"Bearer abc123",
"header_authorization":"Bearer abc123","query_Sort":"NAME","query_sort":"name"}
query: sort กับ Sort คือคนละคีย์กันจริง ๆ — พิมพ์ผิดตัวเดียวคือหาย
header: เรียกด้วยตัวพิมพ์แบบไหนก็ได้ค่าเดียวกัน เพราะสเปก HTTP บอกว่าชื่อ header ไม่แยกเล็กใหญ่
และ Go แปลงชื่อให้เป็นรูปมาตรฐานให้เอง
สองบรรทัดนี้คือเหตุผลว่าทำไม c.GetHeader("authorization") ใช้ได้
แต่ c.Query("Sort") ทำให้ฟีเจอร์เรียงลำดับเงียบไปทั้งฟีเจอร์
5 · ผูก query ทั้งชุดเป็น struct — และ tag ที่คนใส่ผิดกันทุกคน
พอ query มีสี่ห้าตัว การเรียก c.Query ทีละตัวแล้ว strconv.Atoi เองจะเริ่มเละ
Gin ให้ผูกทั้งชุดเข้า struct ได้ด้วย ShouldBindQuery —
แต่มันอ่าน tag ชื่อ form ไม่ใช่ json
type ListQuery struct {
Sort string `form:"sort,default=created_at" binding:"omitempty,oneof=created_at name rating"`
Limit int `form:"limit,default=20" binding:"min=1,max=100"`
Page int `form:"page,default=1" binding:"min=1"`
}
r.GET("/recipes", func(c *gin.Context) {
var q ListQuery
if err := c.ShouldBindQuery(&q); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"sort": q.Sort, "limit": q.Limit, "page": q.Page})
})
อ่าน tag แถวแรกทีละท่อน: form:"sort" = ไปหยิบ query ชื่อ sort ·
default=created_at = ถ้าไม่ส่งมาให้ใช้ค่านี้แทน (อยู่ใน tag form) ·
binding:"..." = กฎตรวจค่าหลังหยิบมาแล้ว โดย omitempty แปลว่า
"ว่างได้ ถ้าว่างก็ข้ามกฎที่เหลือไป" ส่วน oneof= คือรายชื่อค่าที่ยอมรับ
ผลรันจริงทั้งห้าเคส:
GET /recipes?sort=name&limit=10&page=2 -> 200 {"limit":10,"page":2,"sort":"name"}
GET /recipes -> 200 {"limit":20,"page":1,"sort":"created_at"}
GET /recipes?limit=1000 -> 400 {"error":"Key: 'ListQuery.Limit' Error:Field validation for 'Limit' failed on the 'max' tag"}
GET /recipes?sort=price -> 400 {"error":"Key: 'ListQuery.Sort' Error:Field validation for 'Sort' failed on the 'oneof' tag"}
GET /recipes?limit=abc -> 400 {"error":"strconv.ParseInt: parsing \"abc\": invalid syntax"}
สามอย่างที่ได้มาฟรีจาก struct เดียว: ค่า default (default=20),
แปลงชนิดให้ (query เป็นสตริงเสมอ แต่ q.Limit เป็น int แล้ว),
และ กันค่าประหลาด — ?limit=1000 ถูกปฏิเสธตั้งแต่ประตู
ไม่ทันได้ไปถาม database ว่า "ขอ 1000 แถว"
struct ที่มี field Q ติดแค่ json:"q" binding:"required" (ลืม form)
แล้วเรียก ShouldBindQuery →
ค่าจาก query ไม่เข้า field เลย ยิง ?q=go มาก็ยังได้
Field validation for 'Q' failed on the 'required' tag
(มันฟ้อง required เพราะ field ยังว่างอยู่ ไม่ใช่เพราะ tag ผิด)
จำสามคู่นี้ให้ขึ้นใจ: query ใช้ form ·
path param ใช้ uri (ShouldBindUri) ·
body JSON ใช้ json (ShouldBindJSON)
ใส่ form:"id" แทน uri:"id" ก็เจอ error หน้าตาเดียวกันเป๊ะ —
error บอกว่า "ไม่มีค่า" ไม่เคยบอกว่า "คุณใส่ tag ผิดชื่อ"
🧩 สังเกต error สองแบบในผลรันข้างบน —
?limit=1000 ให้ ValidationErrors (ผ่านการแปลงชนิดแล้ว แต่ค่าไม่อยู่ในกรอบ)
ส่วน ?limit=abc ให้ strconv.ParseInt: ... invalid syntax
(แปลงชนิดไม่ผ่านตั้งแต่ต้น จึงยังไม่ถึงขั้น validate)
ทั้งคู่ควรตอบ 400 เหมือนกัน แต่ข้อความดิบจาก validator
(ตัวตรวจกฎที่เขียนไว้ใน tag binding)
ไม่เหมาะจะส่งให้ผู้ใช้ตรง ๆ (มันโผล่ชื่อ struct ภายในของคุณออกไป)
— บทเรียน 34 พูดเรื่องรูปแบบ error response ไว้แล้ว
6 · body — เนื้อข้อมูลที่จะบันทึก
body คือช่องเดียวที่ยาวได้ไม่จำกัดในทางปฏิบัติ และเป็นช่องเดียวที่ไม่ปรากฏใน URL
มันจึงเป็นที่ของ "ของใหม่ที่จะถูกเก็บ" — คู่กับ method ที่แปลว่าเขียน: POST / PUT / PATCH
POST /recipes // สร้างของใหม่ — body คือของทั้งชิ้น
Content-Type: application/json
{"title":"ต้มยำกุ้ง","minutes":30,"difficulty":"medium"}
PUT /recipes/42 // แทนที่ทั้งชิ้น — path บอกว่าชิ้นไหน, body บอกว่าเป็นอะไร
{"title":"ต้มยำกุ้งน้ำข้น","minutes":35,"difficulty":"medium"}
PATCH /recipes/42 // แก้บางส่วน — body มีเฉพาะ field ที่จะแก้
{"minutes":35}
สังเกตว่า PUT /recipes/42 ใช้สองช่องพร้อมกัน —
42 อยู่ใน path (ระบุตัว) ส่วนเนื้อหาอยู่ใน body (เนื้อข้อมูล)
คนใหม่มักเผลอยัด id ลงไปใน body ด้วย แล้วเกิดคำถามที่ตอบไม่ได้:
ถ้า PUT /recipes/42 แต่ body เขียน {"id":99,...} จะเชื่อใคร?
ให้ path เป็นเจ้าของคำตอบเสมอ แล้ว id ใน body ก็ไม่ต้องมีตั้งแต่แรก
❌ ยัดทุกอย่างลง body
POST /recipes/search
{"id":42,
"q":"ต้มยำ",
"sort":"rating",
"limit":10}
ก๊อป URL ส่งให้เพื่อนไม่ได้ · bookmark ไม่ได้ · กด refresh แล้วเบราว์เซอร์ถามว่าจะส่งซ้ำไหม ·
cache ฝั่ง CDN (เครื่องกลางที่ช่วยจำคำตอบไว้แทน server) ทำไม่ได้
เพราะ POST ที่ URL เดียวกันคือคนละคำขอ
✅ แยกตามหน้าที่
GET /recipes/42
GET /recipes
?q=ต้มยำ
&sort=rating
&limit=10
URL เดียวเล่าเรื่องได้ทั้งเรื่อง · แชร์ได้ · เก็บ cache ได้ · และคนอ่าน log รู้ทันทีว่าเกิดอะไรขึ้น โดยไม่ต้องไปขุด body
📌 Content-Type ไม่ใช่แค่ป้ายประดับ — มันคือคำสั่งว่า "จงอ่าน body ด้วยวิธีนี้"
ในผลรันหัวข้อ 2 จะเห็น application/x-www-form-urlencoded ทำให้
c.PostForm อ่าน sort=name ออก · ถ้าเป็น JSON ก็ต้องอ่านด้วย
ShouldBindJSON แทน
ฝั่ง Gin มีความต่างที่ทำให้งงได้: ShouldBindJSON
บังคับอ่านเป็น JSON ไม่สนใจ header เลย ส่วน ShouldBind เฉย ๆ
เลือกวิธีอ่านจาก Content-Type
ส่ง JSON มาแต่ติดป้าย text/plain → ShouldBind จะไปอ่านแบบ form
แล้วได้ค่าว่าง จบด้วย error ว่า field required ไม่ผ่าน ทั้งที่ JSON ถูกทุกตัวอักษร
ส่วน ShouldBindJSON เคสเดียวกันนี้อ่านผ่านสบาย ๆ
7 · header — metadata ของการเชื่อมต่อ
ไม้บรรทัดของ header คือ: มันจะยังจริงอยู่ไหมถ้าเปลี่ยนไปเรียก endpoint อื่น
(endpoint = คู่ "method + path" หนึ่งคู่ เช่น GET /recipes คือ endpoint หนึ่ง)
token ของฉัน — จริงอยู่ทุก endpoint · "ฉันส่ง JSON" — จริงทุกครั้งที่ฉันส่ง JSON ·
แต่ "เรียงตามคะแนน" จริงเฉพาะกับ GET /recipes เท่านั้น → อันหลังคือ query ไม่ใช่ header
| header | ทิศทาง | แปลว่า |
|---|---|---|
Authorization: Bearer <token> | client → server | "นี่คือหลักฐานว่าฉันเป็นใคร" — ไม่ใช่ข้อมูลของ resource จึงไม่ควรอยู่ใน URL หรือ body |
Content-Type: application/json | ทั้งสองทาง | "ของที่แนบมากับข้อความนี้ เป็นชนิดนี้ จงอ่านด้วยวิธีนั้น" |
Accept: application/json | client → server | "ตอบกลับมาเป็นชนิดนี้ได้ไหม" — เป็นคำขอ ไม่ใช่คำสั่ง |
คู่ที่คนสลับกันบ่อยที่สุดคือ Content-Type กับ Accept
อยู่ใน request เหมือนกันแต่คนละเรื่อง: Content-Type พูดถึงของที่แนบไปกับ request นี้
ส่วน Accept พูดถึงของที่อยากได้กลับมา
เวลาส่ง GET ที่ไม่มี body การใส่ Content-Type จึงไม่มีความหมายอะไรเลย —
ไม่มีของให้ติดป้าย
🔤 ชื่อ header ไม่แยกตัวพิมพ์เล็กใหญ่ (พิสูจน์ในหัวข้อ 4 แล้ว:
Authorization / authorization / AUTHORIZATION ได้ค่าเดียวกัน)
แต่ ค่าข้างในแยก — Bearer abc123 กับ bearer abc123
ไม่ใช่สตริงเดียวกัน ถ้าโค้ดคุณตัดคำหน้าออกด้วยการเทียบสตริงตรง ๆ ก็จะพังกับ client บางตัว
8 · กับดักตัวจริง — อย่าส่ง token หรือ password ทาง query
ถึงหัวข้อที่ผมอยากให้จำมากที่สุดของบท มันไม่ใช่เรื่องความสวยงามของ API
แต่เป็นเรื่องที่ทำให้บริษัทต้อง revoke token ทั้งระบบ (= ยกเลิกบัตรผ่านของทุกคนแล้วออกใหม่)
ผมเปิด server จริงด้วย gin.Default() ซึ่งมี Logger ติดมาให้ตามค่า default
Logger คือตัวที่พิมพ์ access log ออกมาหนึ่งบรรทัดต่อหนึ่ง request
(เวลา · status · path ที่ถูกเรียก)
จากนั้นผมยิงเข้าไปสามครั้ง แต่ละครั้งส่งความลับคนละช่องกัน:
$ curl "http://127.0.0.1:18135/orders?token=SECRET-abc123&sort=name"
$ curl -H "Authorization: Bearer SECRET-abc123" \
"http://127.0.0.1:18135/orders?sort=name"
$ curl -X POST -H "Content-Type: application/json" \
-d '{"password":"p@ssw0rd"}' "http://127.0.0.1:18135/login"
ทั้งสามครั้งตอบ 200 เหมือนกันหมด —
แล้วใน access log ที่ server พ่นออกมา จะเห็นความลับของครั้งไหนบ้าง?
[GIN] 2026/08/20 - 22:04:34 | 200 | 104.125µs | 127.0.0.1 | GET "/orders?token=SECRET-abc123&sort=name"
[GIN] 2026/08/20 - 22:04:34 | 200 | 111.75µs | 127.0.0.1 | GET "/orders?sort=name"
[GIN] 2026/08/20 - 22:04:34 | 200 | 74.25µs | 127.0.0.1 | POST "/login"
บรรทัดแรกมี token เต็ม ๆ อยู่ใน log — ส่วนบรรทัดสองที่ส่ง token ตัวเดียวกันทาง header และบรรทัดสามที่ส่ง password ทาง body ไม่มีร่องรอยของความลับเลยสักตัวอักษร
และนี่คือ log ของ Gin เปล่า ๆ ที่ไม่ได้ตั้งค่าอะไรเป็นพิเศษ ของจริง log ก้อนนี้จะถูกส่งต่อไปอีกหลายที่
เพราะ query เป็นส่วนหนึ่งของ URL และ URL ถูกจดไว้ทุกที่ตลอดทาง:
1. access log ของ server (เห็นกับตาข้างบนแล้ว) และของทุกเครื่องกลางที่ request วิ่งผ่าน
ก่อนถึงเรา (reverse proxy / load balancer — ตัวรับ request แทน server แล้วส่งต่อเข้ามาให้)
2. ประวัติการเข้าเว็บของ browser — ซึ่งอาจ sync ขึ้น cloud ของผู้ใช้
3. header Referer ที่ browser แนบไปให้เว็บอื่นตอนผู้ใช้กดลิงก์ออกจากหน้านั้น
4. ระบบรวม log / monitoring ที่ทีมเปิดดูกันได้ทั้งทีม และเก็บย้อนหลังเป็นเดือน
ทั้งสี่ที่นี้ ยังจดต่อไปแม้คุณจะใช้ HTTPS — HTTPS ป้องกันคนแอบดักกลางทาง
ไม่ได้ป้องกันปลายทางที่ตั้งใจจดเอง
ส่วน header กับ body ไม่ได้อยู่ใน log พวกนี้โดยค่า default ตามที่รันให้ดู
รถทุกคันที่เข้าหมู่บ้าน ยามจะจดลงสมุดหน้าป้อมว่า
เวลาเท่าไหร่ · ทะเบียนอะไร · มาธุระอะไร
ส่วนบัตรประชาชนที่คุณยื่นให้ดูแล้วรับคืน กับของที่อยู่ในกระโปรงรถ
ไม่ได้ถูกจดลงสมุด ยามแค่ดูแล้วปล่อยผ่าน
URL (ซึ่งมี query อยู่ข้างใน) คือช่อง "มาธุระอะไร" ที่ถูกจดทุกคัน ·
header คือบัตรที่ยื่นให้ดูแล้วรับคืน · body คือของในกระโปรงรถ
ถ้าคุณเอารหัสตู้เซฟไปเขียนไว้ในช่อง "มาธุระอะไร" มันจะไปนอนอยู่ในสมุดเล่มนั้นเป็นปี ๆ
ให้ใครก็ได้ที่เปิดสมุดเห็น ทั้งที่คุณไม่ได้ตั้งใจให้ใครเห็นเลยสักคน
ที่ไม่เหมือนกัน: ยามเป็นคน ต้องตั้งใจจดถึงจะได้บรรทัด
แต่ access log จดทุก request ให้เองโดยไม่มีใครสั่ง — ไม่ต้องมีใครตั้งใจ ข้อมูลก็ลงสมุดแล้ว
และไม่ได้มีป้อมเดียว — reverse proxy / load balancer / CDN / API gateway
ที่ request วิ่งผ่าน (พวกที่ถอดซอง HTTPS อ่านได้) แต่ละตัวมีสมุดของตัวเองอีกคนละเล่ม ·
ส่วน router กับ ISP ระหว่างทางไม่ได้อยู่ในกลุ่มนี้ — บน HTTPS เขาเห็นแค่ว่าคุณต่อไปที่โดเมนไหน ไม่เห็น query
อีกข้อ: header ไม่ได้ปลอดภัยโดยอัตโนมัติ มันแค่ไม่ถูกจดตาม default
(อย่างที่รันให้ดูข้างบน) ถ้าทีมไหนตั้งให้ log header หรือ log body ก็หลุดได้เหมือนกัน —
ต่างกันตรงที่ URL หลุดโดยไม่มีใครตั้งใจ ส่วน header ต้องมีคนไปตั้งก่อน
มาตรฐาน OAuth 2.0 Bearer Token (RFC 6750 — ข้อตกลงกลางว่าจะส่ง "บัตรผ่าน" ของผู้ใช้กันยังไง)
เองก็นิยามวิธีส่ง token ทาง query ไว้จริง
แต่กำกับไว้ว่า ไม่ควรใช้ และให้เหตุผลตรงกับที่เราเพิ่งเห็น —
URL ที่มี token มีโอกาสสูงมากที่จะถูกจดลง log
สรุปเป็นกฎที่ใช้ได้ตลอดชีวิต: อะไรที่หลุดแล้วต้องรีเซ็ต — ห้ามอยู่ใน URL
(token, password, OTP, เลขบัตร, เลขบัตรประชาชน)
ให้ไป header หรือ body เท่านั้น
- access log
- “สมุดหน้าป้อม” — บรรทัดที่ server จดไว้หนึ่งบรรทัดต่อหนึ่ง request · เวลาไล่ปัญหาให้ขอตรง ๆ ว่า “ขอดู access log ช่วงเวลานั้นหน่อย”
- credential / secret
- ของที่ใช้พิสูจน์ตัวตนหรือเปิดสิทธิ์ — password, token, API key · รวมเรียกว่า secret · ประโยคที่ควรพูดให้เป็น: “อันนี้เป็น secret ไม่ควรอยู่ใน URL” ชัดกว่าพูดว่า “มันไม่ปลอดภัย”
- leak (รั่ว)
- secret หลุดออกไปอยู่ในที่ที่ไม่ควรอยู่ · “token leak ไปอยู่ใน log” คือประโยคที่ทำให้ทั้งทีมเข้าใจสถานการณ์ในสองวินาที
- revoke (เพิกถอน)
- ยกเลิก token ที่หลุดไปแล้วให้ใช้ไม่ได้ แล้วออกใบใหม่ · ตามด้วยคำว่า rotate ที่แปลว่า “หมุนเปลี่ยนของลับเป็นชุดใหม่” · ทั้งสองคำนี้คือสิ่งที่จะได้ยินในห้องประชุมวันที่มีของหลุด
- PII
- ย่อจาก personally identifiable information = ข้อมูลที่ระบุตัวบุคคลได้ (ชื่อ-นามสกุล เบอร์โทร เลขบัตรประชาชน) · กฎเดียวกับ secret คือ อย่าให้โผล่ใน URL หรือ log
9 · GET ที่มี body — ทำได้ แต่ห้ามพึ่ง
คำถามที่ต้องมีคนถามเสมอ: "ถ้าเงื่อนไขค้นหาซับซ้อนมาก ส่ง GET พร้อม body JSON เลยได้ไหม"
ทางเทคนิค — ได้ ผมรัน server จริงแล้วยิงด้วย curl ตรง ๆ handler อ่าน body ออกครบ:
$ curl -X GET -H "Content-Type: application/json" -d '{"sort":"name"}' http://127.0.0.1:18136/echo
{"body":"{\"sort\":\"name\"}","bodyLen":15,"method":"GET"}
$ curl http://127.0.0.1:18136/echo
{"body":"","bodyLen":0,"method":"GET"}
แต่ "รันบนเครื่องตัวเองแล้วได้" กับ "ใช้ได้จริงบนอินเทอร์เน็ต" คนละเรื่องกัน
สเปก HTTP (RFC 9110) บอกไว้ว่า body ที่ติดมากับ GET ไม่มีความหมายที่นิยามไว้
และ server มีสิทธิ์ปฏิเสธ request แบบนั้นได้ — ซึ่งแปลว่าทุกตัวที่อยู่ระหว่างทาง
(proxy, CDN, gateway, API client library, เครื่องมือทดสอบ) มีสิทธิ์ตัด body ทิ้งอย่างถูกต้องตามสเปก
เพราะมัน ทำงานได้ตอน dev (ไม่มี proxy คั่น) แล้วไปเงียบตอน production
โดยไม่มี error — server แค่ได้ body ว่าง แล้วตอบ "ไม่พบผลลัพธ์" อย่างสุภาพ
ผมทดสอบให้ดูได้แค่ว่า "ส่งตรงถึงกันแล้วอ่านออก" (ตัวเลข 15 ข้างบน) —
ผมทดสอบ proxy จริงไม่ได้ในบทนี้ จึงไม่มีตัวเลขให้ดู
เชื่อได้เฉพาะสิ่งที่รันให้ดู ที่เหลือคือสิ่งที่สเปกอนุญาตให้เขาทำ
ทางออกที่ปลอดภัย: ถ้าเงื่อนไขยาวเกิน URL จริง ๆ ให้ทำเป็น
POST /recipes/search ไปเลย — ยอมเสียความเป็น GET อย่างเปิดเผย
ดีกว่าใช้ GET ที่อาจโดนถอดไส้ระหว่างทาง
10 · สรุปเป็นตารางเดียว
| path | query | body | header | |
|---|---|---|---|---|
| ตอบคำถามว่า | อันไหน | เอาแบบไหนบ้าง | ข้อมูลคืออะไร | ใครขอ / ขอยังไง |
| ตัดออกแล้ว | ไม่รู้จะทำกับอะไร | ยังตอบได้ แค่กว้างกว่า | ไม่มีของจะบันทึก | ไม่รู้ว่าเป็นใคร |
| โผล่ใน log ไหม | โผล่ | โผล่ | ไม่โผล่ | ไม่โผล่ |
| แชร์ URL ได้ไหม | ได้ | ได้ | ไม่ได้ | ไม่ได้ |
| Gin อ่านด้วย | c.Param / tag uri | c.Query / tag form | ShouldBindJSON / tag json | c.GetHeader |
| ตัวพิมพ์เล็กใหญ่ | แยก | แยก | ไม่แยก (ชื่อ field) | ไม่แยก (ชื่อ header) |
แถวสุดท้ายมีตัวเดียวที่ต้องระวัง: query เท่านั้นที่แยกตัวพิมพ์เล็กใหญ่
ผมยิง {"TITLE":"ต้มยำ"} เข้า struct ที่ติด json:"title" แล้วได้
200 {"title":"ต้มยำ"} — เข้า field ปกติ เพราะ encoding/json ของ Go
เทียบชื่อแบบไม่สนตัวพิมพ์ให้อยู่แล้ว
แต่ ?Sort=name กับ ?sort=name คือคนละคีย์จริง ๆ ตามที่รันให้ดูในหัวข้อ 4
1. ถามตัวเองว่า "ตัดข้อมูลชิ้นนี้ออกแล้ว request ยังมีความหมายไหม" —
ถ้าไม่มี มันคือ path หรือ body · ถ้ายังมี มันคือ query
2. ถ้าข้อมูลชิ้นนั้นจริงกับทุก endpoint ไม่ใช่แค่อันนี้ — มันคือ header
3. ถ้าหลุดแล้วต้องรีเซ็ต — ห้ามอยู่ใน URL ไม่ว่าจะสะดวกแค่ไหน
ทดสอบความเข้าใจ
1. ทีมหนึ่งส่ง access token ด้วย GET /orders?token=SECRET-abc123 ปัญหาหลักคืออะไร?
ผมยิงจริงสามครั้งด้วยความลับคนละช่อง แล้วอ่าน log ของ gin.Default():
บรรทัดที่ส่งทาง query ขึ้นว่า
GET "/orders?token=SECRET-abc123&sort=name" เต็ม ๆ
ส่วนบรรทัดที่ส่ง token ตัวเดียวกันทาง Authorization header
และบรรทัดที่ส่ง password ทาง body ไม่มีความลับโผล่เลย
และ log ไม่ได้อยู่ที่เดียว — reverse proxy, ระบบรวม log, ประวัติ browser และ
header Referer ก็จดต่ออีก แม้จะใช้ HTTPS
เพราะ HTTPS กันคนดักกลางทาง ไม่ได้กันปลายทางที่ตั้งใจจดเอง
ตัวเลือกที่ 1 ผิด — token ขนาดนี้ไม่ทำให้ URL ยาวเกิน และถ้ายาวเกินจริงจะได้ status error ไม่ใช่เงียบ ·
ตัวเลือกที่ 3 ผิด ทั้ง query และ header ส่งเป็นข้อความธรรมดาเหมือนกัน อ่านค่ากลับได้ตรงทั้งคู่ ·
ตัวเลือกที่ 4 ผิด ผมยิง token=SECRET-abc123 ที่มีขีดกลางแล้วอ่านได้ปกติ
2. ส่ง sort=name ไปใน body แบบ form แต่ handler เรียก c.Query("sort") จะได้อะไร?
ผลรันจริงจาก handler ที่อ่านทุกช่องพร้อมกัน:
ส่งทาง body → {"PostForm_sort":"name","Query_sort":""}
ส่งทาง query → {"PostForm_sort":"","Query_sort":"name"}
สลับกันคนละช่องเป๊ะ ๆ และไม่มี error สักตัว — ตัวที่อ่านผิดช่องแค่ได้สตริงว่าง
ซึ่งคือหน้าตาของบั๊ก "frontend บอกส่งแล้ว backend บอกไม่ได้รับ"
ตัวเลือกที่ 1 ผิด Gin ไม่รวมช่องให้ (มี c.Query กับ c.PostForm แยกกันชัดเจน) ·
ตัวเลือกที่ 3 ผิด ไม่มี panic เลย ทั้งสองรอบตอบ 200 ·
ตัวเลือกที่ 4 ผิด Content-Type บอกแค่วิธีอ่าน body ไม่มีผลกับ query string
3. อยากแยก "ไม่ส่ง q มาเลย" ออกจาก "ส่ง ?q= มาแต่ค่าว่าง" ต้องใช้ตัวไหน?
ผลรันสามสถานการณ์:
?q=go → Query:"go", DefaultQuery:"go", GetQuery:("go", true)
ไม่ส่งเลย → Query:"", DefaultQuery:"x", GetQuery:("", false)
?q= → Query:"", DefaultQuery:"", GetQuery:("", true)
มีแต่ตัวที่สามที่คืนค่าที่ต่างกันระหว่างสองกรณีหลัง
ตัวเลือกที่ 1 ผิด c.Query คืน string ไม่เคยคืน nil และได้ "" เท่ากันทั้งสองกรณี ·
ตัวเลือกที่ 2 ผิดกลับด้าน: DefaultQuery คืน "x" ตอนไม่ส่ง
แต่พอส่ง ?q= มาจริงกลับได้ "" — ค่า default ทะลุ ซึ่งเป็นกับดักของมันเอง ·
ตัวเลือกที่ 4 ผิดเพราะ GetQuery ทำให้แล้ว ไม่ต้องแกะสตริงเอง
4. จะผูก ?sort=name&limit=10 เข้า struct ด้วย c.ShouldBindQuery ต้องติด tag อะไร?
รันจริงกับ struct ที่ใส่ form:"limit,default=20":
ยิง /recipes?sort=name&limit=10&page=2 → 200 {"limit":10,"page":2,"sort":"name"}
และยิง /recipes เปล่า ๆ → 200 {"limit":20,"page":1,"sort":"created_at"}
ส่วน struct ที่ใส่ json อย่างเดียว แล้วยิง ?q=go มาให้
ยังได้ Field validation for 'Q' failed on the 'required' tag —
ค่าไม่เข้า field เลย และข้อความ error ไม่เคยบอกว่าคุณใส่ tag ผิดชื่อ
ตัวเลือกที่ 2 ผิด ไม่มีขั้นตอนแปลงเป็น JSON เลย — json เป็น tag ของ ShouldBindJSON ·
ตัวเลือกที่ 3 ผิด Gin ไม่มี tag ชื่อ query ·
ตัวเลือกที่ 4 ผิด uri เป็น tag ของ ShouldBindUri ที่ใช้กับ path param
(ใส่ form:"id" แทน uri:"id" ก็ไม่เข้าเหมือนกัน)
ทำในโฟลเดอร์ใหม่ (go mod init ของตัวเอง +
go get github.com/gin-gonic/gin@v1.12.0) เป้าหมายไม่ใช่เขียน API ให้เสร็จ —
เป้าหมายคือพิสูจน์ให้ตัวเองเห็นว่าสี่ช่องแยกกันจริง
ทุกข้อเขียนรวมใน main.go ไฟล์เดียว โดยเริ่มจากโครงในกล่อง 🧰 หัวบท
แล้วเติม route ทีละข้อ · รันด้วย go run . ในโฟลเดอร์นั้น แล้วยิง curl จากอีกหน้าต่าง
- ทำ
GET /recipesที่รับ query ทั้งชุด ด้วยShouldBindQuery:q,sort(oneof=created_at name rating),limit(default=20,min=1,max=100),page(default=1,min=1)
ยิงสี่เคส: ครบทุกตัว · ไม่ส่งอะไรเลย ·?limit=1000·?limit=abc
สองเคสหลังได้ error คนละชนิดกัน — อธิบายให้ได้ว่าทำไม - ทำให้พังด้วยตัวเอง: เปลี่ยน tag จาก
form:"sort"เป็นjson:"sort"แล้วยิง?sort=nameอีกครั้ง
จดไว้ว่า error ที่ได้บอกสาเหตุจริงหรือเปล่า - ทำ
POST /recipesที่รับ body JSON (title,minutes,difficulty) แล้วลองส่งtitleไปทาง query แทน (curl -X POST "…/recipes?title=abc"โดยไม่ใส่ body) — ทายก่อนรันว่าจะได้ status อะไร
ทำถูกแล้วจะเห็น:400พร้อม errorEOF—ShouldBindJSONไปเปิด body แล้วไม่เจออะไรเลย ไม่เคยมองที่ query - อ่าน token จาก header: เขียน handler ที่ตอบ 401 ถ้าไม่มี
Authorizationแล้วลองเรียกc.GetHeaderด้วยตัวพิมพ์สามแบบ (Authorization/authorization/AUTHORIZATION)
เทียบกับc.Queryด้วยsortกับSort— ทำไมสองอันนี้ไม่เหมือนกัน
ทำถูกแล้วจะเห็น: header สามแบบได้ค่าเดียวกันหมด แต่ query สองแบบได้คนละค่า - เห็นการรั่วกับตา: รันด้วย
gin.Default()จริง ๆ แล้วcurlสองครั้ง — ครั้งแรกส่ง token ทาง?token=ครั้งที่สองส่งทาง header
เปิด log เทียบสองบรรทัด แล้วเขียนกฎของทีมคุณเองหนึ่งบรรทัดว่าอะไรห้ามอยู่ใน URL - ท้าทาย: ยิง
curl -X GET -d '{"q":"ต้มยำ"}'เข้า handler ที่อ่านc.Request.Body— อ่านออกไหม? แล้วตอบให้ได้ว่า ทำไม "อ่านออกบนเครื่องเรา" ยังไม่พอที่จะเอาไปใช้จริง
ทำถูกแล้วจะเห็น: ความยาว body ไม่เป็น0— คือมันมาถึงจริง ซึ่งเป็นเหตุผลที่กับดักนี้หลอกคนได้ - เขียนเทสพิสูจน์ (
httptest+ table-driven): เขียนเทสหนึ่งตัวที่ยิงsort=nameเข้าทาง body แล้ว assert ว่า handler ตอบด้วยค่า default (created_at) ไม่ใช่name
เทสนี้ต้อง fail ทันทีถ้ามีคนแอบเปลี่ยนโค้ดไปอ่าน body — นี่คือเทสที่ล็อก "สัญญาว่าช่องไหนเป็นของใคร" ไว้ให้ทั้งทีม
ผลรันจริงของผมสองเคสนั้น:
GET /recipes?limit=1000 -> 400 {"error":"Key: 'ListQuery.Limit' Error:Field validation for 'Limit' failed on the 'max' tag"}
GET /recipes?limit=abc -> 400 {"error":"strconv.ParseInt: parsing \"abc\": invalid syntax"}
คนละด่านกัน — 1000 แปลงเป็น int สำเร็จ
แล้วไปตกที่ด่าน validate (max) · ส่วน abc
ตกตั้งแต่ด่านแปลงชนิด จึงยังไม่มีโอกาสได้เจอ validator เลย
ทั้งคู่ควรตอบ 400 เหมือนกัน แต่อย่าส่งข้อความดิบสองอันนี้ให้ผู้ใช้ —
อันแรกโผล่ชื่อ struct ภายในของคุณ อันหลังโผล่ชื่อฟังก์ชันของ Go
💬 ตรงไหนที่รันแล้วได้ไม่ตรงกับในบท ส่ง output มาได้เลย —
โดยเฉพาะข้อ 5 ถ้า log ในเครื่องคุณหน้าตาต่างจากของผม ผมอยากเห็น
และถ้ามีข้อมูลชิ้นไหนในโปรเจกต์จริงของคุณที่เดินคำถามสามข้อแล้วยังตัดสินไม่ได้ว่าไปช่องไหน
เอามาถามได้ — เคสก้ำกึ่งคือเคสที่สอนได้ดีที่สุด
อ้างอิง
- RFC 9110 §9.3.1 — GET — สเปกบอกเองว่า body ที่ติดมากับ
GETไม่มีความหมายที่นิยามไว้ และ server ปฏิเสธได้ - RFC 9110 §5.1 — Field Names — ที่มาของ "ชื่อ header ไม่แยกตัวพิมพ์เล็กใหญ่"
- RFC 6750 §2.3 — URI Query Parameter — วิธีส่ง token ทาง query มีนิยามอยู่จริง แต่กำกับว่า SHOULD NOT ใช้ เพราะ URL มักถูกจดลง log
- pkg.go.dev —
Context.GetQuery· DefaultQuery · QueryArray - pkg.go.dev —
ShouldBindQuery· ShouldBindUri — ตัวไหนอ่าน tag อะไร - pkg.go.dev —
http.Header.Get— อธิบายว่าทำไมตัวพิมพ์ของชื่อ header ไม่สำคัญ - pkg.go.dev — validator/v10 — ที่มาของ tag
oneof,min,maxที่ใช้ในบทนี้ - MDN —
Accept· Content-Type — เส้นแบ่งระหว่าง "ของที่ส่งไป" กับ "ของที่อยากได้กลับ"