บทเรียน 35 · Day 8 · Request

query · body · header — ส่งข้อมูลเข้าไปทางไหน

~13 นาที · ต้องมี: บทเรียน 33 (resource กับ method), บทเรียน 13 (JSON กับ struct tag)

หนึ่ง request มี สี่ช่อง ให้ยัดข้อมูลเข้าไป — path, query, body, header
ช่องพวกนี้ไม่ได้ต่อถึงกัน ส่งผิดช่องแล้วฝั่ง server อ่านไม่เจอเงียบ ๆ ไม่มี error
และมีช่องหนึ่งที่ทุกอย่างที่คุณใส่ลงไปถูกจดลง log ไว้เต็ม ๆ — บทนี้จะยิง token ผ่านช่องนั้นให้ดู แล้วเปิด log ให้เห็นกับตา

🧰 ของที่ต้องมีถ้าจะพิมพ์ตาม

บทนี้ ไม่ใช่บทสอน 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 มาทาง Authorization header หรือยัง” จะชัดกว่าพูดว่า “ส่ง 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 (เว็บรวมสูตรอาหาร) ดู:

ข้อมูลที่อยากส่งตอบข้อไหนว่า "ใช่"ลงเอยที่
สูตรหมายเลข 421 — ไม่มีเลขนี้ก็ไม่รู้จะเปิดสูตรไหน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 แถว"

⚠️ กับดัก tag

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/jsonclient → 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 ถึงอันตรายกว่าช่องอื่น

เพราะ 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 · สรุปเป็นตารางเดียว

pathquerybodyheader
ตอบคำถามว่าอันไหนเอาแบบไหนบ้างข้อมูลคืออะไรใครขอ / ขอยังไง
ตัดออกแล้วไม่รู้จะทำกับอะไรยังตอบได้ แค่กว้างกว่าไม่มีของจะบันทึกไม่รู้ว่าเป็นใคร
โผล่ใน log ไหมโผล่โผล่ไม่โผล่ไม่โผล่
แชร์ URL ได้ไหมได้ได้ไม่ได้ไม่ได้
Gin อ่านด้วยc.Param / tag uric.Query / tag formShouldBindJSON / tag jsonc.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 จากอีกหน้าต่าง

  1. ทำ 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 คนละชนิดกัน — อธิบายให้ได้ว่าทำไม
  2. ทำให้พังด้วยตัวเอง: เปลี่ยน tag จาก form:"sort" เป็น json:"sort" แล้วยิง ?sort=name อีกครั้ง
    จดไว้ว่า error ที่ได้บอกสาเหตุจริงหรือเปล่า
  3. ทำ POST /recipes ที่รับ body JSON (title, minutes, difficulty) แล้วลองส่ง title ไปทาง query แทน (curl -X POST "…/recipes?title=abc" โดยไม่ใส่ body) — ทายก่อนรันว่าจะได้ status อะไร
    ทำถูกแล้วจะเห็น: 400 พร้อม error EOF — ShouldBindJSON ไปเปิด body แล้วไม่เจออะไรเลย ไม่เคยมองที่ query
  4. อ่าน token จาก header: เขียน handler ที่ตอบ 401 ถ้าไม่มี Authorization แล้วลองเรียก c.GetHeader ด้วยตัวพิมพ์สามแบบ (Authorization / authorization / AUTHORIZATION)
    เทียบกับ c.Query ด้วย sort กับ Sort — ทำไมสองอันนี้ไม่เหมือนกัน
    ทำถูกแล้วจะเห็น: header สามแบบได้ค่าเดียวกันหมด แต่ query สองแบบได้คนละค่า
  5. เห็นการรั่วกับตา: รันด้วย gin.Default() จริง ๆ แล้ว curl สองครั้ง — ครั้งแรกส่ง token ทาง ?token= ครั้งที่สองส่งทาง header
    เปิด log เทียบสองบรรทัด แล้วเขียนกฎของทีมคุณเองหนึ่งบรรทัดว่าอะไรห้ามอยู่ใน URL
  6. ท้าทาย: ยิง curl -X GET -d '{"q":"ต้มยำ"}' เข้า handler ที่อ่าน c.Request.Body — อ่านออกไหม? แล้วตอบให้ได้ว่า ทำไม "อ่านออกบนเครื่องเรา" ยังไม่พอที่จะเอาไปใช้จริง
    ทำถูกแล้วจะเห็น: ความยาว body ไม่เป็น 0 — คือมันมาถึงจริง ซึ่งเป็นเหตุผลที่กับดักนี้หลอกคนได้
  7. เขียนเทสพิสูจน์ (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 ในเครื่องคุณหน้าตาต่างจากของผม ผมอยากเห็น
และถ้ามีข้อมูลชิ้นไหนในโปรเจกต์จริงของคุณที่เดินคำถามสามข้อแล้วยังตัดสินไม่ได้ว่าไปช่องไหน เอามาถามได้ — เคสก้ำกึ่งคือเคสที่สอนได้ดีที่สุด

อ้างอิง