บทเรียน 37 · Day 8 · net/http

net/http — เซิร์ฟเวอร์ตัวแรก ไม่ใช้ framework

~18 นาที · ต้องมี: บทเรียน 12 (error), บทเรียน 13 (encoding/json)

ผ่านมา 36 บท เราเขียน Go มาเยอะมาก — แต่ยังไม่เคยเปิดพอร์ตสักครั้ง บทนี้เปิด และเปิดด้วย stdlib ล้วน ไม่ลง library อะไรเลย (stdlib = standard library คือชุดเครื่องมือที่ติดมากับ Go ตั้งแต่ตอนติดตั้ง ไม่ต้องโหลดเพิ่ม)
หัวใจของบทไม่ใช่ "เขียน server ยังไง" — แต่คือกฎข้อเดียวที่ทำให้ API พังเงียบ ๆ ทั้งทีม: ResponseWriter เขียนได้ครั้งเดียว ผมจะทำให้ handler (ฟังก์ชันที่รับ request แล้วเขียนคำตอบกลับ) ที่ "ดูเหมือนตอบ 400" ตอบ 200 ให้ดูกับตา

🧰 เตรียมเครื่องก่อนอ่าน — บทนี้เบามาก ไม่ต้องมี Docker ไม่ต้องมีเน็ต

  1. Go 1.22 ขึ้นไป — เช็คด้วย go version (เครื่องที่ผมรันทุกผลลัพธ์ในบทนี้คือ go1.26.5 darwin/arm64)
    ทำไมต้อง 1.22: ทั้งบทนี้พึ่ง routing แบบใหม่ ("GET /hello" และ {id}) ที่เพิ่งมีใน Go 1.22 — Go 1.21 ลงไปจะแปลสตริงพวกนี้ผิดความหมายทั้งหมด
  2. โฟลเดอร์เปล่า ๆ อันหนึ่ง แล้ว go mod init w37
    ไม่มี go get ในบทนี้เลยแม้แต่บรรทัดเดียว — net/http มาพร้อม Go ตั้งแต่ติดตั้ง
  3. 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.ResponseWriterr *http.Request
ชนิดinterface (3 method)pointer ไปยัง struct
ทิศทางขาออก — เราเขียนใส่ขาเข้า — เราอ่านจากมัน
ทำไมไม่มี/มี *interface พก pointer อยู่แล้วstruct 304 ไบต์ ห้ามคัดลอก
ตอนเทสถูกแทนด้วย*httptest.ResponseRecorderhttptest.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 · และ header X-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 — อันนั้นไม่ใช่ความผิดพลาด

🛠️ ภารกิจ — server ตัวแรกของคุณ แล้วพิสูจน์ด้วยเทส

เป้าหมายไม่ใช่ "ทำให้ curl ได้ 200" — เป้าหมายคือ ทำให้บั๊ก superfluous เกิดขึ้นกับมือ แล้วเขียนเทสที่จับมันได้ ถ้าข้ามข้อ 5 กับ 6 ไป ภารกิจนี้จะไม่เหลืออะไรเลย

  1. ตั้งโปรเจกต์: โฟลเดอร์ใหม่ → go mod init w37 → ยืนยันด้วย go version ว่า 1.22 ขึ้นไป (ต่ำกว่านี้ pattern "GET /health" จะถูกตีเป็น path ชื่อประหลาด ไม่ใช่ method)
    ไม่ต้อง go get อะไรทั้งสิ้น
  2. เขียน writeJSON ก่อนเขียน handler: ฟังก์ชันเดียวที่รับ (w, status int, v any) แล้วทำสามขั้น ตามลำดับของหัวข้อ 5 — Header().Set → WriteHeader → json.NewEncoder(w).Encode
    เขียนตัวนี้ก่อนเพราะมันคือที่เดียวที่ลำดับจะผิดได้ — ที่เหลือของโปรแกรมเรียกมันอย่างเดียว
  3. สอง endpoint:
    GET /health → 200 body {"status":"ok"}
    GET /users/{id} → อ่าน id ด้วย r.PathValue("id") หาใน map ที่ hardcode ไว้ — เจอตอบ 200 พร้อม user · ไม่เจอตอบ 404 body {"error":"user not found"}
    อย่าลืม return หลังตอบ 404
    ข้อมูลปลอมให้ใช้ชุดนี้ไปเลย จะได้ตรงกับเฉลย: var users = map[string]User{"1": {ID: "1", Name: "Somchai"}} โดย struct User ต้องมี json tag ตัวเล็ก (`json:"id"` · `json:"name"` — บทเรียน 13) ไม่งั้น body จะออกมาเป็น {"ID":...,"Name":...} ตัวใหญ่ แล้วเทสในข้อ 7 จะไม่ผ่าน
  4. รันแล้วยิงด้วย curl -i สี่ครั้ง —
    (เปิดสองเทอร์มินัล: หน้าต่างแรกรัน go run . แล้วปล่อยค้างไว้ เพราะ ListenAndServe บล็อกอยู่ตรงนั้น · หน้าต่างที่สองใช้ยิง curl)
    /health · /users/1 · /users/999 · และ -X DELETE /users/1
    ต้องตอบให้ได้สามข้อ: อันสุดท้ายได้ status อะไร · header Allow เขียนว่าอะไร · ทำไมมีคำที่คุณไม่ได้ลงทะเบียนโผล่อยู่ใน Allow
  5. ทำให้มันพังด้วยมือ: ใน writeJSON ลองสลับสองบรรทัดแรก (WriteHeader ขึ้นก่อน Header().Set) แล้วยิง /users/999
    ตอบให้ได้: status ยังเป็น 404 ไหม · Content-Type เปลี่ยนเป็นอะไร · มี error หรือ log ไหม
  6. ทำให้มันพังแบบที่สอง: เขียน handler ใหม่หนึ่งตัวที่ Encode body ก่อน แล้วค่อย w.WriteHeader(400)
    ยิงด้วย curl -i แล้วดูเทอร์มินัลฝั่ง server ด้วย — client ได้ status อะไร และ server พิมพ์อะไรออกมา (ควรมีคำว่า superfluous)
  7. เขียนเทสพิสูจน์ — 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
  8. ตรวจว่าเทสของคุณ "เทสจริง": เอา handler พังจากข้อ 6 มาเขียนเทสที่ assert ว่าได้ 400
    เทสนี้ต้อง fail — ผมรันแล้วได้ code = 200, want 400
    ถ้าเทสของคุณผ่าน แปลว่าคุณ assert แต่ body อยู่ ซึ่งคือเหตุผลที่บั๊กชนิดนี้ รอดไปถึง production ได้
  9. ปิดท้ายด้วยเรื่อง ListenAndServe: ห่อมันด้วย if err := ...; err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatal(err) } แล้วเปิดโปรแกรมสองหน้าต่างพร้อมกันที่พอร์ตเดียวกัน
    เทียบกับตอนที่เขียนลอย ๆ ไม่รับค่า — ต่างกันตรงไหนในสิ่งที่คุณเห็นบนจอ
  10. ท้าทาย: ลง "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 ภายนอกเลยแม้แต่ตัวเดียว

อ้างอิง