🧰 เตรียมเครื่องก่อนอ่าน — บทนี้เบามาก ไม่ต้องมี Docker ไม่ต้องมีเน็ต
- Go 1.22 ขึ้นไป — เช็คด้วย
go version(เครื่องที่ผมรันทุกผลลัพธ์ในบทนี้คือgo1.26.5 darwin/arm64)
ทำไมต้อง 1.22: ทั้งบทนี้พึ่ง routing แบบใหม่ ("GET /hello"และ{id}) ที่เพิ่งมีใน Go 1.22 — Go 1.21 ลงไปจะแปลสตริงพวกนี้ผิดความหมายทั้งหมด - โฟลเดอร์เปล่า ๆ อันหนึ่ง แล้ว
go mod init w37
ไม่มีgo getในบทนี้เลยแม้แต่บรรทัดเดียว —net/httpมาพร้อม Go ตั้งแต่ติดตั้ง curl— macOS มีมาให้อยู่แล้ว
ในบทนี้ผมใช้curl -s -iตลอด (-i= แสดง response header ด้วย ·-s= ไม่ต้องโชว์แถบ progress ให้รก) เพราะสิ่งที่บทนี้จะสอนคือเรื่องที่เกิดใน header ถ้าดูแต่ body จะไม่เห็นอะไรผิดเลย
ทุก output ในหน้านี้ผมรันจริงแล้ว copy มาวาง — ถ้าคุณรันได้ไม่ตรง ส่งมาให้ดูได้
1 · เซิร์ฟเวอร์ที่เล็กที่สุดที่ทำงานได้จริง
ก่อนอื่นต้องตกลงกันว่า "HTTP server" คืออะไรในเชิงโค้ด — มันไม่ใช่ของวิเศษ
มันคือโปรแกรมที่ (1) จอง TCP port ไว้หนึ่งพอร์ต
(พอร์ต = เลขช่องทางเข้าของเครื่อง เครื่องหนึ่งมีหลายพันช่อง แต่หนึ่งช่องมีโปรแกรมจองได้ทีละตัวเท่านั้น —
ถ้าจองซ้ำจะได้ error address already in use · ส่วนโปรแกรมเดียวเปิดฟังหลายพอร์ตพร้อมกันได้ปกติ) ·
(2) นั่งรอจนมีใครต่อเข้ามา ·
(3) อ่านตัวอักษรที่เข้ามาว่าเป็นคำขออะไร ·
(4) เขียนตัวอักษรกลับไปตามรูปแบบที่ browser อ่านออก
ทั้งสี่ข้อ net/http ทำให้หมดแล้ว งานที่เหลือของเราคือข้อเดียว:
บอกมันว่า path ไหนให้เรียกฟังก์ชันไหน
ฟังก์ชันที่ถูกเรียกตอน request เข้ามาตรง path นั้น เรียกว่า handler —
จำคำนี้ไว้ เพราะตั้งแต่บรรทัดนี้ไปจนจบ Day 8 เราจะพูดถึงมันตลอด
โค้ดนี้คือฝั่งซ้ายของสไลด์ Day 8 หน้า 41 — เอาไปวางใน main.go แล้ว go run . ได้เลย
package main
import (
"fmt"
"net/http"
)
func main() {
mux := http.NewServeMux() // 1. สมุดจดเส้นทาง
mux.HandleFunc("GET /hello", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "hello from GET /hello") // 3. เขียนคำตอบลง w
}) // 2. จดว่า path นี้ใครรับ
http.ListenAndServe(":8080", mux) // 4. เปิดพอร์ตแล้วนั่งรอ
}
ยิงดู (ผมรันที่พอร์ต 8765 ตอนตรวจ — คุณรัน :8080 ก็เปลี่ยนเลขตาม):
$ curl -s -i http://127.0.0.1:8765/hello
HTTP/1.1 200 OK
Date: Thu, 20 Aug 2026 14:48:52 GMT
Content-Length: 22
Content-Type: text/plain; charset=utf-8
hello from GET /hello
สี่ชิ้นนี้คือทั้งหมดที่ต้องเข้าใจ ผมขยายทีละชิ้น
| ชิ้นส่วน | มันคืออะไรจริง ๆ | ถ้าไม่มีมันจะเป็นยังไง |
|---|---|---|
http.NewServeMux() |
ตารางจับคู่ จาก "method + path" ไปเป็นฟังก์ชัน — ชื่อเต็มคือ multiplexer (ตัวแยกสาย) · ภาษาอื่นเรียกของแบบนี้ว่า router | ต้องเขียน if r.URL.Path == ... เองทุกเส้นทาง |
mux.HandleFunc(pattern, fn) |
จดหนึ่งบรรทัดลงตาราง — ทำตอน startup ครั้งเดียว ไม่ได้ทำตอนมี request | ตารางว่าง ทุก request ได้ 404 page not found |
fmt.Fprintln(w, ...) |
เขียนตัวอักษรลง w — สิ่งที่เขียนลง w คือสิ่งที่ client ได้ |
client ได้ 200 พร้อม body ว่าง (ไม่ error) |
http.ListenAndServe(addr, mux) |
จองพอร์ตแล้วบล็อกอยู่ตรงนั้น วนรับ request ไปเรื่อย ๆ | main จบทันที โปรแกรมตายก่อนใครจะยิงมาได้ |
คุณเดินเข้าโรงพยาบาล ไม่ได้เดินตรงไปห้องหมอเอง — คุณไปที่เคาน์เตอร์ประชาสัมพันธ์ก่อน
คุณบอกว่า "มาตรวจฟัน" เจ้าหน้าที่เปิดตารางที่ติดไว้ข้างเคาน์เตอร์
แล้วบอกว่า "ไปชั้น 3 ห้อง 302"
เจ้าหน้าที่ไม่ได้รักษาคุณเอง หน้าที่เดียวของเขาคือส่งคุณไปให้ถูกห้อง
โค้ดสี่บรรทัดข้างบนคือเรื่องเดียวกันเป๊ะ ๆ:
· ListenAndServe(":8080") = เปิดประตูโรงพยาบาล แล้วยืนรอคนเดินเข้ามา
· mux = เจ้าหน้าที่ที่เคาน์เตอร์ — ดูอย่างเดียวว่าใครควรไปห้องไหน
· HandleFunc("GET /hello", ...) = จดหนึ่งบรรทัดลงตารางข้างเคาน์เตอร์
ว่า "เรื่องนี้ ไปห้องนี้" — จดตอนก่อนเปิดโรงพยาบาล ไม่ใช่จดตอนคนไข้ยืนรออยู่
· ฟังก์ชันที่เราเขียนไว้ = ห้องตรวจ ที่ทำงานจริง ๆ คือ handler
· w = ใบผลตรวจที่ยื่นกลับให้คนไข้ — สิ่งที่เขียนลงใบนี้เท่านั้นที่คนไข้ได้กลับบ้าน
ที่ไม่เหมือนกัน: เจ้าหน้าที่คนเดียวส่งคนไข้ได้ทีละคน แต่ net/http
เปิด goroutine ใหม่ให้ทุก connection ที่ต่อเข้ามา — คนละคนที่ต่อเข้ามาพร้อมกัน
จึงเข้าห้องตรวจพร้อมกันได้จริง แปลว่า handler ของเราถูกเรียกพร้อมกันหลายตัวได้
ต้องระวังตัวแปรที่ใช้ร่วมกัน
(ส่วนหลาย request ที่วิ่งบน connection เดิม — HTTP/1.1 keep-alive — ใช้ goroutine ตัวเดิม เรียงกันทีละอัน)
และเจ้าหน้าที่จริงอาจถามต่อเมื่อฟังไม่เข้าใจ แต่ mux ตัดสินจากตารางอย่างเดียว:
ถ้า path ไม่มีในตารางเลย ตอบ 404 ·
ถ้า path มีแต่ method ไม่ตรง ตอบ 405 พร้อมบอก Allow
ให้เองว่า method ไหนใช้ได้ — ของใหม่ที่จะเห็นเต็ม ๆ ในหัวข้อ 3
✅ สังเกตว่า import มีแค่สองบรรทัด และไม่มี go get เลย —
Go เขียน HTTP server ได้จาก stdlib ล้วน
นี่คือเหตุผลที่ Day 8 เริ่มที่บทนี้ก่อน Gin: Gin ไม่ได้แทน net/http
มันสร้างทับ net/http ถ้าไม่รู้ชั้นล่าง เวลา Gin ทำอะไรแปลก ๆ
จะไล่ไม่ถูกว่าเป็นความผิดของ Gin หรือของ HTTP เอง
คำพวกนี้คือคำที่คนทำ backend ใช้กันทุกวัน · ใช้คำให้ตรง แล้วคำตอบที่ได้จะตรง ไม่ว่าจะถาม senior หรือพิมพ์ถาม AI
- port (พอร์ต)
- เลขช่องทางเข้าของเครื่อง · ถามว่า "service นี้ฟังพอร์ตอะไร" จะได้คำตอบเร็วกว่าถามว่า "เข้าเว็บนี้ยังไง"
- handler
- ฟังก์ชันที่รับ request แล้วเขียนคำตอบกลับ = "ห้องตรวจ" · เวลาไล่บั๊กให้พูดว่า "handler ของ endpoint นี้อยู่ไฟล์ไหน"
- router / multiplexer (mux)
- ตารางจับคู่ "method + path → handler" = "เจ้าหน้าที่ที่เคาน์เตอร์" ·
Go เรียก
ServeMuxส่วนภาษาอื่นเรียก router — ความหมายเดียวกัน · ประโยคที่ใช้ได้เลย: "โปรเจกต์นี้ใช้ router ของ stdlib หรือของ Gin" - route / register (ลงทะเบียน)
- การจดหนึ่งบรรทัดลงตาราง · "route นี้ยัง register ไม่ครบ"
= ยังไม่ได้เขียน
HandleFuncให้มัน - endpoint
- ที่อยู่ที่ยิงเข้ามาได้จริง · คำนี้คนใช้กันหลวม ๆ สองแบบ — บางทีหมายถึง path เฉย ๆ
บางทีหมายถึงคู่ method + path · จะให้ชัดก็พูด method มาด้วยเลย
เช่น
GET /users/{id}· (ในเอกสาร OpenAPI บทเรียน 41 เขาเรียกคู่ method + path ว่า operation) · คำนี้ใช้คุยกับฝั่ง frontend บ่อยที่สุด: "endpoint นี้ตอบอะไรกลับมาบ้าง" - blocking (บล็อก)
- ฟังก์ชันที่ค้างอยู่ตรงนั้น ไม่ยอมคืนค่าจนกว่าจะจบงาน ·
ListenAndServeเป็นแบบนี้ · "บรรทัดนี้มัน block นะ โค้ดข้างล่างจะไม่ได้รัน" เป็นประโยคที่จะได้ยินบ่อยมาก
2 · อ่าน signature ให้ขาด — ทำไม w ไม่มี * แต่ r มี
ก่อนอื่น signature แปลว่า "หน้าตาของฟังก์ชัน" —
พารามิเตอร์ที่รับ + ค่าที่คืน เท่านั้น ชื่อไม่นับ
(เพราะงั้นฟังก์ชันจะชื่ออะไรก็เอาไปใส่ HandleFunc ได้ ขอแค่รับ/คืนตรงกัน)
และบรรทัดนี้จะตามคุณไปทุกที่ในชีวิตการเขียน Go backend:
func(w http.ResponseWriter, r *http.Request)
คำถามที่มือใหม่ทุกคนถาม (และส่วนใหญ่ถูกตอบว่า "จำ ๆ ไปเถอะ") คือ
ทำไมตัวหนึ่งมีดาว ตัวหนึ่งไม่มี
คำตอบไม่ใช่เรื่องสไตล์ — มันคือของคนละชนิดกันเลย ผมพิสูจน์ให้ดู
w เป็น interface ไม่ใช่ struct
เปิดดูของจริงด้วย go doc (ตัดคอมเมนต์ยาว ๆ ออก):
$ go doc net/http.ResponseWriter
type ResponseWriter interface {
Header() Header
Write([]byte) (int, error)
WriteHeader(statusCode int)
}
มีแค่สาม method — นี่คือ interface ตามบทเรียน
10 เป๊ะ ๆ
และค่า interface ใน Go ถือ pointer ของของจริงไว้ข้างในอยู่แล้ว
การใส่ * ทับลงไปอีกจึงไม่ได้อะไรเพิ่ม (และทำให้เรียก method ไม่ได้ตรง ๆ ด้วย)
พิสูจน์ว่ามันเป็น interface จริง — ผมพิมพ์ ชนิดจริงของ w ในสองสถานการณ์
(./wr, ./lasrv, ./conflict ที่จะเห็นต่อจากนี้
คือโฟลเดอร์ที่ผมใช้ตรวจเอง ไม่ต้องมีในเครื่องคุณ — ดูผลรันอย่างเดียวพอ)
func h(w http.ResponseWriter, r *http.Request) {
fmt.Printf(" ชนิดจริงของ w = %T\n", w)
fmt.Fprintln(w, "hi")
}
$ go run ./wr
ยิงผ่าน httptest:
ชนิดจริงของ w = *httptest.ResponseRecorder
ยิงผ่าน server จริง:
ชนิดจริงของ w = *http.response
โค้ด handler ตัวเดียวกัน แต่ w เป็นคนละชนิดกันเลย
ตอนรัน server จริง Go ยัด *http.response (ตัวที่เขียนลง TCP socket จริง) เข้ามา ·
ตอนเทส httptest ยัด *httptest.ResponseRecorder (ตัวที่เก็บลง buffer ใน RAM) เข้ามา
นี่คือเหตุผลทั้งหมดที่ handler ของเราเทสได้โดยไม่ต้องเปิดพอร์ต —
และถ้า ResponseWriter เป็น struct ธรรมดา เรื่องนี้จะเป็นไปไม่ได้เลย
r เป็น struct — ก้อนใหญ่ที่ห้ามคัดลอก
ผมวัดขนาดจริงด้วย unsafe.Sizeof บนเครื่องเดียวกัน:
$ go run ./wr
unsafe.Sizeof(http.Request{}) = 304 bytes
unsafe.Sizeof(&http.Request{}) = 8 bytes (pointer)
unsafe.Sizeof(interface ค่า w) = 16 bytes
ถ้า signature เป็น r http.Request (ไม่มีดาว) ทุก ๆ request ที่เข้ามา
Go จะต้องคัดลอก 304 ไบต์นั้นทั้งก้อน ทุกครั้งที่ส่งต่อให้ handler
แย่กว่าเรื่องความเร็วคือ ตัวสำเนาจะแก้อะไรไม่ถึงต้นฉบับ
เรื่องนี้จะสำคัญมากตอนเจอ middleware — คำนี้แปลว่า
ฟังก์ชันที่ห่อ handler อีกชั้น เพื่อทำงานบางอย่างก่อน/หลัง handler ตัวจริง
(เช่น log, เช็ค token) เราจะเขียนของจริงกันในบทเรียน
39
วิธีที่ middleware ส่งข้อมูลลงไปให้ handler ชั้นในคือเปลี่ยนตัว r
ที่ชั้นในจะได้รับ เช่นการแนบ context ลง request (บทเรียน
25) — ถ้าแต่ละชั้นได้แค่สำเนา
ชั้นในก็จะได้ของเก่าเสมอ ทั้ง pattern ก็พังทันที
นี่คือกฎเดียวกับบทเรียน 7
ที่เคยเจอมาแล้ว แค่คราวนี้เจอในของจริง
w http.ResponseWriter | r *http.Request | |
|---|---|---|
| ชนิด | interface (3 method) | pointer ไปยัง struct |
| ทิศทาง | ขาออก — เราเขียนใส่ | ขาเข้า — เราอ่านจากมัน |
ทำไมไม่มี/มี * | interface พก pointer อยู่แล้ว | struct 304 ไบต์ ห้ามคัดลอก |
| ตอนเทสถูกแทนด้วย | *httptest.ResponseRecorder | httptest.NewRequest(...) |
w = เต้ารับไฟบ้าน
พัดลมที่บ้านคุณไม่รู้เลยว่าไฟที่เสียบอยู่มาจากการไฟฟ้า หรือมาจากเครื่องปั่นไฟตอนไฟดับ
มันขอแค่รูปทรงของเต้ารับตรงกัน เท่านั้น เสียบแล้วก็หมุนได้เหมือนกัน
· ResponseWriter = รูปทรงเต้ารับ — บอกแค่ว่า "ต้องทำได้ 3 อย่าง"
(Header, Write, WriteHeader)
· ตอนรัน server จริง Go เสียบ ไฟบ้านจริง (*http.response ที่เขียนลงสายจริง) เข้ามา
· ตอนเทส Go เสียบ เครื่องปั่นไฟ (*httptest.ResponseRecorder ที่แค่จดใส่กระดาษ) เข้ามา
· handler ของเราคือพัดลม — ไม่ต้องแก้อะไรเลยสักบรรทัด
r = ตำแหน่งของแฟ้มเอกสาร ไม่ใช่สำเนาของแฟ้ม
แฟ้มคนไข้หนา 300 หน้า เวลาส่งต่อให้แผนกอื่น เขาบอกกันว่า
"แฟ้มวางอยู่ชั้นสอง โต๊ะ 4" — ไม่มีใครถ่ายเอกสารใหม่ทั้งแฟ้มทุกครั้งที่ส่งต่อ
เพราะ (1) เปลืองเวลาเปล่า ๆ และ (2) ถ้าแผนกหลังเขียนเพิ่มลงสำเนา
แผนกอื่นก็จะไม่มีวันเห็นสิ่งที่เขาเขียน
ดาว * ก็คือการบอก "ตำแหน่งของแฟ้ม" แทนการถ่ายสำเนา 304 ไบต์ทุก request
ที่ไม่เหมือนกัน: เต้ารับที่บ้านคุณเลือกเสียบเองได้
แต่ w เราไม่ได้เป็นคนเลือก — Go เป็นคนยัดของจริงเข้ามาให้ตอนเรียก handler
และ "มีที่อยู่ของแฟ้ม" ก็ไม่ได้แปลว่ามีสิทธิ์เขียนลงแฟ้ม
ซึ่งเป็นเรื่องที่กล่องข้างล่างนี้จะเตือน
⚠️ pointer ไม่ได้แปลว่าแก้ได้ตามใจ
เอกสารทางการของ net/http.Handler เขียนไว้ตรง ๆ ว่า
"Except for reading the body, handlers should not modify the provided Request."
แปลว่า r เป็น pointer เพื่อประสิทธิภาพและเพื่อให้ middleware
เปลี่ยนตัวชี้ได้ ไม่ใช่เพื่อให้เราไปแก้ field ในนั้นเล่น ๆ
วิธีที่ถูกคือสร้าง request ตัวใหม่ด้วย r.WithContext(ctx)
แล้วส่งตัวใหม่ต่อ — ไม่ใช่ไปยัดค่าลง r ตัวเดิม
แล้ว HandleFunc เกี่ยวอะไร
จริง ๆ แล้ว net/http รู้จักแค่ interface เดียวชื่อ Handler:
$ go doc net/http.Handler
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
$ go doc net/http.HandlerFunc
type HandlerFunc func(ResponseWriter, *Request)
The HandlerFunc type is an adapter to allow the use of ordinary
functions as HTTP handlers.
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request)
ServeHTTP calls f(w, r).
อ่านสามบรรทัดนี้ช้า ๆ แล้วจะเห็นทริกที่สวยมากของ Go:
HandlerFunc คือ ชนิดที่มี underlying type เป็นฟังก์ชัน
(บทเรียน 9) และมันติด method
ServeHTTP ที่ทำแค่ "เรียกตัวเอง"
เพราะแบบนี้ ฟังก์ชันธรรมดาถึงกลายเป็น Handler ได้ โดยไม่ต้องประกาศ struct
— HandleFunc ก็แค่ห่อฟังก์ชันที่เราส่งไปด้วย HandlerFunc(...) แล้วจดลงตาราง
- interface
- "รูปทรงเต้ารับ" — รายการ method ที่ต้องมี โดยไม่สนว่าของจริงคือใคร · คำถามที่ทำให้ senior ตอบได้ทันทีคือ "ตัวนี้เป็น interface หรือ struct" เพราะคำตอบเปลี่ยนวิธีเขียนเทสไปทั้งหมด
- concrete type / implementation
- "ของจริงที่มาเสียบ" ในเต้ารับนั้น · ประโยคที่ใช้ได้เลย:
"ตอน runtime ตัวจริงที่อยู่หลัง
wคือ*http.response" · วิธีดูด้วยตาคือพิมพ์%Tอย่างที่ทำไปข้างบน - pointer
- "ที่อยู่ของของ" ไม่ใช่ตัวของ · เครื่องหมาย
*หน้าชนิด · เวลาถามให้พูดว่า "ตรงนี้รับเป็น value หรือ pointer" จะสื่อกว่าถามว่า "ต้องใส่ดาวไหม" - signature
- หน้าตาของฟังก์ชัน = สิ่งที่รับ + สิ่งที่คืน · ชื่อไม่นับ
เพราะงั้นฟังก์ชันชื่ออะไรก็เอาไปใส่
HandleFuncได้ ขอแค่รับ/คืนตรงกัน · "ฟังก์ชันนี้ signature ไม่ตรงกับhttp.HandlerFunc" คือประโยคที่อธิบาย error ตอนเอาฟังก์ชันไปใส่HandleFuncไม่ได้ - middleware
- ฟังก์ชันที่ห่อ handler อีกชั้น เพื่อทำงานบางอย่างก่อน/หลัง handler ตัวจริง · "ตัว log นี้ทำเป็น middleware ดีกว่าไหม" — ของจริงอยู่บทเรียน 39
- adapter
- ตัวแปลงหัวเสียบ — ของที่ทำให้ของชนิดหนึ่งเข้าที่ของอีกชนิดได้ ·
HandlerFuncคือ adapter ที่ทำให้ ฟังก์ชันธรรมดา ใช้แทนHandlerได้ · เอกสารทางการเรียกมันแบบนี้ตรง ๆ
3 · pattern "GET /hello" — ของใหม่ที่มาพร้อม Go 1.22
ก่อน Go 1.22 ServeMux ดูแต่ path — จะกรอง method ต้องเขียน
if r.Method != "GET" { ... } เองในทุก handler
ตั้งแต่ 1.22 ใส่ method ไว้หน้า path ในสตริงเดียวกันได้เลย และ mux จะจัดการ method ที่ไม่ตรงให้เอง
ทายก่อนรัน — server ลง pattern ไว้อันเดียวคือ "GET /hello"
แล้วผมยิงสี่แบบนี้ใส่มัน
$ curl -s -i http://127.0.0.1:8765/hello # GET ปกติ
$ curl -s -i -X POST http://127.0.0.1:8765/hello # POST
$ curl -s -i -X OPTIONS http://127.0.0.1:8765/hello
$ curl -s -I http://127.0.0.1:8765/hello # -I ตัวใหญ่ = ยิง HEAD
แต่ละอันได้ status อะไร? และอันที่ไม่ใช่ 200 จะมี header อะไรติดมาบอกเราบ้าง?
คำใบ้: มีอันหนึ่งที่ได้ 200 ทั้งที่เราไม่ได้ลงทะเบียนมันไว้
$ curl -s -i http://127.0.0.1:8765/hello
HTTP/1.1 200 OK
Content-Length: 22
Content-Type: text/plain; charset=utf-8
hello from GET /hello
$ curl -s -i -X POST http://127.0.0.1:8765/hello
HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
Content-Length: 19
Method Not Allowed
$ curl -s -i -X OPTIONS http://127.0.0.1:8765/hello
HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD
...
Method Not Allowed
$ curl -s -I http://127.0.0.1:8765/hello
HTTP/1.1 200 OK
Date: Thu, 20 Aug 2026 14:50:58 GMT
Content-Length: 22
Content-Type: text/plain; charset=utf-8
สามเรื่องที่ต้องเก็บไป:
1. Allow: GET, HEAD ทั้งที่เราลงแค่ GET —
Go แถม HEAD ให้ฟรีทุก pattern ที่ลง GET ไว้
(HEAD = "ขอแต่ header ไม่เอา body" ซึ่ง Go ทำให้ได้เองจาก handler ตัวเดียวกัน)
เพราะแบบนี้ curl -I จึงได้ 200 ไม่ใช่ 405
2. OPTIONS ไม่ได้ฟรี — ได้ 405 เหมือน method อื่น
จำบรรทัดนี้ไว้ให้ดี เพราะ CORS preflight ของ browser ยิงด้วย OPTIONS
preflight = ก่อนที่ browser จะยอมให้หน้าเว็บจากอีกโดเมนยิง API ของเรา
มันจะยิง OPTIONS ไปถามก่อนหนึ่งครั้งว่าอนุญาตไหม
ถ้าเราไม่เขียนรับ OPTIONS เอง คำถามนั้นได้ 405 → frontend เรียก API ไม่ได้เลย
(เรื่องเต็มอยู่ในบทเรียน 39)
3. body ของ 405 คือ Method Not Allowed เป็น text ธรรมดา ไม่ใช่ JSON
— frontend ที่ JSON.parse ทุก response จะพังตรงนี้
(ส่วน X-Content-Type-Options: nosniff ที่เห็นติดมาด้วย คือ Go บอก browser ว่า
"อย่าเดาชนิดไฟล์เอง ให้เชื่อ Content-Type" — เป็นของที่ Go ใส่ให้เอง ไม่ต้องทำอะไรกับมัน)
ลงหลาย method ก็ได้ Allow ครบและเรียงตามตัวอักษร —
ผมลง GET/POST/PUT แล้วยิง DELETE ได้
Allow="GET, HEAD, POST, PUT"
สตริง pattern พิมพ์ผิดนิดเดียว = พังคนละแบบ
"GET /hello" เป็นสตริงธรรมดา — compiler ไม่ช่วยตรวจให้เลย
ผมเลยลองพิมพ์ผิดทุกแบบที่คนพิมพ์ผิดกันจริง แล้วดูว่าเกิดอะไร
| เขียนว่า | ยิง GET /hello แล้วได้ | เกิดอะไรขึ้นจริง ๆ |
|---|---|---|
"GET /hello" |
200 HIT |
ถูกต้อง |
"GET /hello" (เว้นวรรคสองครั้ง) |
200 HIT |
Go ยอมให้ — ไม่ต้องกังวล |
"get /hello" (method ตัวเล็ก) |
405 Allow="get" |
register ผ่านเงียบ ๆ ไม่ panic แต่ HTTP method เป็น case-sensitive — browser ยิง GET ตัวใหญ่เสมอ จึงไม่มีวันตรงกัน |
"GET/hello" (ลืมเว้นวรรค) |
404 page not found |
Go ตีเป็น host = "GET", path = /hello → รอ request ที่มาจากโฮสต์ชื่อ GET ซึ่งไม่มีวันเกิด ไม่มี warning ใด ๆ |
"GET hello" (ลืม /) |
panic ทันทีตอน startup: parsing "GET hello": at offset 4: host/path missing / — อันนี้ดีที่สุดในตาราง เพราะรู้ตัวทันที |
|
⚠️ สังเกตความร้ายของสองแถวกลาง
"get /hello" และ "GET/hello" รันขึ้น ไม่มี error ไม่มี log
— คุณจะเห็นแค่ "ทำไมยิงแล้วไม่เข้า handler" แล้วนั่งไล่โค้ดใน handler ทั้งวัน
ทั้งที่ความผิดอยู่ในสตริงบรรทัดที่ลงทะเบียน
นิสัยที่ควรติด: ใช้ค่าคงที่ของ stdlib แทนการพิมพ์เอง —
http.MethodGet คือ "GET" และพิมพ์ผิดเมื่อไหร่ compiler ฟ้อง
4 · {id} ใน path และ r.PathValue
ของจริงเราไม่ได้มีแค่ /hello — เราต้องการ /users/1, /users/2, …
โดยเขียน handler ตัวเดียว
Go 1.22 ให้ใส่ ปีกกาลงใน pattern แล้วดึงค่าออกมาด้วย r.PathValue
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "id=%q missing=%q\n", r.PathValue("id"), r.PathValue("nope"))
})
$ curl -s -i http://127.0.0.1:8765/users/42
HTTP/1.1 200 OK
...
id="42" missing=""
$ curl -s -i http://127.0.0.1:8765/users/42/posts
HTTP/1.1 404 Not Found
X-Content-Type-Options: nosniff
...
404 page not found
สามข้อสังเกตจากผลรันข้างบน:
1. key ที่ไม่มีคืนสตริงว่าง ไม่ panic และไม่มีตัวที่สองแบบ value, ok
r.PathValue("nope") ได้ "" เฉย ๆ — แปลว่าถ้าคุณพิมพ์ชื่อ key ผิด
(ลง {id} แต่อ่าน PathValue("ID")) โค้ดจะทำงานต่อด้วยสตริงว่าง
แล้วไปพังลึก ๆ ในชั้น database แทน ชื่อใน pattern กับชื่อที่อ่านต้องตรงกันเป๊ะ
2. ทุกค่าที่ได้เป็น string เสมอ — /users/abc ก็เข้า handler ได้
ถ้าโค้ดต้องการตัวเลขก็ต้อง strconv.Atoi เองแล้วตอบ 400 เมื่อแปลงไม่ได้
(routing ไม่ใช่ validation)
3. {id} กินได้แค่ segment เดียว — /users/42/posts ไม่ match
จึงได้ 404 ถ้าอยากให้กินทั้งหางใช้ {path...} (มีจุดสามจุด):
// pattern: "GET /static/{path...}"
$ curl -s -i http://127.0.0.1:8767/static/css/app.css
HTTP/1.1 200 OK
...
path...="css/app.css" url="/static/css/app.css"
$ curl -s -i http://127.0.0.1:8767/static/
HTTP/1.1 200 OK
...
path...="" url="/static/"
ลง /users/{id} คู่กับ /users/me ชนกันไหม
ไม่ชน และตัวที่เจาะจงกว่าชนะ — ผมลง {id} ก่อน me ด้วยซ้ำ:
✅ เจาะจงกว่าชนะ ไม่สนลำดับ
$ curl -s -i .../users/me
HTTP/1.1 200 OK
this is /users/me (literal)
$ curl -s -i .../users/42
HTTP/1.1 200 OK
id="42" missing=""
กติกาคือ "เจาะจงกว่าชนะ" ไม่ใช่ "ลงก่อนชนะ" — ต่างจาก router หลายตัวในภาษาอื่นที่ไล่จากบนลงล่าง
❌ เจาะจงเท่ากัน → panic ตอน startup
$ go run ./conflict
registered GET /users/{id}
panic: pattern "GET /users/{name}"
conflicts with pattern "GET /users/{id}":
GET /users/{name} matches the same
requests as GET /users/{id}
พังตอน register ไม่ใช่ตอน request — แปลว่ารู้ตัวตั้งแต่ยังไม่ deploy ถือว่าโชคดี
กรณีที่ทับกันบางส่วนแต่ไม่มีใครเจาะจงกว่า ข้อความ panic ยังยกตัวอย่าง path ที่ชนให้ดูด้วย ซึ่งช่วยชีวิตมาก:
$ go run ./conflict2
registered GET /a/{x}/c
panic: pattern "GET /a/b/{y}" conflicts with pattern "GET /a/{x}/c":
GET /a/b/{y} and GET /a/{x}/c both match some paths, like "/a/b/c".
But neither is more specific than the other.
GET /a/b/{y} matches "/a/b/y", but GET /a/{x}/c doesn't.
GET /a/{x}/c matches "/a/x/c", but GET /a/b/{y} doesn't.
🔗 เกร็ดที่ทำคนงงบ่อย: /files กับ /files/ ไม่ใช่อันเดียวกัน
pattern ที่ลงท้ายด้วย / คือ subtree — match ทุกอย่างที่ขึ้นต้นด้วยมัน
(/files/a/b/c.txt ก็เข้า)
และถ้าลง /files/ ไว้แล้วมีคนยิง /files เฉย ๆ mux จะ redirect ให้เอง
ด้วย 307 Temporary Redirect พร้อม Location: /files/
ระวัง: บทความเก่าเกือบทั้งอินเทอร์เน็ตเขียนว่า 301 — นั่นคือพฤติกรรมของ mux รุ่นก่อน Go 1.22
ผมยืนยันด้วยการเปิดของเก่ากลับมาด้วย GODEBUG=httpmuxgo121=1 แล้วได้
301 Moved Permanently จริง ส่วน default ของ Go 1.26.5 คือ 307
ถ้าอยากได้ "/exact/ เป๊ะ ๆ ห้าม subtree" ใช้ "/exact/{$}" —
แล้ว /exact/sub จะได้ 404 ตามที่ควร
5 · ตอบ JSON — และลำดับสามบรรทัดที่ห้ามสลับ
API จริงตอบ JSON ไม่ใช่ text ธรรมดา และเครื่องมือคือ encoding/json จาก
บทเรียน 13 ตัวเดิม
แต่มีทางเลือกสองทาง และผมอยากให้เลือกทางที่สอง:
| วิธี | เกิดอะไร | ควรใช้เมื่อ |
|---|---|---|
b, err := json.Marshal(v)w.Write(b) |
สร้าง []byte ทั้งก้อนใน RAM ก่อน แล้วค่อยเขียน — marshal = แปลง struct ให้เป็น JSON (บทเรียน 13) |
อยากรู้ขนาด/อยากตรวจ error ก่อนเริ่มตอบ |
json.NewEncoder(w).Encode(v) |
ปั้น JSON แล้วเขียนลง w ให้เลยในขั้นตอนเดียว (ก็ปั้นทั้งก้อนใน RAM เหมือนกัน แค่เราไม่ต้องรับ []byte มาถือเอง) · ใส่ \n ท้ายให้เสมอ |
เกือบทุกกรณี — เขียนสั้นกว่า พลาดยากกว่า |
ทีนี้ประเด็นสำคัญ: Encode ไม่ได้บอก client ว่านี่คือ JSON
ถ้าไม่ set Content-Type เอง Go จะเดาจาก 512 ไบต์แรกของ body
(ฟังก์ชัน http.DetectContentType) และมันเดา JSON ไม่ออก:
DetectContentType(json bytes) = "text/plain; charset=utf-8"
encoded bytes = "{\"id\":\"7\",\"name\":\"Somchai\"}\n"
$ curl -s -i http://127.0.0.1:8765/json-nosniff
HTTP/1.1 200 OK
Content-Length: 28
Content-Type: text/plain; charset=utf-8
{"id":"7","name":"Somchai"}
body เป็น JSON ถูกต้องทุกตัวอักษร แต่ป้ายบอกว่าเป็น text/plain —
client บางตัวก็ยังอ่านได้ แต่บางตัว (เช่น fetch ที่เช็ค Content-Type
หรือ SDK ที่ generate มา) จะปฏิเสธ แล้วคุณจะหาสาเหตุไม่เจอเพราะ body ดูถูกทุกอย่าง
ส่งพัสดุทุกครั้งจะมีสองส่วนแยกกันชัดเจน: ของข้างในกล่อง กับ
ป้ายที่แปะหน้ากล่อง
ข้างในเป็นแก้วน้ำ แต่ป้ายเขียนว่า "เสื้อผ้า" — กล่องยังส่งถึงปลายทางได้ปกติ
ไม่มีใครทัก ไม่มีใครตีกลับ
แต่คนขนของจะโยนกล่องนั้นเหมือนโยนเสื้อผ้า เพราะเขาเชื่อป้าย ไม่ได้เปิดดูข้างใน
· ของข้างใน = body — ในตัวอย่างข้างบนคือ JSON ที่ถูกต้องทุกตัวอักษร
· ป้ายหน้ากล่อง = header Content-Type
· ถ้าเราไม่แปะป้ายเอง Go จะแอบเปิดดู 512 ไบต์แรกแล้วเดาให้
(http.DetectContentType) — และมันเดา JSON ไม่ออก จึงแปะว่า text/plain
ผลคือ frontend บางตัวเห็นป้ายว่า "ข้อความธรรมดา" ก็ไม่ยอมแปลงเป็น JSON ให้
ทั้งที่ข้างในคือ JSON เต็ม ๆ
ที่ไม่เหมือนกัน: พัสดุเปิดกล่องดูทีหลังได้ แต่ Content-Type
แก้ทีหลังไม่ได้เลย — พอ body ไหลออกไปแล้ว ป้ายก็ถูกส่งไปก่อนหน้านั้นแล้ว
ซึ่งคือเรื่องของหัวข้อถัดไป
ทางแก้คือ set เอง — แต่ ต้อง set ให้ถูกจังหวะ ผมรันเทียบให้ดูสองแบบ:
✅ set ก่อน WriteHeader
w.Header().Set("Content-Type",
"application/json; charset=utf-8")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(u)
$ curl -s -i .../json-before
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Content-Length: 28
{"id":"7","name":"Somchai"}
❌ set หลัง WriteHeader
w.WriteHeader(http.StatusCreated)
w.Header().Set("Content-Type",
"application/json; charset=utf-8") // สาย
json.NewEncoder(w).Encode(u)
$ curl -s -i .../json-after
HTTP/1.1 201 Created
Content-Length: 28
Content-Type: text/plain; charset=utf-8
{"id":"7","name":"Somchai"}
ไม่มี error ไม่มี log ไม่มี panic — บรรทัด Set นั้นแค่ไม่มีผล
✅ จำเป็นลำดับตายตัวสามขั้น — ห้ามสลับ
w.Header().Set(...) → w.WriteHeader(status) →
w.Write(...) / Encode(...)
วิธีที่ไม่ต้องจำ: เขียนฟังก์ชัน writeJSON ไว้ตัวเดียวแล้วเรียกมันทุกที่
ลำดับจะถูกอัตโนมัติทั้งโปรเจกต์ (ในภารกิจท้ายบทเราจะเขียนตัวนี้กัน)
- header กับ body
- ทุก response แบ่งเป็นสองส่วนเสมอ — header คือป้ายกำกับ (ใครส่ง ยาวเท่าไหร่ ชนิดอะไร)
body คือเนื้อหาจริง · เวลาแจ้งบั๊กให้แยกให้ชัด:
"body ถูก แต่ header
Content-Typeผิด" — ประโยคเดียวจบ ไม่ต้องอธิบายยาว - Content-Type / MIME type
- ป้ายบอกชนิดของ body เช่น
application/json,text/plain· ชื่อรูปแบบนี้เรียกรวม ๆ ว่า MIME type · คำถามที่ตรงจุดคือ "endpoint นี้ตอบ Content-Type อะไร" - content sniffing
- "การแอบเดาชนิดจากเนื้อไฟล์" เมื่อไม่มีป้ายบอก · Go ทำผ่าน
http.DetectContentType· และ headerX-Content-Type-Options: nosniffที่เห็นบ่อย ๆ แปลว่า "ห้าม browser เดาเอง ให้เชื่อป้ายอย่างเดียว" - marshal / encode
- การแปลง struct ของ Go ให้เป็น JSON ·
Marshal= ได้[]byteกลับมาถือเอง (เช็ค error / ดูขนาดได้ก่อนเริ่มตอบ) ·Encode= ปั้นแล้วเขียนลงio.Writerให้เลยในขั้นตอนเดียว ใส่\nท้ายให้ด้วย · ทั้งคู่ปั้นทั้งก้อนใน RAM เหมือนกันEncodeแค่สั้นกว่า — อย่าเรียกมันว่า streaming เพราะคำนั้นหมายถึงการทยอยส่งออกทีละชิ้น ซึ่งEncodeไม่ได้ทำ · ขาตรงข้ามคือ unmarshal / decode ตอนอ่าน JSON เข้ามา - status code
- เลขสามหลักที่บอกผลลัพธ์ ·
2xx= สำเร็จ ·4xx= คนเรียกทำผิด ·5xx= ฝั่งเราพัง · พูดว่า "endpoint นี้ควรตอบ 400 ไม่ใช่ 500" จะสื่อกว่าพูดว่า "มัน error"
6 · หัวใจของบท — ResponseWriter เขียนได้ครั้งเดียว
ทุกอย่างในหัวข้อ 5 มาจากกฎเดียวกัน และกฎนี้คือสิ่งที่ผมอยากให้จำจากบทนี้มากที่สุด
HTTP response ส่งออกเรียงลำดับ: status line → headers → body
พอ byte แรกของ status line ออกจากเครื่องไปแล้ว มันดึงกลับไม่ได้
— ไม่ใช่ข้อจำกัดของ Go แต่เป็นข้อจำกัดของโปรโตคอล
"เขียนได้ครั้งเดียว" หมายถึงอะไรกันแน่: body จะเขียนกี่ครั้งก็ได้
(Write ต่อกันไปเรื่อย ๆ ได้) — ที่ส่งได้ครั้งเดียวคือ
status + header ทั้งชุด ซึ่งออกไปพร้อมกันทีเดียวตอน WriteHeader
เวลาเดินจากบนลงล่าง — เส้นประคือ "จุดไม่ย้อนกลับ" w.Header().Set(...) แก้ได้ตามใจ ยังไม่มีอะไรออกจากเครื่อง │ ─ ─ ─ ┼ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ w.WriteHeader(201) ← status + header ทั้งชุด ถูกส่งออกไปแล้ว ▼ w.Header().Set(...) สาย — ไม่มีผล ไม่มี error ไม่มี log w.WriteHeader(400) สาย — ไม่มีผล แต่ มี log ฝั่ง server w.Write(...) ต่อ body ได้เรื่อย ๆ ตามปกติ
พนักงานกดไมค์ประกาศในห้าง: "ประกาศตามหาเด็กชายอายุ 5 ขวบ…"
ประโยคเปิดนั้นออกลำโพงไปแล้ว ดึงกลับไม่ได้ คนทั้งห้างได้ยินพร้อมกันหมด
เขาจะพูดต่ออีกกี่ประโยคก็ได้ — แต่จะเปลี่ยนประโยคเปิดเป็น
"ประกาศโปรโมชั่นชั้น 4" ไม่ได้แล้ว เพราะคนตัดสินใจไปแล้วว่าเรื่องนี้เกี่ยวกับตัวเองไหม
· ประโยคเปิดที่บอกว่าเรื่องอะไร = status + header ทั้งชุด
— ส่งได้ครั้งเดียว ตอนที่ WriteHeader ทำงาน
· เนื้อความที่พูดต่อ = body — Write ต่อไปเรื่อย ๆ ได้
· และถ้าพนักงานลืมกดประกาศหัวเรื่อง แล้วพูดเนื้อความเลย ระบบจะใส่หัวเรื่อง
"ประกาศทั่วไป" ให้อัตโนมัติ — นั่นคือ Write ที่แอบเรียก
WriteHeader(200) ให้เราโดยที่เราไม่ได้สั่ง
ที่ไม่เหมือนกัน: ในห้างพูดผิดยังขอโทษแล้วประกาศแก้ได้ แต่ HTTP ไม่มีช่องให้แก้เลย และที่ร้ายกว่าคือ Go ไม่ฟ้อง error ไม่ panic — บอกเราแค่ log บรรทัดเดียวฝั่ง server เท่านั้น ซึ่งเป็นเหตุผลที่บั๊กชนิดนี้อยู่ยาวเป็นปี
แล้วถ้าไม่เคยเรียก WriteHeader เลยล่ะ? เอกสารของ
ResponseWriter.Write ตอบไว้ว่า:
"If WriteHeader has not yet been called, Write calls WriteHeader(http.StatusOK)"
แปลว่า การเขียน body ครั้งแรก = การประกาศ 200 โดยปริยาย
— และนี่แหละคือกับดัก
ทายก่อนรัน — handler ตัวนี้ตั้งใจจะตอบ 400
พร้อม body ที่บอกว่าอะไรผิด อ่านแล้วดูสมเหตุสมผลมาก
func broken(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{"error": "id ต้องเป็นตัวเลข"})
w.WriteHeader(http.StatusBadRequest)
}
client ได้ status อะไร? · Content-Type เป็นอะไร? ·
โปรแกรม panic ไหม? · ฝั่ง server มีอะไรบอกเราไหมว่ามีอะไรผิด
ผมรัน handler ข้างบนตัวนั้นเป๊ะ ๆ ผ่าน httptest ได้:
=== RUN TestBroken
main_test.go:19: w.Code = 200
main_test.go:20: Content-Type = "text/plain; charset=utf-8"
main_test.go:21: body = "{\"error\":\"id ต้องเป็นตัวเลข\"}\n"
--- PASS: TestBroken (0.00s)
และผมรันบั๊กรูปแบบเดียวกัน (เขียน body ก่อน แล้วค่อยสั่ง status) บน
server จริง ที่ยิงด้วย curl ได้ผลสอดคล้องกัน —
คราวนี้ body เป็น text ธรรมดาเพื่อให้เห็นชัดว่าสิ่งที่เขียนหลัง
WriteHeader ก็ยังไหลออกไปตามปกติ:
$ curl -s -i http://127.0.0.1:8765/late-status
HTTP/1.1 200 OK
Content-Length: 37
Content-Type: text/plain; charset=utf-8
body written first
after WriteHeader
สถานะเป็น 200 · Content-Type เป็น text/plain · ไม่ panic · request สำเร็จเรียบร้อย
ทั้งที่ body บอกว่า error — ฝั่ง client จะเห็นว่า "สำเร็จ" แล้วเอา
{"error": ...} ไปแสดงเป็นข้อมูลผู้ใช้
สิ่งเดียวที่บอกเราคือ log ฝั่ง server หนึ่งบรรทัด:
2026/08/20 21:51:17 http: superfluous response.WriteHeader call from main.main.func8 (main.go:60)
superfluous = "เกินความจำเป็น" · main.main.func8 (main.go:60)
คือชื่อ handler และบรรทัดที่เรียก WriteHeader ผิด — พาไปหาต้นเหตุได้ทันที
ถ้าวันไหนเห็นคำว่า superfluous ใน log แปลว่า
มี endpoint ที่ตอบ status ผิดอยู่ตอนนี้ อย่าปล่อยผ่าน
⚠️ ทำไมบั๊กชนิดนี้ถึงอยู่รอดใน production ได้นาน
เพราะมันไม่ทำให้อะไรพัง — ไม่ panic ไม่ตัด connection ไม่ทำให้ test crash
มันแค่ทำให้ error ทุกตัวของ endpoint นั้นกลายเป็น 200
ซึ่งแปลว่า retry logic ไม่ทำงาน · alert ไม่ดัง · dashboard เขียวสวยตลอด
และเทสก็จับไม่ได้ ถ้าเทสดูแต่ w.Body — ต้อง assert w.Code ด้วยเสมอ
ทางแก้: return ทุกครั้งที่ตอบเสร็จ
สาเหตุที่แท้จริงของกับดักนี้ไม่ใช่ "สลับสองบรรทัด" — ของจริงมันหน้าตาแบบนี้:
❌ ลืม return — ตอบสองครั้ง
u, ok := users[r.PathValue("id")]
if !ok {
writeJSON(w, 404, errNotFound)
// ← ลืม return ตรงนี้
}
writeJSON(w, 200, u) // วิ่งต่อเสมอ!
ผมรันจริง ยิง /users/999 ได้ HTTP/1.1 404 Not Found
(ครั้งแรกชนะ) แต่ body ออกมาสองก้อนต่อกัน:
{"error":"user not found"} แล้วตามด้วย {"id":"","name":""}
และ server log ว่า superfluous response.WriteHeader call from
main.writeJSON (main.go:18)
✅ guard clause — ตอบแล้วออก
u, ok := users[id]
if !ok {
writeJSON(w, 404, errNotFound)
return // ← ขาดไม่ได้
}
writeJSON(w, 200, u)
แบบเดียวกับบทเรียน 4 เป๊ะ ๆ — แค่คราวนี้ "ลืม return" ไม่ได้ทำให้ค่าผิด แต่ทำให้ HTTP response ผิด
หัวข้อนี้คือหัวใจของบท — ศัพท์ห้าคำนี้ทำให้คุณอธิบายบั๊กให้ senior ฟังจบใน 1 ประโยค
- commit (response ถูก commit แล้ว)
- "จุดไม่ย้อนกลับ" ในภาพข้างบน · หมายถึง status + header ถูกล็อกแล้ว แก้ไม่ได้อีก (Go จดลงบัฟเฟอร์เตรียมส่งเรียบร้อย — ไม่ว่าไบต์จะออกจากเครื่องไปจริงหรือยัง) · ประโยคที่ตรงที่สุดเวลาไล่บั๊กคือ "handler นี้ commit response ไปตั้งแต่บรรทัดไหน"
- implicit 200
- "200 ที่เกิดขึ้นเองโดยไม่มีใครสั่ง" — ผลของการเขียน body ก่อนสั่ง status ·
"ตรงนี้มัน implicit 200 ไปแล้ว
WriteHeader(400)เลยไม่มีผล" - superfluous WriteHeader
- ข้อความ log ของ Go เอง แปลว่า "สั่ง status ซ้ำโดยไม่จำเป็น" · คำนี้ค้นหาได้ตรงตัว — พิมพ์ถาม AI ว่า "Go บอก superfluous response.WriteHeader call แปลว่าอะไร" ได้คำตอบตรงจุดกว่าถามว่า "ทำไม API ตอบ 200"
- guard clause / early return
- "เจอเคสผิดแล้วตอบแล้วออกทันที" ด้วย
return· "ตรงนี้ลืม early return" คือคำที่ reviewer ใช้จริงตอน comment PR - silent failure (พังเงียบ)
- บั๊กที่ไม่ทำให้อะไรพัง ไม่มี error ไม่มี alert แต่ผลลัพธ์ผิด · "บั๊กนี้ fail เงียบ เทสเลยไม่จับ" — เป็นคำที่ทำให้คนอื่นรู้ทันทีว่าทำไมมันรอดมาได้นาน
7 · ListenAndServe คืน error เสมอ — อย่ากลืนมัน
โค้ดในหัวข้อ 1 ผมเขียน http.ListenAndServe(":8080", mux) ลอย ๆ
โดยไม่รับค่าที่มันคืน — ซึ่ง Go ยอมให้ทำ และนั่นคือปัญหา
ผมรันสามสถานการณ์ให้ดูว่ามันคืนอะไรบ้าง:
$ go run ./lasrv
// เปิด server ตัวที่สองทับพอร์ตที่ตัวแรกจองอยู่
srv2 return: listen tcp 127.0.0.1:8971: bind: address already in use
srv2 err == nil ? false
// สั่งปิดตัวแรกอย่างสุภาพด้วย srv.Shutdown()
srv1 return: http: Server closed | errors.Is(err, http.ErrServerClosed)=true
// ขอจองพอร์ต 80 ในฐานะ user ธรรมดา
port80 return: listen tcp 127.0.0.1:80: bind: permission denied
สรุปจากผลรัน: ListenAndServe ไม่เคยคืน nil
ถ้ามันคืนค่าเมื่อไหร่แปลว่า server หยุดแล้ว และมีสองสาเหตุเท่านั้น —
เปิดไม่ขึ้นตั้งแต่แรก หรือ ถูกสั่งปิด
❌ กลืน error
http.ListenAndServe(":8080", mux)
พอร์ตชน → ฟังก์ชันคืนค่าทันที → main จบ →
process ออกด้วย exit code 0 อย่างเงียบสนิท
คุณจะเห็นแค่ "container ขึ้นแล้วดับ ไม่มี log อะไรเลย"
ซึ่งเป็นหนึ่งในเคสที่ debug ยากที่สุดตอน deploy
✅ บังคับให้มันดัง
log.Println("listening on :8080")
if err := http.ListenAndServe(":8080", mux);
err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
พิมพ์สาเหตุออกมาแล้ว exit ด้วย code 1 — ตัวที่คอยรันโปรแกรมให้เรา
(Docker, Kubernetes, systemd) เห็น exit code ที่ไม่ใช่ 0 แล้วรู้ว่าพัง
ที่ยกเว้น ErrServerClosed เพราะการปิดที่เราสั่งเอง ไม่ใช่ความผิดพลาด
(ใช้ errors.Is จาก บทเรียน 12)
💡 ":8080" กับ "127.0.0.1:8080" ไม่เหมือนกัน
ละ host ไว้ (":8080") = ฟังทุก network interface — เครื่องอื่นในวงเน็ตเดียวกันยิงเข้ามาได้
และบน macOS จะเด้ง popup ขออนุญาต firewall
"127.0.0.1:8080" = ฟังเฉพาะเครื่องตัวเอง — ตอนพัฒนาใช้อันนี้ปลอดภัยกว่า
(ตัวอย่างในบทนี้ผมใช้ 127.0.0.1 ทั้งหมดด้วยเหตุผลนี้)
8 · เทส handler โดยไม่ต้องเปิดพอร์ต
ยังจำหัวข้อ 2 ได้ไหมว่า w เป็น interface? ตรงนี้แหละที่มันคืนกำไร
เราไม่ต้องรัน server ไม่ต้องยิง curl ไม่ต้องหาพอร์ตว่าง —
แค่เอา ResponseWriter ปลอมยัดเข้าไปแล้วเรียก mux.ServeHTTP ตรง ๆ
w := httptest.NewRecorder() // ResponseWriter ปลอม (เก็บลง buffer)
req := httptest.NewRequest(http.MethodGet, "/users/1", nil) // Request ปลอม
mux.ServeHTTP(w, req) // เรียกเข้า mux เหมือน request จริง
// จากนั้นอ่านผลจาก w ได้เลย — สามบรรทัดล่างนี้แค่โชว์ว่ามีอะไรให้อ่านบ้าง
// เวลาใช้จริงต้องเอาไปใส่ใน if/t.Errorf ไม่ใช่วางลอย ๆ แบบนี้
w.Code // int เช่น 200
w.Body.String() // string body ทั้งก้อน
w.Header().Get("Content-Type") // string header ที่ handler set
สังเกตว่าเรายิงเข้า mux ไม่ใช่เรียก handler ตรง ๆ —
ทำแบบนี้จะได้ทดสอบ routing และ pattern ของเราไปพร้อมกัน
ซึ่งจากหัวข้อ 3 เรารู้แล้วว่า pattern พิมพ์ผิดคือกับดักที่เงียบที่สุด
📌 บทนี้ใช้ httptest แค่พอทำภารกิจได้ —
เรื่องเต็ม ๆ (NewRecorder เทียบ NewServer ·
กับดัก recorder ใช้ซ้ำ · assert.JSONEq · เทสไฟล์อัปโหลดและ middleware)
อยู่ใน บทเรียน 40 ท้าย Day 8
9 · แล้วทำไมยังต้องมี Gin
พออ่านมาถึงตรงนี้คำถามที่ควรผุดขึ้นคือ "ก็ครบแล้วนี่ จะ framework ไปทำไม"
คำตอบที่ซื่อสัตย์คือ ครบจริงสำหรับ API เล็ก ๆ — ของที่ Gin ให้เพิ่มคือของที่
เขียนเองได้แต่ต้องเขียนซ้ำทุกโปรเจกต์
| เรื่อง | net/http (Go 1.26.5) | Gin (v1.12.0) |
|---|---|---|
| method ไม่ตรง | 405 + Allow ให้เอง |
404 จนกว่าจะเปิด HandleMethodNotAllowed |
| path param | {id} + r.PathValue("id") |
:id + c.Param("id") |
| static ชนกับ param ( /users/me vs /users/{id}) |
เหมือนกันทั้งคู่ — static ชนะ ไม่สนลำดับที่ลง | |
| อ่าน JSON body + validate | เขียน json.Decode + เช็คทีละ field เอง |
c.ShouldBindJSON + tag binding:"required" |
| ตอบ JSON | set header เอง แล้ว Encode (ลำดับสามขั้นในหัวข้อ 5) |
c.JSON(200, obj) — set Content-Type ให้เอง |
| middleware / group | ห่อ Handler เองทีละชั้น |
r.Use(...) · r.Group("/api/v1") |
✅ เก็บไปจากบทนี้: Gin ไม่ได้มาแทน net/http — Gin
รับ ResponseWriter กับ *Request ตัวเดียวกันนี้แหละ
ไปห่อไว้ใน *gin.Context
แปลว่ากฎทุกข้อในบทนี้ ยังใช้อยู่ตอนเขียน Gin: ตอบซ้ำสองครั้งก็ยังได้
superfluous · เขียน header หลังตอบก็ยังไม่มีผล
คุณเพิ่งเรียนชั้นที่อยู่ใต้ทุก framework ของ Go ไปแล้ว —
บทเรียน 38 ค่อยขึ้นไปชั้นบน
ทดสอบความเข้าใจ
1. ลง pattern ไว้อันเดียวคือ "GET /hello" แล้วยิง curl -I http://.../hello (ซึ่งคือ method HEAD) จะได้อะไร?
ผมยิงจริงได้ HTTP/1.1 200 OK พร้อม Content-Length: 22
— Go สร้าง HEAD จาก handler ตัวเดียวกับ GET ให้อัตโนมัติ
(ส่ง header แต่ตัด body ทิ้ง)
หลักฐานอีกชิ้นอยู่ที่ header ของเคสที่ผิด method จริง ๆ: ยิง POST /hello
ได้ 405 พร้อม Allow: GET, HEAD —
คำว่า HEAD โผล่มาเองทั้งที่เราไม่ได้พิมพ์
ตัวเลือกที่ 1 ผิดสองจุด: ทั้ง status และค่าใน Allow (มี HEAD ด้วยเสมอ) ·
ตัวเลือกที่ 3 ผิดเพราะ path ตรง จึงไม่ใช่ 404 — 404 จะเกิดตอนไม่มี path นี้เท่านั้น ·
ตัวเลือกที่ 4 ผิดเพราะ ServeMux ไม่เคยตอบ 501
ข้อควรจำที่ต่างกันสุดขั้ว: OPTIONS ไม่ได้ฟรีแบบ HEAD —
ผมยิงแล้วได้ 405 ซึ่งกระทบ CORS preflight โดยตรง
2. handler เรียก json.NewEncoder(w).Encode(errBody) ก่อน แล้วบรรทัดถัดมาจึงเรียก w.WriteHeader(400) — client เห็นอะไร?
ผมรัน handler รูปนี้ผ่าน httptest ได้ w.Code = 200
และรันบั๊กแบบเดียวกันบน server จริงแล้วยิงด้วย curl ก็ได้
HTTP/1.1 200 OK เหมือนกัน
เหตุผลอยู่ในเอกสารของ Write: ถ้ายังไม่เคยเรียก WriteHeader
ตัว Write จะเรียก WriteHeader(http.StatusOK) ให้ก่อน
— Encode เขียน body ก็คือเรียก Write จึงล็อกสถานะเป็น 200 ไปแล้ว
ตอนที่บรรทัด WriteHeader(400) ทำงาน
สิ่งเดียวที่เตือนเราคือ log ฝั่ง server:
http: superfluous response.WriteHeader call from main.main.func8 (main.go:60)
ซึ่งบอกทั้งชื่อ handler และเลขบรรทัด
ตัวเลือกที่ 1 ผิดเพราะ status ที่ส่งออกไปแล้วดึงกลับไม่ได้ (ข้อจำกัดของโปรโตคอล ไม่ใช่ของ Go) ·
ตัวเลือกที่ 2 ผิดเพราะไม่ panic ไม่ตัด connection request จบสวยงามด้วยซ้ำ ·
ตัวเลือกที่ 4 ผิดเพราะ net/http แค่ log ไม่เปลี่ยน response ให้
สังเกตด้วยว่า Content-Type กลายเป็น text/plain; charset=utf-8
เพราะไม่ได้ set ก่อน — บั๊กสองตัวมาจากกฎเดียวกัน
3. ทำไม signature ถึงเป็น w http.ResponseWriter (ไม่มีดาว) แต่ r *http.Request (มีดาว)?
go doc net/http.ResponseWriter บอกตรง ๆ ว่ามันคือ
interface ที่มีสาม method: Header(), Write(),
WriteHeader() — และค่า interface ใน Go ถือ pointer ของ implementation ไว้ข้างในแล้ว
ผมพิสูจน์โดยพิมพ์ %T ของ w ในสองสถานการณ์ ได้
*http.response ตอนรัน server จริง และ
*httptest.ResponseRecorder ตอนรันผ่าน httptest
— โค้ด handler ตัวเดียวกันแท้ ๆ
ส่วน Request ผมวัดได้ unsafe.Sizeof(http.Request{}) = 304 bytes
เทียบกับ pointer ที่ 8 bytes — ส่งเป็นค่าจะคัดลอกทุก request และ middleware แก้อะไรไม่ถึงกัน
ตัวเลือกที่ 2 ผิดเพราะ Go ไม่มีกฎแบบนั้นเลย เขียน *T ตำแหน่งไหนก็ได้ ·
ตัวเลือกที่ 3 ผิดที่คำว่า struct — ถ้ามันเป็น struct จริง การสลับตัวปลอมตอนเทสจะทำไม่ได้ ·
ตัวเลือกที่ 4 ผิดกลับด้าน: handler ต้องอ่านจาก r (นั่นคือหน้าที่หลักของมัน)
สิ่งที่เอกสารห้ามคือแก้ r ไม่ใช่อ่าน
4. เขียน http.ListenAndServe(":8080", mux) ลอย ๆ ไม่รับค่าคืน แล้วบังเอิญพอร์ต 8080 ถูกใช้อยู่ — เกิดอะไรขึ้น?
ผมรันจริงแล้วมันคืน error ทันที:
listen tcp 127.0.0.1:8971: bind: address already in use (และ err == nil เป็น false)
แต่เมื่อโค้ดไม่รับค่าที่คืนมา ไม่มีใครอ่าน error นั้น →
main เดินต่อจนจบฟังก์ชัน → process ออกด้วย exit code 0
โดยไม่พิมพ์อะไรเลยสักบรรทัด ซึ่งตอน deploy จะเห็นแค่ "ขึ้นแล้วดับ"
ตัวเลือกที่ 1 ผิดเพราะ bind ที่ล้มเหลวคือจบเลย ไม่มีการรอคิว ·
ตัวเลือกที่ 2 ผิดเพราะ Go บังคับเรื่องนี้เฉพาะกับตัวแปรที่ประกาศแล้วไม่ได้ใช้
ไม่ได้บังคับให้รับค่าคืนของฟังก์ชัน ·
ตัวเลือกที่ 3 ผิดเพราะไม่มี panic — มันคืน error ธรรมดา
ทางแก้: if err := http.ListenAndServe(...); err != nil &&
!errors.Is(err, http.ErrServerClosed) { log.Fatal(err) }
ที่ต้องยกเว้น ErrServerClosed เพราะตอนสั่งปิดเอง
ListenAndServe คืน http: Server closed ซึ่ง
errors.Is(err, http.ErrServerClosed) เป็น true — อันนั้นไม่ใช่ความผิดพลาด
เป้าหมายไม่ใช่ "ทำให้ curl ได้ 200" — เป้าหมายคือ
ทำให้บั๊ก superfluous เกิดขึ้นกับมือ แล้วเขียนเทสที่จับมันได้
ถ้าข้ามข้อ 5 กับ 6 ไป ภารกิจนี้จะไม่เหลืออะไรเลย
- ตั้งโปรเจกต์: โฟลเดอร์ใหม่ →
go mod init w37→ ยืนยันด้วยgo versionว่า 1.22 ขึ้นไป (ต่ำกว่านี้ pattern"GET /health"จะถูกตีเป็น path ชื่อประหลาด ไม่ใช่ method)
ไม่ต้องgo getอะไรทั้งสิ้น - เขียน
writeJSONก่อนเขียน handler: ฟังก์ชันเดียวที่รับ(w, status int, v any)แล้วทำสามขั้น ตามลำดับของหัวข้อ 5 —Header().Set→WriteHeader→json.NewEncoder(w).Encode
เขียนตัวนี้ก่อนเพราะมันคือที่เดียวที่ลำดับจะผิดได้ — ที่เหลือของโปรแกรมเรียกมันอย่างเดียว - สอง endpoint:
GET /health→200body{"status":"ok"}GET /users/{id}→ อ่าน id ด้วยr.PathValue("id")หาใน map ที่ hardcode ไว้ — เจอตอบ200พร้อม user · ไม่เจอตอบ404body{"error":"user not found"}
อย่าลืมreturnหลังตอบ 404
ข้อมูลปลอมให้ใช้ชุดนี้ไปเลย จะได้ตรงกับเฉลย:var users = map[string]User{"1": {ID: "1", Name: "Somchai"}}โดย structUserต้องมี json tag ตัวเล็ก (`json:"id"`·`json:"name"`— บทเรียน 13) ไม่งั้น body จะออกมาเป็น{"ID":...,"Name":...}ตัวใหญ่ แล้วเทสในข้อ 7 จะไม่ผ่าน - รันแล้วยิงด้วย
curl -iสี่ครั้ง —
(เปิดสองเทอร์มินัล: หน้าต่างแรกรันgo run .แล้วปล่อยค้างไว้ เพราะListenAndServeบล็อกอยู่ตรงนั้น · หน้าต่างที่สองใช้ยิงcurl)
/health·/users/1·/users/999· และ-X DELETE /users/1
ต้องตอบให้ได้สามข้อ: อันสุดท้ายได้ status อะไร · headerAllowเขียนว่าอะไร · ทำไมมีคำที่คุณไม่ได้ลงทะเบียนโผล่อยู่ในAllow - ทำให้มันพังด้วยมือ: ใน
writeJSONลองสลับสองบรรทัดแรก (WriteHeaderขึ้นก่อนHeader().Set) แล้วยิง/users/999
ตอบให้ได้: status ยังเป็น 404 ไหม ·Content-Typeเปลี่ยนเป็นอะไร · มี error หรือ log ไหม - ทำให้มันพังแบบที่สอง: เขียน handler ใหม่หนึ่งตัวที่
Encodebody ก่อน แล้วค่อยw.WriteHeader(400)
ยิงด้วยcurl -iแล้วดูเทอร์มินัลฝั่ง server ด้วย — client ได้ status อะไร และ server พิมพ์อะไรออกมา (ควรมีคำว่าsuperfluous) - เขียนเทสพิสูจน์ — table-driven ตามบทเรียน
16 ใช้
httptest.NewRecorder()+httptest.NewRequest(...)+mux.ServeHTTP(w, req)ครอบสี่เคส:200 /health·200 /users/1·404 /users/999·405 DELETE /users/1
ต้อง assert ทั้งw.Codeและw.Body(ดูข้อ 8 ว่าทำไม assert แค่ body ถึงจับบั๊กของบทนี้ไม่ได้เลย)
สร้างhttptest.NewRecorder()ใหม่ทุกเคสภายในt.Run - ตรวจว่าเทสของคุณ "เทสจริง": เอา handler พังจากข้อ 6 มาเขียนเทสที่
assertว่าได้400
เทสนี้ต้อง fail — ผมรันแล้วได้code = 200, want 400
ถ้าเทสของคุณผ่าน แปลว่าคุณ assert แต่ body อยู่ ซึ่งคือเหตุผลที่บั๊กชนิดนี้ รอดไปถึง production ได้ - ปิดท้ายด้วยเรื่อง
ListenAndServe: ห่อมันด้วยif err := ...; err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatal(err) }แล้วเปิดโปรแกรมสองหน้าต่างพร้อมกันที่พอร์ตเดียวกัน
เทียบกับตอนที่เขียนลอย ๆ ไม่รับค่า — ต่างกันตรงไหนในสิ่งที่คุณเห็นบนจอ - ท้าทาย: ลง
"GET /users/{id}"คู่กับ"GET /users/me"แล้วยิงทั้งสอง · จากนั้นลองเพิ่ม"GET /users/{name}"เข้าไปอีกอัน — ทายก่อนรันว่า พังตอน compile, ตอน startup, หรือตอน request
ผมแยก NewMux() ออกจาก main() เพื่อให้เทสหยิบไปใช้ได้:
type User struct {
ID string `json:"id"`
Name string `json:"name"`
}
var users = map[string]User{"1": {ID: "1", Name: "Somchai"}}
// writeJSON คือตัวที่เขียนไว้ตั้งแต่ข้อ 2 — Header().Set → WriteHeader → Encode
func NewMux() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
u, ok := users[r.PathValue("id")]
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "user not found"})
return
}
writeJSON(w, http.StatusOK, u)
})
return mux
}
func main() {
log.Println("listening on 127.0.0.1:8080")
if err := http.ListenAndServe("127.0.0.1:8080", NewMux()); err != nil {
log.Fatal(err)
}
}
ฝั่งเทส — w ใหม่ทุกเคส และเทียบ body หลัง TrimSpace
(เพราะ Encode ใส่ \n ท้ายให้เสมอ):
tests := []struct {
name, method, path, wantBody string
wantCode int
}{
{"health ตอบ ok", http.MethodGet, "/health", `{"status":"ok"}`, http.StatusOK},
{"เจอ user", http.MethodGet, "/users/1", `{"id":"1","name":"Somchai"}`, http.StatusOK},
{"ไม่เจอ user", http.MethodGet, "/users/999", `{"error":"user not found"}`, http.StatusNotFound},
{"method ผิด", http.MethodDelete, "/users/1", "Method Not Allowed", http.StatusMethodNotAllowed},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
w := httptest.NewRecorder() // ← ใน t.Run เท่านั้น
req := httptest.NewRequest(tc.method, tc.path, nil)
mux.ServeHTTP(w, req)
if w.Code != tc.wantCode {
t.Fatalf("code = %d, want %d (body=%s)", w.Code, tc.wantCode, w.Body.String())
}
if got := strings.TrimSpace(w.Body.String()); got != tc.wantBody {
t.Errorf("body = %q, want %q", got, tc.wantBody)
}
})
}
ผลรันจริงบนเครื่องผม (ผมใส่ t.Logf เพิ่มเพื่อให้เห็น header ด้วย)
ตอนตรวจผมวางไฟล์ไว้ในโฟลเดอร์ย่อย api/ จึงสั่ง go test -v ./api/
— ถ้าคุณวางไว้ที่รากโปรเจกต์เลย ให้สั่ง go test -v . แทน ผลที่ได้เหมือนกัน
$ go test -v ./api/
=== RUN TestAPI
=== RUN TestAPI/health_ตอบ_ok
main_test.go:39: code=200 ct="application/json; charset=utf-8" allow="" body={"status":"ok"}
=== RUN TestAPI/เจอ_user
main_test.go:39: code=200 ct="application/json; charset=utf-8" allow="" body={"id":"1","name":"Somchai"}
=== RUN TestAPI/ไม่เจอ_user
main_test.go:39: code=404 ct="application/json; charset=utf-8" allow="" body={"error":"user not found"}
=== RUN TestAPI/method_ผิด
main_test.go:39: code=405 ct="text/plain; charset=utf-8" allow="GET, HEAD" body=Method Not Allowed
--- PASS: TestAPI (0.00s)
PASS
ok w37/api 0.448s
บรรทัดสุดท้ายคือของแถมที่ผมอยากให้สังเกตที่สุด — เคส 405 นั้น
Content-Type เป็น text/plain และมี Allow: GET, HEAD
ทั้งสองค่านี้ไม่ได้มาจากโค้ดที่เราเขียนสักบรรทัด —
ServeMux ตอบให้เองก่อนที่ handler ของเราจะถูกเรียกด้วยซ้ำ
นั่นคือสิ่งที่ Gin ไม่ทำให้ (ตอบ 404 แทน) ตามตารางในหัวข้อ 9
💬 ข้อ 8 คือข้อที่ผมอยากเห็นผลที่สุด — เทสที่ "ผ่านทั้งที่โค้ดตอบ status ผิด"
คือเทสที่หลอกคุณอยู่ และมันจะหลอกทั้งทีมต่อไปอีกเป็นปี
ถ้ารันแล้วได้ผลไม่ตรงกับในบทนี้ ส่ง output มาได้เลย —
โดยเฉพาะถ้า Go ของคุณต่ำกว่า 1.22 (pattern จะทำงานคนละแบบทั้งหมด) หรือถ้าเห็นคำว่า
superfluous โผล่ในที่ที่ไม่ได้ตั้งใจ
ตัวเลขและ output ทุกบรรทัดในหน้านี้รันบน go1.26.5 darwin/arm64 ด้วย stdlib ล้วน
ไม่มี dependency ภายนอกเลยแม้แต่ตัวเดียว
อ้างอิง
- pkg.go.dev —
net/http.ServeMux— อ่านหัวข้อ "Patterns" ให้จบ: กติกา method,{wildcard},{$}และนิยามของคำว่า "เจาะจงกว่า" (precedence) - Go Blog — Routing Enhancements for Go 1.22 — ทีม Go อธิบายเองว่าทำไมถึงเพิ่ม method กับ wildcard เข้า
ServeMuxและตัดสินใจเรื่อง 307 อย่างไร - pkg.go.dev —
ResponseWriter— ประโยคสำคัญอยู่ใน doc ของWrite: ถ้ายังไม่เรียกWriteHeaderมันจะเรียกWriteHeader(StatusOK)ให้เอง - pkg.go.dev —
Request.PathValue· SetPathValue — คืนสตริงว่างเมื่อไม่มี key ที่ขอ - pkg.go.dev —
ListenAndServe· ErrServerClosed — "ListenAndServe always returns a non-nil error" - pkg.go.dev —
net/http/httptest—NewRecorderและNewRequest(เรื่องเต็มอยู่บทเรียน 40) - MDN — 405 Method Not Allowed — สเปกบอกว่า response 405 ต้องมี header
Allowซึ่งอธิบายว่าทำไม Go ใส่ให้เอง