🧰 เตรียมเครื่องก่อนอ่าน — บทนี้รันของจริงทั้งบท ถ้าจะพิมพ์ตามให้ทำสามข้อนี้
- สร้างโฟลเดอร์ใหม่ชื่อ
day8เข้าไปข้างในแล้วสั่งgo mod init day8 go get github.com/gin-gonic/gin@v1.12.0— สั่งในโฟลเดอร์เดียวกันนั้น (ต้องมีgo.modอยู่ก่อน ไม่งั้น go จะไม่รู้ว่าจะเขียน dependency ลงไฟล์ไหน) · ต่อเน็ตครั้งเดียว- ตรวจว่าตรงกับเครื่องผม:
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 เพิ่มเข้ามาจริง ๆ มีสองอย่างเท่านั้น:
- router ที่เร็วกว่าและเขียน param ได้สั้นกว่า — ข้างในเป็น radix tree
(ต้นไม้ที่เก็บ path โดยรวบส่วนที่ขึ้นต้นเหมือนกันไว้กิ่งเดียว เช่น
/users/meกับ/users/:idใช้กิ่ง/users/ร่วมกัน — หา route เจอโดยไม่ต้องไล่เทียบทีละเส้น) ที่ยกมาจาก httprouter (ไฟล์tree.goใน Gin ยังติด license header ของ httprouter อยู่เลย) *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")
}
ก่อนอ่านตาราง — สองคำนี้จะโผล่ทั้งบท ขอนิยามให้ชัดก่อน
- engine คือ
*gin.Engine— ตัวที่เก็บตาราง route ทั้งหมดไว้ เทียบตรง ๆ กับ*http.ServeMuxของบท 37 - middleware คือฟังก์ชันที่ถูกเรียกก่อน (และหลัง) handler ของเรา ทุก request
เช่นตัวที่คอยพิมพ์ log หรือคอยดัก panic — เขียนหน้าตาเหมือน handler เป๊ะ
(
func(c *gin.Context)) ต่างกันแค่ว่ามันไม่ได้เป็นคนตอบ แต่เป็นคนที่ยืนขวางทางอยู่
ห้าชิ้นในโค้ดนี้ ต้องแยกให้ออกว่าอันไหนคืออะไร:
| ชิ้น | คืออะไร | ของเทียบในบท 37 |
|---|---|---|
gin.Default() | สร้าง engine พร้อม middleware 2 ตัว | http.NewServeMux() (แต่ mux ไม่มี middleware ให้) |
r.GET(path, h) | ลง route เฉพาะ method GET | mux.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 สักบรรทัด
[GIN-debug] GET /ping --> main.main.func1คือตาราง route ที่ลงทะเบียนสำเร็จ ใช้ตรวจว่าพิมพ์ path ตกหรือเปล่าได้ดีมาก(3 handlers)ไม่ได้แปลว่ามี 3 route — แปลว่า request ที่เข้า path นี้จะวิ่งผ่านฟังก์ชัน 3 ตัว คือ Logger + Recovery + handler ของเรา (เดี๋ยวหัวข้อ 3 พิสูจน์ตัวเลขนี้)- บรรทัด
trusted all proxiesโผล่ตอนr.Run()เท่านั้น
reverse proxy คือเครื่องที่ยืนอยู่หน้าเซิร์ฟเวอร์เรา (เช่น nginx, load balancer) รับ request จาก browser แล้วส่งต่อมาให้เราอีกที — พอเป็นแบบนั้น IP ที่เราเห็นจะเป็นของ proxy ไม่ใช่ของผู้ใช้จริง proxy เลยแปะ IP จริงมาให้ใน header ชื่อX-Forwarded-For
ที่ Gin เตือนคือ: มันจะเชื่อ header ตัวนั้นจากใครก็ได้ แปลว่าใครยิงตรงเข้ามาก็ปลอม IP ตัวเองได้ — ยังไม่ต้องแก้ตอนเรียน แต่ต้องรู้ว่าบรรทัดนี้ไม่ใช่ 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
สามข้อสรุปจากห้าบรรทัดนี้:
- static ชนะ param —
/users/meเข้า handler ของmeไม่ใช่:idที่ได้id="me"· และไม่ใช่เพราะลงก่อนหลัง แต่เพราะ router มองว่า เจาะจงกว่าชนะ — เหมือนnet/httpเป๊ะ (แต่/users/mexที่แค่ขึ้นต้นเหมือนกัน ตกไปที่:idตามปกติ) c.Param("nope")คืนสตริงว่าง ไม่ panic —nope_nคือlen()ของมัน ได้0พฤติกรรมเดียวกับr.PathValueคือไม่มีตัวที่สองบอกokพิมพ์ชื่อ param ผิดจึงเงียบสนิท ไม่มี error ให้เห็น/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/http | Gin | สิ่งที่หายไป |
|---|---|---|
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 → Encode | c.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 |
|---|---|---|
| ตอบ JSON | 3 บรรทัด เรียงผิดลำดับได้เงียบ ๆ | c.JSON 1 บรรทัด ✅ |
| path param | {id} + PathValue | :id + Param (เท่ากัน) |
| prefix ซ้ำ ๆ | พิมพ์เองทุก route | r.Group ✅ |
| middleware | ต้องห่อ handler เอง | r.Use + Logger/Recovery แถม ✅ |
| method ผิด | 405 + Allow ฟรี ✅ | 404 จนกว่าจะเปิดเอง ❌ |
| 404 body | text/plain; charset=utf-8 | text/plain — ต้องเขียน NoRoute เอง ❌ |
| route ทับกัน | เจาะจงกว่าชนะ / ambiguous แล้วค่อย panic | panic ตอน register บ่อยกว่า |
| dependency | 0 ตัว (อยู่ใน 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 ดีกว่ารู้ตอนลูกค้ายิงเข้ามา
เป้าหมายไม่ใช่ "ทำให้ Gin ทำงาน" — เป้าหมายคือ ทำให้เห็นความต่างระหว่างสองตัวด้วยตาตัวเอง แล้วเขียนเทสล็อกมันไว้
- ตั้งโปรเจกต์: สร้างโฟลเดอร์
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"} - ย้ายโค้ดบท 37 มา: handler
GET /api/v1/users/{id}ที่ตอบ 200 กับ 404 เป็น JSON → เขียนใหม่ด้วยr.Group+c.Param+c.JSON
ห้ามลบไฟล์เดิมของบท 37 — แก้ให้ฝั่งnet/httpฟัง:8080ส่วนฝั่ง Gin ฟัง:8081แล้วเปิดสอง terminal รันคู่กัน จะได้ยิงเทียบในข้อถัดไปโดยไม่ต้องปิด ๆ เปิด ๆ - ยิงเทียบสี่เคสเดียวกับหัวข้อ 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 - ซ่อมสิ่งที่หายไป: เติม
r.HandleMethodNotAllowed = trueและr.NoRoute(...)ที่ตอบ JSON
ยิงซ้ำสี่เคสเดิม — ตอนนี้เหมือนnet/httpครบทุกช่องหรือยัง (ดู headerAllowด้วย) - ทำให้มัน panic ให้ได้: ลง
/users/:idคู่กับ/users/:userId
อ่าน panic message แล้วตอบว่า มัน panic ตอนไหน — ตอนgo runหรือตอนยิง request - ลอง query ให้ครบสามเคส: ทำ
/searchที่พิมพ์ทั้งQuery/DefaultQuery/GetQueryแล้วยิง?q=go· ไม่ส่งq·?q=
เขียนอธิบายด้วยคำพูดตัวเองว่าทำไมGetQueryถึงจำเป็น - เขียนเทสพิสูจน์ (ต่อยอด บทเรียน 16 table-driven):
*gin.Engineเป็นhttp.Handlerจึงเสียบhttptest.NewServer(r)ได้ตรง ๆ ไม่ต้องเปิด port จริง
ทำตาราง{method, path, wantStatus, wantContentType}ครอบสี่เคสในข้อ 3
เทสข้อPOST /users/42ต้อง fail ทันทีที่คุณลบบรรทัดHandleMethodNotAllowed = trueออก — ถ้าลบแล้วยังเขียว แปลว่าเทสยังไม่ได้เทสอะไร - ท้าทาย: ตั้ง
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 กี่ตัว คำตอบขึ้นกับสองเลขนี้มากกว่าความชอบส่วนตัว
อ้างอิง
- pkg.go.dev — gin — API reference ตัวจริง: ดู
Context.Param,Context.Query,Context.GetQuery,Context.JSONและ fieldEngine.HandleMethodNotAllowed - Gin — Quickstart · ตัวอย่างทางการ —
gin.Default(), route group,gin.H - GitHub — gin-gonic/gin — README บอกเองว่า router มาจาก httprouter เวอร์ชันดัดแปลง และไฟล์
tree.goยังติด license header ของ httprouter - julienschmidt/httprouter — อ่านหัวข้อ "Named parameters" และ "Catch-All parameters" เพื่อเข้าใจว่าทำไม
*anyต้องอยู่ท้าย path เสมอ - pkg.go.dev —
net/http.ServeMux— อ่านหัวข้อ "Patterns" และ "Precedence" เทียบกับกติกาของ Gin ในบทนี้ - MDN — 405 Method Not Allowed — สเปกบอกว่า response 405 ต้อง มี header
Allowซึ่งเป็นเหตุผลว่าทำไม 404 ของ Gin ถึงไม่ตรงสเปก - บทเรียน 37 — โค้ดต้นทางที่เอามารีแฟกเตอร์ในหัวข้อ 7 · บทเรียน 13 — struct tag ที่
c.JSONใช้อยู่ข้างใน