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

Gin — router ที่อ่านออก และ Context ที่ทำงานแทน

~16 นาที · ต้องมี: บทเรียน 37 (net/http + ServeMux), บทเรียน 13 (json struct tag)

Gin ทำให้ handler สั้นลงจริง — สามบรรทัดที่ใช้ตอบ JSON ในบท 37 เหลือบรรทัดเดียว
แต่บทนี้ไม่ได้มาขาย Gin ผมเอาโค้ดบท 37 มารีแฟกเตอร์ทีละบรรทัด แล้ววาง response จริงของทั้งสองฝั่งเทียบกัน — และมีอยู่ช่องหนึ่งที่ตัวเลขเปลี่ยนจาก 405 เป็น 404 เงียบ ๆ โดยไม่มีใครเตือน นั่นแหละคือราคาที่คุณจ่าย

🧰 เตรียมเครื่องก่อนอ่าน — บทนี้รันของจริงทั้งบท ถ้าจะพิมพ์ตามให้ทำสามข้อนี้

  1. สร้างโฟลเดอร์ใหม่ชื่อ day8 เข้าไปข้างในแล้วสั่ง go mod init day8
  2. go get github.com/gin-gonic/gin@v1.12.0 — สั่งในโฟลเดอร์เดียวกันนั้น (ต้องมี go.mod อยู่ก่อน ไม่งั้น go จะไม่รู้ว่าจะเขียน dependency ลงไฟล์ไหน) · ต่อเน็ตครั้งเดียว
  3. ตรวจว่าตรงกับเครื่องผม: go version ต้องได้ go1.26.5

ผลรันทุกก้อนในหน้านี้มาจาก go version go1.26.5 darwin/arm64 + github.com/gin-gonic/gin v1.12.0 — ถ้าเครื่องคุณได้ไม่ตรง ส่ง output มาได้เลย

1 · Gin ไม่ได้มาแทน net/http — มันมานั่งทับ

ความเข้าใจผิดข้อแรกของทุกคนคือคิดว่า Gin เป็น "web server คนละตัว" กับที่เรียนมาในบท 37
ไม่ใช่ครับ — Gin ยังใช้ net/http เป็นคนรับ TCP และ parse HTTP อยู่เหมือนเดิมทุกตัวอักษร
(TCP = ท่อรับส่งข้อมูลดิบระหว่างสองเครื่อง · parse HTTP = แกะข้อความที่ไหลมาในท่อ ให้กลายเป็น method / path / header ที่โค้ดหยิบไปใช้ได้)
*gin.Engine เป็นแค่ http.Handler ตัวหนึ่ง (มี method ServeHTTP) ที่คุณเอาไปเสียบแทน *http.ServeMux ได้ตรง ๆ

ผมพิสูจน์ข้อนี้ตลอดทั้งบทโดยไม่รู้ตัว — โค้ดที่ผมใช้ยิงทดสอบทุกอันเขียนแบบนี้:

ts := httptest.NewServer(r)   // r คือ *gin.Engine — httptest รับ http.Handler เท่านั้น

httptest.NewServer รับพารามิเตอร์เป็น http.Handler ถ้า *gin.Engine ไม่ใช่ http.Handler บรรทัดนี้จะคอมไพล์ไม่ผ่าน แต่มันผ่าน และ server ทำงานได้ — นี่คือ interface กับ duck typing (มีเมธอดครบตามที่ interface ขอ ก็นับว่าใช้แทนกันได้เลย ไม่ต้องไปประกาศว่า "ฉันเป็นชนิดนี้นะ") จาก บทเรียน 10 ทำงานให้เห็นของจริง

  request จาก browser
      │
      ▼
  net/http            ← รับ TCP, parse HTTP, จัดการ goroutine ต่อ connection
      │                  (ตัวนี้ไม่หายไปไหน แม้ใช้ Gin)
      ▼
  http.Handler        ← ช่องเสียบเดียว: เสียบ ServeMux ก็ได้ เสียบ gin.Engine ก็ได้
      │
      ├─▶ *http.ServeMux   → handler(w http.ResponseWriter, r *http.Request)
      │
      └─▶ *gin.Engine      → handler(c *gin.Context)
                              (c ห่อ w กับ r ไว้ข้างในอีกที)
🔌 เทียบให้เห็นภาพ — ปลั๊กพ่วง

เต้ารับที่ผนังบ้านมีรูปแบบเดียว ใครจะมาเสียบก็ต้องเข้ารูนั้น
สิ่งที่เสียบเข้าไปคือปลั๊กพ่วง — ตัวที่ไม่ได้ทำงานเองแต่แจกไฟต่อ ให้อุปกรณ์ปลายทางอีกที · จะเป็นปลั๊กพ่วงรุ่นเรียบ ๆ ช่องเดียวไม่มีสวิตช์ หรือรุ่นหลายช่องที่มีสวิตช์แยกมีกันไฟกระชากก็ได้ เข้ารูเดียวกันเป๊ะทั้งคู่
ไม่ว่าจะแบบไหน ไฟก็ยังมาจากมิเตอร์เดิม สายเดิม การไฟฟ้าเจ้าเดิม

· เต้ารับที่ผนัง = http.Handler — ช่องเสียบแบบเดียวที่ net/http รู้จัก
· ปลั๊กพ่วงรุ่นเรียบ ๆ สวิตช์น้อย = *http.ServeMux ของบท 37 — แจกงานต่อได้เหมือนกัน แค่ฟีเจอร์น้อยกว่า
· ปลั๊กพ่วงรุ่นหลายช่อง มีสวิตช์แยก มีกันไฟกระชาก = *gin.Engine — เข้ารูเดียวกันเป๊ะ แต่แจกงานต่อได้เก่งกว่า · ของชั้นเดียวกันคนละยี่ห้อ ไม่ใช่คนละชั้นกัน
· พัดลมที่ปลายสาย = handler ของเราเอง — ตัวที่ทำงานจริงจนจบ
· การไฟฟ้า = net/http ที่ยังทำงานอยู่ข้างหลังทั้งหมด

ที่ไม่เหมือนกัน: ปลั๊กพ่วงจ่ายไฟออกมาเหมือนเต้ารับทุกประการ แต่ Gin เปลี่ยนพฤติกรรมบางอย่างจริง ๆ ที่ชัดที่สุดคือเคสยิงผิด method: net/http ตอบ 405 แต่ Gin ตอบ 404 — หัวข้อ 7 กับ 8 จะยิงให้ดูของจริง

สิ่งที่ Gin เพิ่มเข้ามาจริง ๆ มีสองอย่างเท่านั้น:

  1. router ที่เร็วกว่าและเขียน param ได้สั้นกว่า — ข้างในเป็น radix tree (ต้นไม้ที่เก็บ path โดยรวบส่วนที่ขึ้นต้นเหมือนกันไว้กิ่งเดียว เช่น /users/me กับ /users/:id ใช้กิ่ง /users/ ร่วมกัน — หา route เจอโดยไม่ต้องไล่เทียบทีละเส้น) ที่ยกมาจาก httprouter (ไฟล์ tree.go ใน Gin ยังติด license header ของ httprouter อยู่เลย)
  2. *gin.Context — ตัวห่อ w กับ r พร้อมเมธอดสำเร็จรูปที่คุณเคยเขียนเองในบท 37 (c.Param, c.Query, c.JSON)
🗣 ศัพท์ที่เพิ่งเรียนไป

คำพวกนี้คือชื่อที่วงการใช้เรียกสิ่งที่คุณเพิ่งเข้าใจไป — ใช้คำให้ตรง แล้วคำตอบที่ได้จะตรง

http.Handler
“รูเสียบมาตรฐาน” ของโลก Go web — อะไรก็ตามที่มีเมธอด ServeHTTP(http.ResponseWriter, *http.Request) เสียบเข้าไปได้หมด · ชื่อต้องตรง และพารามิเตอร์ต้องตรงด้วย ขาดอย่างใดอย่างหนึ่งก็ไม่นับ — ถ้าเขียน ServeHTTP() เปล่า ๆ คอมไพเลอร์จะฟ้องว่า wrong type for method ServeHTTP แล้วบอกต่อว่า have ServeHTTP() / want ServeHTTP(http.ResponseWriter, *http.Request) · เวลาถามให้พูดว่า “ตัวนี้เป็น http.Handler ไหม” จะสื่อกว่าถามว่า “เอาไปใช้กับ server ได้ไหม”
router
ส่วนที่ดูว่า path ที่เข้ามาควรวิ่งไปเข้า handler ตัวไหน · ServeMux กับ gin.Engine ต่างก็เป็น router ทั้งคู่ · ประโยคที่ใช้ได้จริง: “Gin เปลี่ยนแค่ router ไม่ได้เปลี่ยน HTTP server”
radix tree
โครงสร้างข้อมูลที่ Gin ใช้เก็บ path โดยรวบส่วนหัวที่เหมือนกันไว้กิ่งเดียว · ถามได้ว่า “ที่ Gin panic ตอนลง route เพราะ radix tree มันชนกันใช่ไหม” — คำนี้จะพาไปเจอคำตอบเร็วมาก
wrapper
“ตัวห่อ” — ของที่คลุมของเดิมไว้แล้วให้ปุ่มที่ใช้ง่ายกว่า โดยข้างในยังเรียกของเดิมอยู่ · *gin.Context คือ wrapper ของ w กับ r
drop-in replacement
ของที่เอาไปเสียบแทนอันเดิมได้เลยโดยไม่ต้องแก้รอบข้าง · พูดว่า “gin.Engine เป็น drop-in replacement ของ ServeMux ไหม” จะได้คำตอบที่ตรงประเด็นกว่าถามว่า “ใช้แทนกันได้ไหม”
คำตอบของบทนี้: เสียบเข้าช่อง http.Handler ได้เหมือนกันเป๊ะ แต่ไม่ใช่ drop-in เต็มตัว เพราะ behavior บางอย่างเปลี่ยน (405 กลายเป็น 404 · body ของ 404 เปลี่ยนหน้าตา) — หัวข้อ 7 กับ 8 ยิงให้ดูของจริง

2 · เขียน Gin ตัวแรก — แล้วอ่าน log ที่มันพ่นออกมาให้ออก

package main

import (
    "net/http"

    "github.com/gin-gonic/gin"
)

func main() {
    r := gin.Default()

    r.GET("/ping", func(c *gin.Context) {
        c.JSON(http.StatusOK, gin.H{"message": "pong"})
    })

    r.Run(":8080")
}

ก่อนอ่านตาราง — สองคำนี้จะโผล่ทั้งบท ขอนิยามให้ชัดก่อน

ห้าชิ้นในโค้ดนี้ ต้องแยกให้ออกว่าอันไหนคืออะไร:

ชิ้นคืออะไรของเทียบในบท 37
gin.Default()สร้าง engine พร้อม middleware 2 ตัวhttp.NewServeMux() (แต่ mux ไม่มี middleware ให้)
r.GET(path, h)ลง route เฉพาะ method GETmux.HandleFunc("GET /ping", h)
gin.H{...}ชื่อย่อของ map[string]any เฉย ๆmap[string]any{...}
c.JSON(code, v)set Content-Type + WriteHeader + Encode ในบรรทัดเดียวสามบรรทัดที่เรียงผิดไม่ได้
r.Run(":8080")สร้าง &http.Server{…} แล้วเรียก ListenAndServe ให้http.ListenAndServe(":8080", mux)

gin.H เป็น map — ไม่ใช่ struct แปลว่า key ที่ออกมาใน JSON เรียงตามตัวอักษรเสมอ ไม่ใช่ตามลำดับที่คุณพิมพ์ ผมรันจริง gin.H{"id":…, "nope":…, "nope_n":…, "count":42, "ok":true} ได้ออกมาแบบนี้:
{"count":42,"id":"42","nope":"","nope_n":0,"ok":true}
ถ้า response ต้องมีสัญญาแน่นอน (contract) ให้ใช้ struct + json tag จาก บทเรียน 13 แทน — gin.H เหมาะกับ error message สั้น ๆ มากกว่า

log ตอน boot ที่คนใหม่ตกใจ

พอรัน gin.Default() ในโหมด default มันพ่นก้อนนี้ออกมาก่อนรับ request สักตัว
(ก้อนนี้มาจากโปรแกรมทดสอบของผม ซึ่งลง route ไว้สองเส้นคือ /ping กับ /boom และฟังพอร์ต :18081 — ถ้าคุณรันโค้ดข้างบนตรง ๆ จะเห็นแค่บรรทัด /ping และท้ายสุดเป็น :8080 ที่เหลืออกมาเหมือนกันทุกบรรทัด)

[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:	export GIN_MODE=release
 - using code:	gin.SetMode(gin.ReleaseMode)

[GIN-debug] GET    /ping                     --> main.main.func1 (3 handlers)
[GIN-debug] GET    /boom                     --> main.main.func2 (3 handlers)
[GIN-debug] [WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value.
Please check https://github.com/gin-gonic/gin/blob/master/docs/doc.md#dont-trust-all-proxies for details.
[GIN-debug] Listening and serving HTTP on :18081

อ่านให้ออกทีละบรรทัด — ไม่มีบรรทัดไหนคือ error สักบรรทัด

และเมื่อมี request เข้ามา Logger จะพ่นบรรทัดนี้ต่อ 1 request:

[GIN] 2026/08/20 - 21:49:10 | 200 |  87.25µs |       127.0.0.1 | GET      "/ping"

3 · gin.Default() vs gin.New() — ต่างกันสองบรรทัด แต่ต่างกันคนละโลก

ทุกคนพิมพ์ gin.Default() ตามตัวอย่าง โดยไม่เคยรู้ว่ามันแถมอะไรมาให้บ้าง ผมเลยเปิดฝาดูด้วยการนับ Handlers แล้วพิมพ์ชื่อฟังก์ชันจริงออกมา
(./p1 คือชื่อโฟลเดอร์โปรแกรมทดสอบของผมเอง ไม่ใช่คำสั่งมาตรฐานอะไร — ของคุณจะชื่ออะไรก็ได้ ถ้าโค้ดอยู่ในโฟลเดอร์ปัจจุบันก็สั่ง go run . เฉย ๆ)

$ go run ./p1
len(Default().Handlers) = 2
len(New().Handlers)     = 0
Default handler[0] = github.com/gin-gonic/gin.LoggerWithConfig.func1
Default handler[1] = github.com/gin-gonic/gin.CustomRecoveryWithWriter.func1

จบ — Default() = New() + Logger + Recovery เป๊ะ ๆ ไม่มีอย่างอื่นแถม
นี่ยังอธิบาย (3 handlers) เมื่อกี้ด้วย: 2 ตัวนี้ + handler ของเรา = 3
ถ้าสร้างด้วย gin.New() route เดียวกันจะขึ้น (1 handlers)

ทายก่อนรัน — สอง engine นี้ต่างกันแค่บรรทัดที่สร้าง โค้ด route เหมือนกันเป๊ะ

d := gin.Default()                                    // ฟังอยู่ที่ :18081
d.GET("/boom", func(c *gin.Context) { panic("explode") })

n := gin.New()                                        // ฟังอยู่ที่ :18082
n.GET("/boom", func(c *gin.Context) { panic("explode") })

ยิง curl ไปที่ /boom ของทั้งสองตัว — ได้ status อะไร? และที่สำคัญกว่านั้น ตัวที่ไม่มี Recovery จะตายทั้ง process ไหม (จำ บทเรียน 8 เรื่อง recover ไว้ก่อนตอบ)

$ curl -s -o /dev/null -w "status=%{http_code}\n" http://127.0.0.1:18081/boom   # gin.Default()
status=500

$ curl -s -o /dev/null -w "status=%{http_code}\n" http://127.0.0.1:18082/boom   # gin.New()
FAILED: curl status=000

ฝั่ง Default() — Recovery จับ panic ได้ ตอบ 500 แล้ว log ก้อนนี้ให้ (ตัดมาจริงบางส่วน สังเกตว่ามันบอกบรรทัดที่ panic ให้เลย):

2026/08/20 21:49:10 [Recovery] 2026/08/20 - 21:49:10 panic recovered:
GET /boom HTTP/1.1
Host: 127.0.0.1:18081
Accept: */*
User-Agent: curl/8.13.0

explode
.../verify-gin/p1/main.go:21 (0x1030d504b)
	main.func2: d.GET("/boom", func(c *gin.Context) { panic("explode") })
...
[GIN] 2026/08/20 - 21:49:10 | 500 | 681.584µs |       127.0.0.1 | GET      "/boom"

ฝั่ง New() — status=000 ของ curl แปลว่า "ไม่ได้ response กลับมาเลย" ไม่ใช่ 500 · panic ไหลทะลุ Gin ไปถึง net/http ซึ่งปิด connection ทิ้งแล้ว log ให้เอง:

2026/08/20 21:50:10 http: panic serving 127.0.0.1:54717: explode
goroutine 35 [running]:
net/http.(*conn).serve.func1()
...

แต่ process ไม่ตาย — net/http มี recover() ของตัวเองอยู่ชั้นนอกสุด ยิง route อื่นต่อได้ปกติ:
$ curl … http://127.0.0.1:18082/ping → status=200
สรุปความต่างที่ต้องจำ: ไม่มี Recovery ≠ เซิร์ฟเวอร์ล่ม แต่ = ลูกค้าคนนั้นได้ connection ขาดกลางอากาศแทน error page ซึ่งดีบั๊กยากกว่า เพราะฝั่ง client ไม่มี status code ให้ดูเลย

🍜 เทียบให้เห็นภาพ — ครัวมีเรื่อง แล้วใครออกมาบอกลูกค้า

ลูกค้าสั่งอาหารไปแล้ว จู่ ๆ ในครัวเกิดเรื่องจนทำจานนั้นต่อไม่ได้

· ร้านที่มีผู้จัดการยืนอยู่ = gin.Default() — ผู้จัดการเดินออกมาบอกโต๊ะนั้นว่า “ขอโทษครับ จานนี้ทำไม่ได้” ลูกค้าได้คำตอบกลับไป = ได้ 500 · แล้วร้านก็ขายต่อ
· ร้านที่ไม่มีใครออกมา = gin.New() — ลูกค้านั่งรอเงียบ ๆ จนโดนเก็บโต๊ะ ไม่มีใครมาบอกสักคำ = curl ได้ status=000
· และข้อสำคัญที่คนกลัวกันผิด ๆ: ร้านไม่ได้ปิดทั้งสองแบบ โต๊ะอื่นยังสั่งได้ปกติ

ที่ไม่เหมือนกัน: ลูกค้าในร้านจริงยังเห็นควันจากครัว พอเดาออกว่าเกิดอะไรขึ้น แต่ฝั่ง client ที่ได้ 000 ไม่มีอะไรให้ดูเลยสักอย่าง — ไม่มี status ไม่มี body · เพราะงั้น “ไม่มี Recovery” จึงดีบั๊กยากกว่า “ได้ 500” ไม่ใช่ปลอดภัยกว่า

✅ กฎที่ใช้ได้จริง

ใช้ gin.Default() ตอนเรียนและตอน dev · ใช้ gin.New() เมื่อคุณต้องการ เปลี่ยน Logger เป็นของตัวเอง (เช่น พ่น log เป็น JSON เพื่อส่งเข้าระบบรวม log ของทีม) — แต่ ต้อง r.Use(gin.Recovery()) กลับเข้าไปเสมอ ไม่งั้นคุณกำลังแลก error 500 ที่ดีบั๊กได้ กับ connection ขาดที่ดีบั๊กไม่ได้

🗣 ศัพท์ที่เพิ่งเรียนไป
middleware
ฟังก์ชันที่คั่นอยู่ในเส้นทางของ request — ทำงานทั้งก่อนและหลัง handler (ท่อนหลังคือที่ Logger รู้ว่า status เป็นอะไรและใช้เวลาเท่าไร ถึงพิมพ์ | 500 | 681.584µs | ออกมาได้) · Logger กับ Recovery คือ middleware
ผูกที่ engine ด้วย r.Use() = ทำงานทุก request · ผูกที่ group = ทำงานเฉพาะ route ในกลุ่มนั้น (เจอในหัวข้อ 6) · ประโยคที่ใช้ได้จริง: “route นี้มี middleware กี่ตัวก่อนถึง handler”
handler chain
“แถวคิว” ของฟังก์ชันที่ request ต้องวิ่งผ่านเรียงกัน · (3 handlers) ที่ Gin พิมพ์ออกมา คือความยาวของ chain นี้ ไม่ใช่จำนวน route
panic / recover
panic = โปรแกรมเจอสถานการณ์ที่ไปต่อไม่ได้แล้วโยนทิ้งกลางอากาศ · recover = การรับของที่ถูกโยนไว้ก่อนตกพื้น · เวลาถามให้พูดว่า “panic ตัวนี้มีใคร recover ไว้หรือเปล่า” จะสื่อกว่าถามว่า “ทำไมมันพัง”
panic containment / fail-safe
“พังแล้วไม่ลากคนอื่นลงไปด้วย” — panic เกิดใน handler ตัวเดียว แต่ถูกกันไว้ตรงนั้น แล้วยังตอบ error ที่อ่านออกกลับไปให้ client · นี่คือสิ่งที่ gin.Recovery() ทำจริง ๆ
อย่าเรียกมันว่า graceful degradation — คำนั้นแปลว่าระบบยังให้บริการต่อได้ใน ระดับที่ลดลง เมื่อชิ้นส่วนบางอย่างพัง (เช่น DB ล่ม แต่ยังตอบข้อมูลเก่าจาก cache ได้ · ระบบแนะนำสินค้าล่ม แต่ยังกดซื้อได้) · ตอบ 500 คือพังเต็ม ๆ ให้ลูกค้าคนนั้น ไม่ใช่การลดระดับ — บอก senior ว่า “ผมทำ graceful degradation แล้ว ตอบ 500 แทน” เขาจะเข้าใจว่าคุณมี fallback path จริง ๆ ซึ่งไม่มี
connection reset / status=000
“สายขาดกลางอากาศ” — client ไม่ได้อะไรกลับมาเลย ไม่ใช่ error ที่มี status · บอก senior ว่า “curl ได้ 000 ไม่ใช่ 500” เขาจะรู้ทันทีว่าปัญหาอยู่คนละชั้นกัน

4 · c.Param — path parameter ที่สั้นกว่า แต่กติกาต่างกัน

บท 37 เขียน {id} แล้วอ่านด้วย r.PathValue("id") · Gin เขียน :id แล้วอ่านด้วย c.Param("id") — แค่นั้น

net/http (บท 37)

mux.HandleFunc("GET /users/{id}",
  func(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    …
})

Gin

r.GET("/users/:id",
  func(c *gin.Context) {
    id := c.Param("id")
    …
})

ผมลง route สองอันพร้อมกัน — /users/:id กับ /users/me — แล้วยิงจริงห้าแบบ
handler ของ /users/me ตอบ gin.H{"who": "me (static)"} เฉย ๆ ส่วน handler ของ :id ตั้งใจตอบ ห้า field เพื่อดูหลายอย่างในนัดเดียว: id = c.Param("id") · nope = c.Param("nope") (ชื่อ param ที่ไม่มีอยู่จริง) · nope_n = len() ของค่านั้น · count กับ ok เป็นค่าคงที่ที่ใส่ไว้ให้เห็นการเรียงคีย์ของ gin.H

  GET    /users/me        -> 200  body={"who":"me (static)"}
  GET    /users/42        -> 200  body={"count":42,"id":"42","nope":"","nope_n":0,"ok":true}
  GET    /users/mex       -> 200  body={"count":42,"id":"mex","nope":"","nope_n":0,"ok":true}
  GET    /users/          -> 404  body=404 page not found
  GET    /users           -> 404  body=404 page not found

สามข้อสรุปจากห้าบรรทัดนี้:

  1. static ชนะ param — /users/me เข้า handler ของ me ไม่ใช่ :id ที่ได้ id="me" · และไม่ใช่เพราะลงก่อนหลัง แต่เพราะ router มองว่า เจาะจงกว่าชนะ — เหมือน net/http เป๊ะ (แต่ /users/mex ที่แค่ขึ้นต้นเหมือนกัน ตกไปที่ :id ตามปกติ)
  2. c.Param("nope") คืนสตริงว่าง ไม่ panic — nope_n คือ len() ของมัน ได้ 0 พฤติกรรมเดียวกับ r.PathValue คือไม่มีตัวที่สองบอก ok พิมพ์ชื่อ param ผิดจึงเงียบสนิท ไม่มี error ให้เห็น
  3. /users/ ได้ 404 ไม่ใช่ id="" — :id ต้องมีอย่างน้อยหนึ่งตัวอักษร มันไม่เข้า handler เลย เพราะฉะนั้นอย่าเขียน if id == "" { … } เพื่อกันเคสนี้ — โค้ดนั้นจะไม่มีวันทำงาน

5 · Query / DefaultQuery / GetQuery — ทำไมต้องมีสามตัว

query string คือส่วนหลัง ? เช่น /search?q=go — เขียนเป็นคู่ key=value ต่อกันด้วย & ธรรมเนียมของ REST API คือใช้ query สำหรับ filter / sort / paginate (กรอง / เรียง / แบ่งหน้า) ส่วน path เอาไว้ระบุตัวของ ข้อมูลที่จะหยิบ — /users/42 คือ "ผู้ใช้คนที่ 42" ส่วน /users?dept=hr คือ "ผู้ใช้ทั้งหมด แต่กรองเอาเฉพาะแผนก hr"
Gin ให้มาสามเมธอดสำหรับอ่าน query ซึ่งดูซ้ำซ้อนจนกว่าจะเจอเคสที่สาม

ทายก่อนรัน — handler เดียว อ่าน q ด้วยสามวิธี แล้วยิงสามแบบ

r.GET("/search", func(c *gin.Context) {
    v, ok := c.GetQuery("q")
    c.JSON(200, gin.H{
        "Query":        c.Query("q"),
        "DefaultQuery": c.DefaultQuery("q", "x"),
        "GetQuery_val": v,
        "GetQuery_ok":  ok,
        "Query_len":    len(c.Query("q")),
    })
})

// ยิงสามแบบ: /search?q=go   ·   /search   ·   /search?q=

แบบที่ 3 คือ ส่ง key q มาแต่ค่าว่าง — DefaultQuery("q","x") จะคืน "x" หรือ ""? และ GetQuery จะให้ ok เป็นอะไร

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}

บรรทัดที่ 2 กับ 3 คือหัวใจ: Query คืน "" เหมือนกันทั้งคู่ — มันแยกไม่ออกว่า "ไม่ได้ส่งมา" หรือ "ส่งมาแต่ว่าง"
DefaultQuery ก็แยกออกแค่ครึ่งเดียว: ไม่ส่งมาเลยได้ "x" · ส่ง ?q= มาได้ "" ไม่ใช่ "x" (คนส่วนใหญ่ทายผิดข้อนี้ — เพราะคิดว่า "ค่าว่างก็คือไม่มีค่า")
มีแค่ GetQuery ที่แยกสองเคสนี้ออกจากกันได้ ผ่านตัวที่สอง ok

เมื่อไหร่ที่ความต่างนี้สำคัญจริง? เวลาที่ค่าว่างมีความหมาย: /users?department= ที่แปลว่า "ขอคนที่ยังไม่มีแผนก" ต่างจาก /users ที่แปลว่า "ขอทุกคน" — ถ้าใช้ Query ตัวเดียว API จะตอบผิดเงียบ ๆ รูปแบบ value, ok นี้เป็นตัวเดียวกับที่คุณเจอในบทที่แล้ว ๆ มา: v, ok := m[key] และ v, ok := x.(T) จาก บทเรียน 11

📋 เทียบให้เห็นภาพ — ช่องที่เว้นว่างในใบสมัคร

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

· c.Query("q") = อ่านแค่ตัวหนังสือในช่อง → ได้ "" ทั้งสองกรณี แยกไม่ออก
· c.DefaultQuery("q","x") = เติมค่ามาตรฐานให้เฉพาะตอนไม่มีช่องนั้นมาด้วยซ้ำ
· c.GetQuery("q") = บอกเพิ่มอีกคำว่า “ช่องนี้มีมาไหม” ผ่านค่าที่สอง ok

ที่ไม่เหมือนกัน: คนอ่านใบสมัครจริงยังเห็นด้วยตาว่ามีช่องอยู่แต่ไม่ได้กรอก แต่ค่าที่ c.Query คืนมาคือสตริงตัวเดียว — ในตัวมันไม่มีที่ให้บอกว่า “มีช่องนี้ส่งมาไหม” · ข้อมูลดิบไม่ได้หายไปไหน มันยังอยู่ครบที่ c.Request.URL.RawQuery ตลอดอายุ request และเรียก c.GetQuery ทีหลังก็ยังได้คำตอบเต็ม (โค้ดข้างบนก็เรียกทั้งสองตัวใน handler เดียวกันอยู่แล้ว) — แต่ถ้าคุณตัดสินใจจากค่าที่ Query คืนมาอย่างเดียว คุณจะแยกสองเคสนี้ไม่ออก ต้องไปถามด้วย GetQuery แทน

🗣 ศัพท์ที่เพิ่งเรียนไป
query string
ส่วนหลัง ? ใน URL · เขียนเป็นคู่ key=value ต่อกันด้วย & · เวลาคุยให้แยกให้ชัดว่า “ค่านี้อยู่ใน path หรืออยู่ใน query” เพราะอ่านคนละเมธอดกัน
comma-ok idiom
ท่ามาตรฐานของ Go ที่คืนค่าสองตัว v, ok := … โดยตัวที่สองบอกว่า “มีจริงไหม” · เจอที่ m[key] · x.(T) · และ c.GetQuery · ถาม AI ว่า “เมธอดนี้มีเวอร์ชัน comma-ok ไหม” จะเจอทางออกเร็วกว่าถามว่า “จะเช็คว่ามีค่าไหมยังไง”
zero value
ค่าเริ่มต้นที่ Go ให้เมื่อไม่มีค่าจริง — string ได้ "" · int ได้ 0 · ปัญหาทั้งหัวข้อนี้เกิดจาก “zero value แยกไม่ออกจากค่าที่ผู้ใช้ส่งมาจริงแต่ว่าง” — พูดประโยคนี้กับ senior ได้เลย
optional parameter
พารามิเตอร์ที่จะส่งมาหรือไม่ส่งก็ได้ · ทั้ง DefaultQuery และ GetQuery มีไว้จัดการกับของแบบนี้

6 · r.Group — versioning ที่ไม่ต้องพิมพ์ /api/v1 ห้ารอบ

สไลด์บอกว่า API ควรมีเวอร์ชันใน path: /api/v1/users → วันหนึ่งเปลี่ยน contract ก็เปิด /api/v2/users ขนานกันไปโดยไม่ทำ client เก่าพัง
ปัญหาคือถ้าพิมพ์ prefix เองทุก route คุณจะพิมพ์ตกสักตัวแน่นอน และมันจะไม่ error ให้ด้วย — แค่กลายเป็น 404 เงียบ ๆ · r.Group แก้ปัญหานี้ตรง ๆ

r := gin.Default()

v1 := r.Group("/api/v1")
v1.GET("/recipes",      listRecipes)
v1.POST("/recipes",     createRecipe)
v1.GET("/recipes/:id",  getRecipe)
v1.PUT("/recipes/:id",  updateRecipe)
v1.DELETE("/recipes/:id", deleteRecipe)

listRecipes, createRecipe, … คือฟังก์ชันที่คุณเขียนเองไว้ในไฟล์เดียวกัน หน้าตา func listRecipes(c *gin.Context) { … } — เขียนแยกเป็นชื่อแบบนี้แทนที่จะยัด func(c *gin.Context){…} ลงไปในบรรทัด route ทำให้ตาราง route ที่ Gin พิมพ์ออกมาอ่านรู้เรื่องด้วย:

[GIN-debug] GET    /api/v1/recipes           --> main.listRecipes (3 handlers)
[GIN-debug] POST   /api/v1/recipes           --> main.createRecipe (3 handlers)
[GIN-debug] GET    /api/v1/recipes/:id       --> main.getRecipe (3 handlers)
[GIN-debug] PUT    /api/v1/recipes/:id       --> main.updateRecipe (3 handlers)
[GIN-debug] DELETE /api/v1/recipes/:id       --> main.deleteRecipe (3 handlers)
--- v1.BasePath() = /api/v1

(บรรทัดสุดท้ายเป็นของผมเอง ไม่ใช่ของ Gin — ผมเรียก v1.BasePath() มาพิมพ์ เพื่อยืนยันว่า group จำ prefix อะไรไว้)

สังเกตสองอย่าง: (1) prefix /api/v1 ถูกเติมให้ตอน register ไม่ใช่ตอน request — ค่าที่เก็บไว้จริงคือ path เต็ม (2) path ที่ลงคือ /api/v1/recipes เท่านั้น ยิง /recipes เฉย ๆ ได้ 404

ทำไม group ถึงสำคัญกว่าที่คิด — บทหน้าคุณจะเอา middleware มาแปะทั้ง group ทีเดียว เช่น auth := r.Group("/api/v1", AuthRequired()) แล้วทุก route ใน group นั้น ต้องผ่าน auth หมดโดยไม่ต้องไปแปะทีละตัว — นั่นคือเหตุผลจริงที่ group มีอยู่ ไม่ใช่แค่ประหยัดการพิมพ์

7 · หัวใจของบท — รีแฟกเตอร์โค้ดบท 37 เป็น Gin ทีละบรรทัด

รีแฟกเตอร์ แปลว่าเขียนโค้ดใหม่ให้อ่านง่ายขึ้น โดยผลลัพธ์ต้องเหมือนเดิมเป๊ะ ถ้าผลลัพธ์เปลี่ยน อันนั้นไม่เรียกรีแฟกเตอร์แล้ว เรียกว่าเปลี่ยนพฤติกรรม
เอา handler ที่คุณเขียนในบท 37 มาวางจริง ๆ แล้วแปลงทีละบรรทัด ห้ามเปลี่ยน behavior — เป้าหมายคือให้ response เหมือนเดิมทุกไบต์

สองก้อนข้างล่างเป็นท่อนกลางของ main() ไม่ใช่ไฟล์เต็ม ถ้าจะ copy ไปรัน ต้องมีของอีกสามอย่างในไฟล์เดียวกันด้วย: package main · import (ฝั่งซ้ายใช้ net/http + encoding/json · ฝั่งขวาใช้ net/http + github.com/gin-gonic/gin — ฝั่งขวายัง import "net/http" อยู่เพราะยังใช้ค่าคงที่ http.StatusOK) · และ struct User จากบท 37: type User struct { ID string `json:"id"`; Name string `json:"name"` }

ก่อน — net/http

mux := http.NewServeMux()

mux.HandleFunc("GET /api/v1/users/{id}",
 func(w http.ResponseWriter, r *http.Request) {

  id := r.PathValue("id")

  if id == "0" {
    w.Header().Set("Content-Type",
        "application/json; charset=utf-8")
    w.WriteHeader(http.StatusNotFound)
    json.NewEncoder(w).Encode(
        map[string]string{"error": "user not found"})
    return
  }

  w.Header().Set("Content-Type",
      "application/json; charset=utf-8")
  w.WriteHeader(http.StatusOK)
  json.NewEncoder(w).Encode(
      User{ID: id, Name: "Somchai"})
})

http.ListenAndServe(":8080", mux)

หลัง — Gin

r := gin.Default()
v1 := r.Group("/api/v1")

v1.GET("/users/:id",
 func(c *gin.Context) {

  id := c.Param("id")

  if id == "0" {
    c.JSON(http.StatusNotFound,
        gin.H{"error": "user not found"})
    return
  }

  c.JSON(http.StatusOK,
      User{ID: id, Name: "Somchai"})
})

r.Run(":8080")

แปลทีละบรรทัด — จำตารางนี้พอ:

net/httpGinสิ่งที่หายไป
http.NewServeMux()gin.Default()—
"GET /api/v1/users/{id}"Group("/api/v1") + GET("/users/:id")prefix ที่ต้องพิมพ์ซ้ำ
w, r *http.Request (2 ตัว)c *gin.Context (1 ตัว)—
r.PathValue("id")c.Param("id")—
Header().Set → WriteHeader → Encodec.JSON(code, v)โอกาสเรียงผิดลำดับ
http.ListenAndServe(addr, mux)r.Run(addr)—

บรรทัดที่กำไรจริงคือแถวที่ห้า — สามบรรทัดเหลือหนึ่ง
และมันไม่ได้กำไรแค่ "สั้นกว่า" · จำบทเรียนเจ็บ ๆ จากบท 37 ได้ไหม — Header().Set() หลัง WriteHeader() จะไม่มีผล แบบเงียบสนิท ไม่ error ไม่ log ทำให้ Content-Type กลายเป็น text/plain โดยที่คุณไม่รู้ตัว
c.JSON ทำให้เขียนผิดลำดับไม่ได้ตั้งแต่แรก เพราะมันเป็นบรรทัดเดียว — นี่คือกำไรที่แท้จริง ไม่ใช่จำนวนบรรทัด

แล้ว response จริงเหมือนกันไหม? ผมยิงเทียบทั้งสี่เคส

=== net/http (แบบบท 37) ===
  GET    /api/v1/users/42     -> 200  Content-Type="application/json; charset=utf-8"
        body={"id":"42","name":"Somchai"}
  GET    /api/v1/users/0      -> 404  Content-Type="application/json; charset=utf-8"
        body={"error":"user not found"}
  POST   /api/v1/users/42     -> 405  Content-Type="text/plain; charset=utf-8"
        body=Method Not Allowed
  GET    /api/v1/nope         -> 404  Content-Type="text/plain; charset=utf-8"
        body=404 page not found

=== Gin (refactor แล้ว) ===
  GET    /api/v1/users/42     -> 200  Content-Type="application/json; charset=utf-8"
        body={"id":"42","name":"Somchai"}
  GET    /api/v1/users/0      -> 404  Content-Type="application/json; charset=utf-8"
        body={"error":"user not found"}
  POST   /api/v1/users/42     -> 404  Content-Type="text/plain"
        body=404 page not found
  GET    /api/v1/nope         -> 404  Content-Type="text/plain"
        body=404 page not found

⚠️ สองเคสแรกเหมือนกันทุกไบต์ เคสที่สามไม่เหมือน POST ไปที่ path ที่ลงไว้แค่ GET: net/http ตอบ 405 · Gin ตอบ 404
คุณเพิ่งรีแฟกเตอร์โดย "ไม่เปลี่ยน behavior" แต่ behavior เปลี่ยนไปแล้ว และไม่มี compile error ไม่มี warning ไม่มี log สักบรรทัด ถ้า client ฝั่งหน้าเว็บเขียน if (res.status === 405) ไว้ — มันเพิ่งพังโดยไม่มีใครรู้

8 · ราคาที่ Gin เก็บ — สามอย่างที่ต้องรู้ก่อนใช้ของจริง

ราคาที่ 1 — 405 กลายเป็น 404 (และเปิดคืนได้)

net/http ให้ 405 Method Not Allowed พร้อม header Allow ฟรีตั้งแต่บรรทัดแรก · Gin ปิดความสามารถนี้ไว้เป็น default
ผมรันเทียบชัด ๆ — HandleMethodNotAllowed ค่า default คือ false
(โปรแกรมทดสอบนี้ลง route ไว้สองเส้น: /only-post ลงไว้เฉพาะ POST กับ PUT ส่วน /users/:id ลงไว้เฉพาะ GET — จำไว้ตอนอ่านค่า Allow ข้างล่าง เพราะมันคือรายชื่อ method ที่ลงไว้กับ path นั้นเป๊ะ ๆ):

=== default (HandleMethodNotAllowed = false) ===
GET    /only-post   -> status=404  Content-Type="text/plain"  body=404 page not found
DELETE /users/42    -> status=404  Content-Type="text/plain"  body=404 page not found

=== r.HandleMethodNotAllowed = true ===
GET    /only-post   -> status=405  Allow="POST, PUT"  body=405 method not allowed
GET    /nope        -> status=404  body=404 page not found

แก้ด้วยบรรทัดเดียว: r.HandleMethodNotAllowed = true แล้ว Gin จะคำนวณ header Allow ให้เองด้วย
ทำไมมันถึงไม่เปิดให้เป็น default? เพราะการจะตอบ 405 ได้ router ต้องไปไล่ดูทุก method ที่ลงไว้กับ path นั้นก่อน = งานเพิ่มต่อ request ที่ miss — Gin เลือก "เร็วที่สุด" มากกว่า "ถูกต้องตาม HTTP ที่สุด" · เป็นการแลกที่ตั้งใจ ไม่ใช่บั๊ก

🏦 เทียบให้เห็นภาพ — ป้ายหน้าเคาน์เตอร์ธนาคาร

คุณเดินเข้าธนาคาร ตรงไปที่เคาน์เตอร์ 3 เพื่อขอถอนเงิน
· คำตอบแบบ 405 = “เคาน์เตอร์นี้มีอยู่ครับ แต่รับเฉพาะฝาก ถ้าจะถอนเชิญช่องถัดไป” — รู้ทันทีว่าต้องทำยังไงต่อ ส่วนรายการว่า “ช่องนี้รับอะไรได้บ้าง” ก็คือ header Allow นั่นเอง
· คำตอบแบบ 404 = “ไม่มีเคาน์เตอร์นี้ครับ” — ทั้งที่มันตั้งอยู่ตรงหน้าคุณ
คุณจะเดินวนหาเคาน์เตอร์ที่คิดว่าไม่มีอยู่จริง แทนที่จะขยับไปอีกช่องเดียวแล้วจบ

ที่ไม่เหมือนกัน: พนักงานธนาคารตอบผิดเพราะไม่รู้ แต่ Gin ตอบ 404 เพราะตั้งใจแลกกับความเร็ว — และเปิดคืนได้ด้วยบรรทัดเดียว r.HandleMethodNotAllowed = true ซึ่งเป็นทางเลือกที่พนักงานธนาคารไม่มี

🗣 ศัพท์ที่เพิ่งเรียนไป
404 vs 405
404 Not Found = “ไม่มีของชิ้นนี้” · 405 Method Not Allowed = “ของมีอยู่ แต่ทำแบบนั้นกับมันไม่ได้” · เวลารายงานบั๊กให้พูดว่า “ควรได้ 405 แต่ได้ 404” จะสื่อกว่าพูดว่า “API มันหาไม่เจอ”
header Allow
บรรทัดใน response ที่ลิสต์ method ที่ path นั้นรับได้ · สเปกกำหนดว่า 405 ต้อง มีตัวนี้ · ตรวจด้วย curl -i แล้วดูบรรทัด Allow:
default (ค่าตั้งต้น)
ค่าที่ library เลือกให้เมื่อเราไม่ได้สั่งอะไร · ประโยคที่ควรติดปาก: “ค่า default ของ field นี้คืออะไร และมันแลกอะไรไป” — คำถามนี้ทำให้เจอกับดักแบบนี้ก่อนขึ้น production
trade-off
“การแลก” — ได้อย่างเสียอย่างที่ผู้ออกแบบตั้งใจ ไม่ใช่บั๊ก · ที่ Gin เลือกเร็วแลกกับไม่ตอบ 405 คือ trade-off · พูดคำนี้กับ senior แล้วบทสนทนาจะเปลี่ยนจาก “ของมันพัง” เป็น “เราอยากได้ฝั่งไหน”
spec-compliant
“ตรงตามสเปก” — ทำตามมาตรฐาน HTTP เป๊ะ · ใช้ถามได้ว่า “พฤติกรรมนี้ spec-compliant ไหม หรือเป็นของเฉพาะ framework”

ราคาที่ 2 — 404 ของ Gin ไม่ใช่ JSON

สังเกต Content-Type ในผลรันข้างบน: 404 ของ Gin คือ "text/plain" เปล่า ๆ ไม่มี charset ต่อท้ายด้วยซ้ำ และ body คือข้อความ 404 page not found ไม่ใช่ JSON

แปลว่า API ของคุณที่ตั้งใจตอบ JSON ทุกเส้นทาง ตอบ JSON ไม่ครบ — เส้นทางที่พิมพ์ path ผิดจะได้ text กลับไป ฝั่ง client ที่เรียก res.json() จะพังเป็น parse error แทนที่จะเห็น error message ที่อ่านออก
แก้ด้วย r.NoRoute(...) ให้ตอบ JSON ของเราเอง:

r.NoRoute(func(c *gin.Context) {
    c.JSON(http.StatusNotFound, gin.H{"error": "route not found"})
})

ราคาที่ 3 — Gin panic ตอนลง route ในเคสที่ net/http รับได้สบาย

ศัพท์ที่ต้องรู้ก่อนอ่านข้อนี้ — *any
นอกจาก :id ที่กินแค่หนึ่งช่วง path แล้ว Gin ยังมี * ที่เรียกว่า catch-all (หรือ wildcard) — /swagger/*any แปลว่า "ทุกอย่างที่ขึ้นต้นด้วย /swagger/ ไม่ว่าจะเหลือกี่ช่วงก็ตาม" เช่น /swagger/index.html หรือ /swagger/a/b/c ก็เข้า route นี้หมด แล้วอ่านส่วนที่เหลือด้วย c.Param("any")
ของฝั่ง net/http ที่ทำหน้าที่คล้ายกันคือ pattern ที่ลงท้ายด้วย / เช่น "GET /files/" ซึ่งเรียกว่า subtree — แมตช์ทุก path ที่ขึ้นต้นด้วย /files/ เหมือนกัน
สองอันนี้จะโผล่ในโจทย์ข้างล่างและในบทหน้า (Swagger UI ต้องใช้ /swagger/*any)

ทายก่อนรัน — เจ็ดคู่นี้ ลงทะเบียน route แล้วคู่ไหน panic คู่ไหนผ่าน?

A: /users/:id      แล้วตามด้วย /users/me
B: /users/me       แล้วตามด้วย /users/:id
C: /users/:id      แล้วตามด้วย /users/:userId
D: /users/:id      แล้วตามด้วย /users/:id  (path เดิม method เดิม)
E: /swagger/*any   แล้วตามด้วย /swagger/foo
F: /users/:id (GET) แล้วตามด้วย /users/me (POST)
G: /a/*any/b       (wildcard ไม่อยู่ท้าย path)

ข้อ A กับ B คือข้อที่คนตอบผิดเยอะที่สุด — "static ชนกับ param" ทำให้ Gin panic ไหม และลำดับการลงมีผลหรือเปล่า (ใบ้: หัวข้อ 4 คุณเห็นผลรันของคู่นี้ไปแล้ว)

[A: /users/:id then /users/me] OK (no panic)
[B: /users/me then /users/:id] OK (no panic)
[C: /users/:id then /users/:userId] PANIC: ':userId' in new path '/users/:userId' conflicts with existing wildcard ':id' in existing prefix '/users/:id'
[D: /users/:id twice (same path, same method)] PANIC: handlers are already registered for path '/users/:id'
[E: /swagger/*any then /swagger/foo] PANIC: '/foo' in new path '/swagger/foo' conflicts with existing wildcard '/*any' in existing prefix '/swagger/*any'
[F: /users/:id (GET) and /users/me (POST) - different methods] OK (no panic)
[G: wildcard not at end: /a/*any/b] PANIC: catch-all routes are only allowed at the end of the path in path '/a/*any/b'

A และ B ไม่ panic ทั้งคู่ — และลำดับไม่มีผล ผมรันซ้ำบน gin v1.9.1 ก็ได้ผลเหมือนกันทุกบรรทัด
ที่คน (และบทความจำนวนมาก) เข้าใจผิดว่า "static ชนกับ param แล้ว Gin panic" — ไม่จริงอย่างน้อยตั้งแต่ v1.9.1 ถึง v1.12.0 สิ่งที่ panic จริงคือ param สองตัวคนละชื่อในตำแหน่งเดียวกัน (ข้อ C) ซึ่งเป็นบั๊กที่เกิดจริงเวลาแยกไฟล์ route: ไฟล์หนึ่งเขียน :id อีกไฟล์เขียน :userId

ข้อสำคัญที่สุดของทั้งก้อน: Gin panic ตอน register ไม่ใช่ตอน request แปลว่าถ้าลง route ชนกันโปรแกรมจะตายตั้งแต่ตอนบูต ไม่ได้ตายกลางดึกตอนมีลูกค้ายิงเข้ามา — ในแง่นี้ panic คือของขวัญ ไม่ใช่ปัญหา

แล้วคู่ไหนที่ Gin panic แต่ net/http ไม่ panic? ผมเอา pattern คู่เดียวกันไปลงทั้งสองฝั่ง — subtree/wildcard คู่กับ path เจาะจง:

=== A) net/http: mux.HandleFunc("GET /files/") + mux.HandleFunc("GET /files/foo") ===
  GET     /files/foo             -> 200  body="LITERAL foo"
  GET     /files/bar             -> 200  body="SUBTREE"
[net/http /files/ + /files/foo] OK (no panic)

=== B) gin: r.GET("/files/*any") + r.GET("/files/foo") ===
[gin /files/*any + /files/foo] PANIC: '/foo' in new path '/files/foo' conflicts with existing wildcard '/*any' in existing prefix '/files/*any'

net/http รับได้สบาย แล้วใช้กติกา "เจาะจงกว่าชนะ" ตัดสินตอน request — /files/foo ไปที่ literal, /files/bar ตกลง subtree
Gin ไม่ยอมให้มีสองอันนี้อยู่ด้วยกันตั้งแต่แรก เพราะ radix tree ของมัน ตัดสินใจว่า node นี้เป็น catch-all ไปแล้ว จะแตกลูกออกไปอีกไม่ได้
เคสนี้ไม่ใช่เคสสมมติ — มันคือเคสที่คุณจะเจอในบทเรียนถัดไปตอนเสียบ Swagger UI ที่ต้องลง /swagger/*any แล้วดันมี route /swagger/something อยู่ก่อน

🎯 สรุปการแลกทั้งหมดในหน้าเดียว
เรื่องnet/http (Go 1.26.5)Gin v1.12.0
ตอบ JSON3 บรรทัด เรียงผิดลำดับได้เงียบ ๆc.JSON 1 บรรทัด ✅
path param{id} + PathValue:id + Param (เท่ากัน)
prefix ซ้ำ ๆพิมพ์เองทุก router.Group ✅
middlewareต้องห่อ handler เองr.Use + Logger/Recovery แถม ✅
method ผิด405 + Allow ฟรี ✅404 จนกว่าจะเปิดเอง ❌
404 bodytext/plain; charset=utf-8text/plain — ต้องเขียน NoRoute เอง ❌
route ทับกันเจาะจงกว่าชนะ / ambiguous แล้วค่อย panicpanic ตอน register บ่อยกว่า
dependency0 ตัว (อยู่ใน Go เอง) ✅Gin + อีก 18 module ที่ติดตามมา ❌

คำตอบที่ซื่อสัตย์คือ: Gin ชนะเรื่องที่คุณทำวันละสิบครั้ง (ตอบ JSON, group, middleware) และแพ้เรื่องที่คุณเจอเดือนละครั้งแต่เจ็บ (405, 404 body) — ซึ่งเป็นการแลกที่คุ้มสำหรับทีมส่วนใหญ่ ถ้ารู้ตัวว่าแลกอะไรไป บทนี้มีอยู่เพื่อให้คุณรู้ตัว
(เลข 18 มาจากการนับจริงบนเครื่องผมด้วย go list -deps -f '{{if .Module}}{{.Module.Path}}{{end}}' github.com/gin-gonic/gin | sort -u ซึ่งได้ 19 บรรทัดรวมตัว Gin เอง — ลองนับบนเครื่องคุณดูได้ ตัวเลขอาจต่างเล็กน้อยตาม OS/CPU)

9 · ก่อนขึ้น production — gin.SetMode(gin.ReleaseMode)

โค้ดเดียวกันเป๊ะ ต่างแค่เรียก SetMode หรือไม่
(./p5 เป็นโฟลเดอร์โปรแกรมทดสอบของผมอีกตัว ผมเขียนให้มันรับ argument ชื่อ release แล้วค่อยเรียก gin.SetMode(gin.ReleaseMode) — ไม่ใช่ความสามารถของ Gin แต่เป็นโค้ดที่ผมเขียนเองเพื่อรันเทียบสองโหมดจากไฟล์เดียว)

❌ debug (ค่า default)

$ go run ./p5
>>> gin.Mode() = debug
[GIN-debug] [WARNING] Creating an Engine instance …
[GIN-debug] [WARNING] Running in "debug" mode. …
 - using env:	export GIN_MODE=release
 - using code:	gin.SetMode(gin.ReleaseMode)

[GIN-debug] GET    /ping   --> main.main.func1 (3 handlers)
[GIN] 2026/08/20 - 21:50:30 | 200 |     41µs | 127.0.0.1 | GET "/ping"

✅ release

$ go run ./p5 release
>>> gin.Mode() = release
[GIN] 2026/08/20 - 21:50:31 | 200 |  36.75µs | 127.0.0.1 | GET "/ping"

บรรทัด [GIN-debug] หายหมด — ทั้ง warning ทั้งตาราง route

จุดที่ต้องอ่านให้ออกคือ access log [GIN] ยังอยู่ (access log = บรรทัดที่บันทึกทุก request ที่เข้ามา — ใครยิงอะไร ได้ status อะไร ใช้เวลาเท่าไหร่)
เพราะมันมาจาก Logger middleware ไม่ได้มาจากโหมด debug — คนละเรื่องกัน
ตั้งผ่าน environment ก็ได้ผลเดียวกัน: export GIN_MODE=release ซึ่งเป็นวิธีที่ควรใช้ตอน deploy จริง เพราะไม่ต้องแก้โค้ดระหว่าง dev กับ prod

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

1. เปลี่ยนจาก gin.Default() เป็น gin.New() แล้ว handler เกิด panic ขึ้นมา — เกิดอะไรขึ้น?

ผมรันเทียบสอง engine พร้อมกัน: Default() ที่ :18081 ได้ status=500 · New() ที่ :18082 ได้ FAILED: curl status=000
000 ของ curl แปลว่า ไม่มี response เลย เพราะ panic ทะลุ Gin ไปถึง net/http ซึ่งปิด connection ทิ้ง
ตัวเลือกที่ 1 ผิดตรง "process ตาย" — ผมยิง /ping ของ engine ตัวเดียวกันต่อทันที ได้ status=200 เพราะ net/http มี recover() ของตัวเองอยู่ชั้นนอกสุด · ตัวเลือกที่ 2 ผิดเพราะ Recovery ไม่ได้ติดมากับ New() — ผมนับได้ len(New().Handlers) = 0 เทียบกับ len(Default().Handlers) = 2 · ตัวเลือกที่ 4 ผิดเพราะ Gin ไม่รู้เลยว่า handler จะ panic ตอน register — panic เกิดตอน request

2. ลง route ไว้แค่ r.GET("/users/:id", …) แล้วมี client ยิง DELETE /users/42 เข้ามา — Gin ที่ไม่ได้ตั้งค่าอะไรเพิ่มตอบว่าอะไร?

ผมยิงจริง: DELETE /users/42 -> status=404 Content-Type="text/plain" body=404 page not found
Gin ตั้ง HandleMethodNotAllowed = false ไว้เป็น default เพราะการตอบ 405 ต้องไล่ดูทุก method ที่ลงกับ path นั้นก่อน = งานเพิ่มต่อ request ที่ไม่แมตช์ · ตั้ง r.HandleMethodNotAllowed = true เองแล้วจึงได้ 405 body 405 method not allowed พร้อม header Allow ที่ลิสต์ method ที่ลงไว้กับ path นั้น (โจทย์ข้อนี้ลงแต่ GET ก็จะได้ Allow: GET) — ผลรันที่ผมยกมาในหัวข้อ 8 ขึ้น Allow="POST, PUT" เพราะเป็นคนละ route คือ /only-post ที่ผมลง POST กับ PUT ไว้
ตัวเลือกที่ 1 คือพฤติกรรมของ net/http (บท 37 ยิง POST ได้ 405 Allow: GET, HEAD) — นี่คือความสามารถที่หายไปตอนรีแฟกเตอร์ · ตัวเลือกที่ 3 ผิดเพราะ Gin ไม่เคยตอบ 501 เอง · ตัวเลือกที่ 4 ผิดเพราะ r.GET ลงเฉพาะ method GET เท่านั้น ไม่ใช่ทุก method

3. client ยิง /search?q= (ส่ง key q มาแต่ค่าว่าง) แล้ว handler เรียก c.DefaultQuery("q", "x") — ได้ค่าอะไรกลับมา?

ผลรันจริงสามเคสเรียงกัน: ?q=go → DefaultQuery:"go" · ไม่ส่ง q → DefaultQuery:"x" · ?q= → DefaultQuery:""
กติกาคือ default ใช้เมื่อ "ไม่มี key" เท่านั้น ไม่ใช่ "ค่าว่าง" ตัวที่แยกสองเคสนี้ออกจริง ๆ มีตัวเดียวคือ GetQuery: ไม่ส่งมาได้ ok=false · ส่ง ?q= มาได้ ok=true
ตัวเลือกที่ 1 คือคำตอบที่คนทายเยอะที่สุดและผิด · ตัวเลือกที่ 3 ผิดเพราะ DefaultQuery คืนค่าเดียว ไม่มี error และ ?q= ถูกต้องตามสเปก · ตัวเลือกที่ 4 ผิดเพราะไม่มีเมธอดไหนของ Gin คืนชื่อ key

4. เอา /files/ (subtree) คู่กับ /files/foo ใน net/http ไปเขียนเป็น /files/*any คู่กับ /files/foo ใน Gin — เกิดอะไรขึ้น?

ผมลง pattern คู่เดียวกันทั้งสองฝั่งแล้วรัน:
net/http → ไม่ panic · /files/foo ได้ "LITERAL foo" · /files/bar ได้ "SUBTREE" — กติกา "เจาะจงกว่าชนะ" ตัดสินตอน request
Gin → PANIC: '/foo' in new path '/files/foo' conflicts with existing wildcard '/*any' in existing prefix '/files/*any' เพราะ radix tree ตัดสินไปแล้วว่า node นี้เป็น catch-all จะแตกลูกออกไปอีกไม่ได้
ตัวเลือกที่ 2 ผิดครึ่งเดียว — net/http panic เฉพาะตอนสอง pattern ทับกันโดยไม่มีใครเจาะจงกว่า ซึ่งเคสนี้ไม่ใช่ · ตัวเลือกที่ 3 และ 4 ผิดเพราะโค้ด Gin ชุดนี้ไม่มีวันได้รับ request สักตัว มันตายตั้งแต่ตอนบูตแล้ว — และนั่นคือข้อดี: รู้ตอน deploy ดีกว่ารู้ตอนลูกค้ายิงเข้ามา

🛠️ ภารกิจ — รีแฟกเตอร์บท 37 เป็น Gin แล้วพิสูจน์ว่าคุณเสียอะไรไป

เป้าหมายไม่ใช่ "ทำให้ Gin ทำงาน" — เป้าหมายคือ ทำให้เห็นความต่างระหว่างสองตัวด้วยตาตัวเอง แล้วเขียนเทสล็อกมันไว้

  1. ตั้งโปรเจกต์: สร้างโฟลเดอร์ day8/gin/ แล้วสั่ง go mod init day8 · go get github.com/gin-gonic/gin@v1.12.0 · สร้างไฟล์ main.go แล้ววางโค้ดจากหัวข้อ 2 ลงไป
    รัน go run . หนึ่งครั้ง — อ่าน log ตอน boot ให้ครบทุกบรรทัด แล้วตอบให้ได้ว่า (3 handlers) คือใครบ้าง
    ถูกแล้วดูยังไง: เห็นบรรทัด [GIN-debug] GET /ping … (3 handlers) และ curl http://127.0.0.1:8080/ping ได้ {"message":"pong"}
  2. ย้ายโค้ดบท 37 มา: handler GET /api/v1/users/{id} ที่ตอบ 200 กับ 404 เป็น JSON → เขียนใหม่ด้วย r.Group + c.Param + c.JSON
    ห้ามลบไฟล์เดิมของบท 37 — แก้ให้ฝั่ง net/http ฟัง :8080 ส่วนฝั่ง Gin ฟัง :8081 แล้วเปิดสอง terminal รันคู่กัน จะได้ยิงเทียบในข้อถัดไปโดยไม่ต้องปิด ๆ เปิด ๆ
  3. ยิงเทียบสี่เคสเดียวกับหัวข้อ 7 (path เต็มเพราะอยู่ใน group /api/v1): GET /api/v1/users/42 · GET /api/v1/users/0 · POST /api/v1/users/42 · GET /api/v1/nope
    ใช้ curl -i ทั้งสองฝั่ง แล้วชี้ให้ได้ว่าเคสไหนคือเคสที่ต่าง และต่างที่ header อะไรบ้าง
    ถูกแล้วดูยังไง: สองเคสแรกตรงกันทุกบรรทัด · เคส POST ฝั่ง net/http ได้ 405 แต่ฝั่ง Gin ได้ 404
  4. ซ่อมสิ่งที่หายไป: เติม r.HandleMethodNotAllowed = true และ r.NoRoute(...) ที่ตอบ JSON
    ยิงซ้ำสี่เคสเดิม — ตอนนี้เหมือน net/http ครบทุกช่องหรือยัง (ดู header Allow ด้วย)
  5. ทำให้มัน panic ให้ได้: ลง /users/:id คู่กับ /users/:userId
    อ่าน panic message แล้วตอบว่า มัน panic ตอนไหน — ตอน go run หรือตอนยิง request
  6. ลอง query ให้ครบสามเคส: ทำ /search ที่พิมพ์ทั้ง Query / DefaultQuery / GetQuery แล้วยิง ?q=go · ไม่ส่ง q · ?q=
    เขียนอธิบายด้วยคำพูดตัวเองว่าทำไม GetQuery ถึงจำเป็น
  7. เขียนเทสพิสูจน์ (ต่อยอด บทเรียน 16 table-driven):
    *gin.Engine เป็น http.Handler จึงเสียบ httptest.NewServer(r) ได้ตรง ๆ ไม่ต้องเปิด port จริง
    ทำตาราง {method, path, wantStatus, wantContentType} ครอบสี่เคสในข้อ 3
    เทสข้อ POST /users/42 ต้อง fail ทันทีที่คุณลบบรรทัด HandleMethodNotAllowed = true ออก — ถ้าลบแล้วยังเขียว แปลว่าเทสยังไม่ได้เทสอะไร
  8. ท้าทาย: ตั้ง gin.SetMode(gin.ReleaseMode) แล้วรันข้อ 5 ซ้ำ — ทายก่อนรัน: route ที่ชนกันยัง panic อยู่ไหม หรือ release mode ทำให้มันเงียบไป?

ยัง panic เหมือนเดิม — ผมใช้ gin.SetMode(gin.ReleaseMode) ในโปรแกรมทดสอบ route ชนกันของตัวเองตลอด แล้วก็ยังได้ PANIC: '/foo' in new path '/files/foo' conflicts with existing wildcard … ออกมาปกติ
เหตุผล: ReleaseMode คุมแค่ว่าจะพิมพ์บรรทัด [GIN-debug] ไหม ส่วนการตรวจ route ชนกันเป็นงานของ radix tree ตอน insert ซึ่งทำงานเสมอทุกโหมด
สิ่งที่คุณเสียไปตอนขึ้น release คือตาราง route ที่เอาไว้ตรวจว่าพิมพ์ path ตกหรือเปล่า — เพราะงั้นตรวจตารางนั้นให้ครบตั้งแต่ตอน dev

📌 เกร็ดจากสไลด์ — สไลด์ Day 8 หน้า 41 วางโค้ด net/http กับ Gin เทียบกันแบบซ้าย-ขวา เหมือนหัวข้อ 7 ของบทนี้เป๊ะ
แต่ตัวอย่างในสไลด์เทียบเฉพาะ เส้นทางที่ทุกอย่างถูก (GET /hello → 200) ซึ่งสองฝั่งเหมือนกันจริง ๆ — ผลรันในหัวข้อ 7 ยืนยันว่าสองเคสแรกตรงกันทุกไบต์
ความต่างมันซ่อนอยู่ในเส้นทางที่ผิดเท่านั้น และนั่นคือเส้นทางที่สไลด์ไม่ได้พูดถึง

💬 ข้อ 4 กับข้อ 7 คือสองข้อที่ผมอยากเห็นผลที่สุด — เพราะเทสที่ยังเขียวหลังจากลบ HandleMethodNotAllowed ออก คือเทสที่กำลังหลอกคุณอยู่
ถ้ารันแล้วได้ผลไม่ตรงกับที่เขียนไว้ในบทนี้ ส่ง output มาได้เลย — ผมอยากรู้ว่าเวอร์ชัน Gin ของคุณต่างจาก v1.12.0 ตรงไหน
หรือถ้ายังงงว่า "แล้วสรุปควรใช้ Gin ไหม" — ถามมาพร้อมบอกด้วยว่าโปรเจกต์คุณมีกี่ route และมี middleware กี่ตัว คำตอบขึ้นกับสองเลขนี้มากกว่าความชอบส่วนตัว

อ้างอิง