🧰 บทนี้ไม่ต้องลงอะไรเพิ่มเลย — โค้ดทุกชิ้นใช้ net/http +
net/http/httptest ที่ติดมากับ Go อยู่แล้ว
go mod init แล้ว go run . ได้ทันที
ผลรันทุกบรรทัดในหน้านี้มาจากเครื่องจริง: go version go1.26.5 darwin/arm64
(ส่วนที่พูดถึง Gin คือ github.com/gin-gonic/gin v1.12.0)
1 · ตัวเลขสามหลักคือ "หัวข้อ" ของคำตอบ ไม่ใช่ของประดับ
มือใหม่เกือบทุกคนคิดแบบนี้ตอนเริ่ม: "ก็ตอบ JSON ไปสิ ในนั้นมี field error อยู่แล้ว
ใครอยากรู้ก็อ่านเอา" — ฟังดูสมเหตุสมผล จนกระทั่งนึกออกว่า
คนที่อ่านคำตอบของคุณไม่ได้มีแค่โปรแกรมเมอร์คนเดียว
📖 สามคำที่จะใช้ทั้งบท — บทนี้ยังไม่ต้องเขียน server เอง (บท 37 ค่อยเขียน)
แต่ต้องรู้จักคำพวกนี้ก่อน:
handler = ฟังก์ชันที่เราเขียนไว้ให้ทำงานเมื่อมี request เข้ามาตรง URL หนึ่ง —
หน้าที่ของมันคือประกอบคำตอบ
router (ใน net/http เรียกว่า ServeMux) = ตัวที่ยืนอยู่หน้าสุด
ดูว่า request นี้ควรส่งไปให้ handler ตัวไหน
response = คำตอบหนึ่งชุด ประกอบด้วย 3 ส่วน: status code (ตัวเลข 3 หลัก) ·
header (ข้อมูลกำกับ) · body (เนื้อหา เช่น JSON) — บทนี้โฟกัสส่วนแรก
handler ของคุณตอบกลับไป
│
├─▶ client library ดู "ตัวเลข" ก่อนเสมอ แล้วค่อยตัดสินใจว่าจะแกะ body ไหม
│
├─▶ เบราว์เซอร์ ตัดสินใจ cache / redirect / ถาม username จาก "ตัวเลข"
│
├─▶ proxy · load balancer นับ error rate จาก "ตัวเลข" (มันไม่แกะ body ให้คุณ)
│
├─▶ monitoring / alert กราฟ 4xx / 5xx ที่ทีม on-call จ้องอยู่ = "ตัวเลข" ล้วน
│
└─▶ คน คนเดียวในลิสต์นี้ที่อ่าน body เป็น
(proxy กับ load balancer คือเครื่องที่ยืนคั่นกลางระหว่าง client กับ server ของเรา
คอยส่งต่อ request — ยังไม่ต้องรู้ลึกกว่านี้ในบทนี้)
ทุกคนในภาพยกเว้นบรรทัดสุดท้าย ไม่รู้จัก field error ของคุณเลย
พวกเขารู้จักแค่ตัวเลข เพราะตัวเลขคือส่วนเดียวของคำตอบที่มีความหมายมาตรฐาน
ไม่ว่าจะเขียนด้วยภาษาอะไร ทีมไหน บริษัทไหน
ดังนั้นกฎข้อแรกของบทนี้คือ:
ตัวเลขคือคำตอบที่เครื่องอ่าน · body คือคำตอบที่คนอ่าน — ต้องพูดตรงกันเสมอ
2 · อ่านหลักแรกให้เป็น แล้วที่เหลือเดาได้ครึ่งทาง
status code มี 3 หลัก แต่หลักแรกหลักเดียวบอกได้แล้วว่าเรื่องนี้จบยังไง สองหลักหลังเป็นแค่รายละเอียดว่า "จบแบบไหน"
| กลุ่ม | แปลว่า | ในงานจริงเจอตอนไหน |
|---|---|---|
| 1xx | รับทราบแล้ว ยังไม่จบ | แทบไม่ได้เขียนเอง — เป็นเรื่องของ protocol (ข้อตกลงกลางว่าสองฝั่งจะคุยกันด้วยรูปแบบไหน) เช่นตอน upgrade ไป WebSocket |
| 2xx | สำเร็จ | เส้นทางปกติของ handler ที่ทำงานได้ |
| 3xx | ของอยู่ที่อื่น ตามไปต่อ | redirect · ส่วนใหญ่ framework จัดการให้เอง (หัวข้อ 8) |
| 4xx | คำขอมีปัญหา — client ต้องแก้ | ส่งข้อมูลผิด · ไม่ได้ล็อกอิน · ขอของที่ไม่มี |
| 5xx | คำขอไม่มีปัญหา แต่ server ทำให้ไม่ได้ — server ต้องแก้ | DB ล่ม · โค้ดพัง · dependency timeout |
4xx = "คุณส่งมาแบบนี้ ผมทำให้ไม่ได้" · 5xx = "คุณส่งมาถูกแล้ว แต่ผมเองมีปัญหา"
เส้นแบ่งนี้ไม่ใช่เรื่องความสุภาพ มันคือการชี้ตัวคนที่ต้องลงมือแก้
ตอบ 4xx = ส่งงานกลับไปให้คนเรียก API · ตอบ 5xx = รับงานไว้เองและควรมี log ที่ฝั่งคุณเสมอ
ตอบผิดฝั่งเมื่อไหร่ คนที่ถูกตามตัวตอนตีสองก็ผิดคนทันที
คุณสั่งอาหารกับร้าน แล้วร้านทำให้ไม่ได้ — มันมีสองแบบ และคนที่ต้องลงมือแก้เป็นคนละคนกัน
· คุณสั่งเมนูที่ร้านไม่มี ("ขอซูชิครับ") → แม่ค้าบอกว่าไม่มี
คุณต้องเปลี่ยนไปสั่งอย่างอื่นเอง แม่ค้าไม่ต้องแตะเตาเลย = 4xx
(แต่ถ้ามีคนเดินมาสั่งซูชิกันทั้งวัน แปลว่าป้ายเมนูหน้าร้านสื่อผิด
ซึ่งเป็นงานของร้านอยู่ดี — 4xx ที่พุ่งขึ้นจึงไม่ใช่เคสที่ปิดทิ้งได้
มันมักแปลว่าเอกสาร API ผิด หรือเพิ่ง deploy อะไรที่ทำให้ฝั่งเรียกพัง)
· คุณสั่งกะเพราไก่ไข่ดาว ซึ่งมีในเมนูอยู่แล้ว แต่แก๊สหมด →
คำสั่งของคุณไม่ผิดสักนิด คนที่ต้องวิ่งไปหาแก๊สคือร้าน = 5xx
คำถามจึงไม่ใช่ "ใครน่าสงสารกว่า" แต่เป็น "ใครต้องลุกไปทำอะไรบางอย่าง"
ตอบเลขผิดกลุ่ม = ส่งงานไปให้ผิดคน
ที่ไม่เหมือนกัน: ที่ร้าน แม่ค้าจะพูดยังไงก็ได้ตามใจ ไม่มีใครจดไว้
แต่ status code มีเครื่องคอยนับอยู่ตลอดเวลา — ตอบผิดกลุ่มไม่ได้แค่ทำให้คนสั่งงง
แต่ทำให้กราฟที่ทั้งทีมใช้ดูสุขภาพระบบเพี้ยนไปด้วย ซึ่งเสียหายกว่ามาก
และที่ร้าน คนสั่งเป็นมนุษย์ที่เห็นหน้าแม่ค้าแล้วปรับเองได้ แต่ผู้เรียก API
เป็นโปรแกรมที่แตกเส้นทางจากตัวเลขอย่างเดียว —
ตอบเลขผิดกลุ่มเมื่อไหร่ มันเดินผิดทางทันทีโดยไม่มีใครทักท้วง
ลองตัวอย่างเดียวกันสองแบบ: ผู้ใช้สมัครสมาชิกด้วยอีเมลที่มีคนใช้แล้ว
ถ้าคุณตอบ 409 Conflict → กราฟ 4xx ขยับ ทีม server มองแล้วรู้ว่า
"คนใช้กรอกอีเมลซ้ำเยอะขึ้น" ซึ่งอาจแปลว่าหน้าเว็บสื่อสารไม่ดี — ไม่ใช่ระบบพัง
ถ้าคุณตอบ 500 → กราฟ 5xx พุ่ง alert ดัง คนถูกปลุกมาไล่ดู log
แล้วพบว่า "ทุกอย่างปกติ แค่อีเมลซ้ำ" — เสียแรงคนทั้งกะ และที่แย่กว่าคือ
ครั้งหน้าที่ 5xx พุ่งจริง ๆ จะไม่มีใครเชื่อกราฟนั้นอีก
สิ่งที่เพิ่งเข้าใจไปทั้งหมด วงการเรียกด้วยคำพวกนี้ · ใช้คำให้ตรงแล้วจะคุยกับ dev คนอื่นและถาม AI ได้ตรงจุดกว่าเดิมมาก
- client / server
- client = ฝั่งที่ขอ (แอปมือถือ หน้าเว็บ หรือโปรแกรมอื่นที่ยิงเข้ามา) · server = ฝั่งที่ตอบ คือโค้ดที่เรากำลังเขียนอยู่นี้ · เวลาเล่าปัญหาให้พูดว่า “client ส่งอะไรมา แล้ว server ตอบอะไรกลับ” จะเห็นภาพกว่าพูดว่า “มันพัง”
- status class
- “กลุ่มของตัวเลข” ที่ดูจากหลักแรก — 2xx, 4xx, 5xx · ประโยคที่ใช้จริง: “เคสนี้ควรตอบ 4xx หรือ 5xx” ถามแบบนี้กับ senior จะได้คำตอบเร็วกว่าถามลอย ๆ ว่า “ควรตอบเลขอะไรดี”
- endpoint
- URL หนึ่งเส้นทางที่ API เปิดให้เรียก เช่น
POST /users· พูดว่า “endpoint นี้ตอบ 500 อยู่” จะระบุที่เกิดเหตุได้แม่นกว่า “API พัง” - error rate
- สัดส่วนคำตอบที่เป็น error เทียบกับคำตอบทั้งหมด · เครื่องวัดจากตัวเลข ไม่ได้แกะ body ดู · ถามได้ว่า “error rate ของ endpoint นี้ตอนนี้เท่าไหร่”
- alert
- การแจ้งเตือนอัตโนมัติที่ยิงออกมาเองเมื่อกราฟผิดปกติ เช่น 5xx พุ่ง · “alert ดัง” = มีคนถูกเรียกจริง ๆ ไม่ใช่แค่ไฟกะพริบบนจอ
- on-call
- คนที่ถือเวรรับ alert ตอนระบบมีปัญหา รวมถึงตอนตีสอง · ถ้าได้ยินใครพูดว่า “อย่าทำให้ on-call ตื่นฟรี” เขากำลังพูดถึงเรื่องในหัวข้อนี้ตรง ๆ
3 · เก้าตัวที่ต้องคล่อง — รันจริงทั้งชุดในคราวเดียว
ผมเขียน API เล็ก ๆ ด้วย net/http ตัวเดียว
(สมัครสมาชิก + ลบผู้ใช้ที่ให้เฉพาะแอดมิน) แล้วยิงมันด้วย client จริงผ่าน
httptest.NewServer (ตัวช่วยที่ติดมากับ Go: เปิด server ชั่วคราวบนพอร์ตสุ่มให้ยิงทดสอบ
แล้วปิดทิ้ง — หัวข้อ 10 จะใช้อีกที) — ทุกบรรทัดข้างล่างนี้คือ output ที่ออกมาจริง
ไม่ได้พิมพ์เอง:
วิธีอ่าน: บรรทัดที่ขึ้นต้นด้วย $ คือคำขอที่ยิงเข้าไป
(เขียนย่อ ๆ ให้อ่านง่าย ไม่ใช่คำสั่งที่พิมพ์ในเทอร์มินัลได้) ·
บรรทัดที่ขึ้นต้นด้วย -> คือคำตอบที่ได้กลับมา
$ POST /users body={"email":"new@example.com"}
-> 201 Created Location: /users/2
{"id":"2","email":"new@example.com"}
$ POST /users body={"email":"new@example.com"} # อีเมลเดิมซ้ำ
-> 409 Conflict
{"error":"email already registered"}
$ POST /users body={"email":"ไม่ใช่อีเมล"}
-> 422 Unprocessable Entity
{"error":"email is not a valid address"}
$ POST /users body={"email": # JSON ขาดครึ่ง
-> 400 Bad Request
{"error":"invalid json body"}
$ DELETE /users/1 # ไม่ส่ง token
-> 401 Unauthorized WWW-Authenticate: Bearer realm="api"
{"error":"missing token"}
$ DELETE /users/1 Authorization: Bearer user-token
-> 403 Forbidden
{"error":"admin only"}
$ DELETE /users/1 Authorization: Bearer admin-token
-> 204 No Content
# ← body ว่างจริง ๆ
$ DELETE /users/1 Authorization: Bearer admin-token # ลบซ้ำ ของหายไปแล้ว
-> 404 Not Found
{"error":"user not found"}
$ PATCH /users/1 Authorization: Bearer admin-token
-> 405 Method Not Allowed
Method Not Allowed # ← อันนี้ Go ตอบเอง ไม่ได้เขียน
สังเกตว่า request แทบเหมือนกันหมด — DELETE /users/1 ตัวเดียวกัน
ได้ 401, 403, 204 และ 404 ตามลำดับ เพราะสิ่งที่เปลี่ยนคือบริบท: ใครถาม · ถามตอนไหน
นี่คือเหตุผลที่ status code ไม่ใช่ "ตารางที่ต้องท่อง" แต่เป็นคำถามที่ต้องตอบให้ตรง
ผลรันข้างบนมี 422 กับ 405 แถมมาด้วย (สองตัวนี้พูดถึงในหัวข้อ 6 และ 8) — แต่เก้าตัวที่ต้องคล่องจริง ๆ คือตารางข้างล่างนี้ ยึดตารางนี้เป็นหลัก
| รหัส | ตอบเมื่อ | ที่คนใหม่ใช้ผิดบ่อย |
|---|---|---|
| 200 OK | สำเร็จ และมีข้อมูลจะส่งกลับ | เอาไปใช้กับทุกอย่างรวมทั้งตอน error (หัวข้อ 7) |
| 201 Created | สำเร็จ และเพิ่งสร้างของใหม่ | ใช้ 200 กับ POST ที่สร้างของ |
| 204 No Content | สำเร็จ และไม่มีอะไรจะส่งกลับ | ส่ง body ตามไปด้วย (หัวข้อ 4) |
| 400 Bad Request | อ่านคำขอไม่รู้เรื่อง — รูปแบบผิด | เอาไปใช้แทน 401/403/409 หมดทุกอย่าง |
| 401 Unauthorized | ไม่รู้ว่าคุณเป็นใคร — ไม่มี token / token ใช้ไม่ได้ | สลับกับ 403 |
| 403 Forbidden | รู้ว่าคุณเป็นใครแล้ว แต่คุณทำสิ่งนี้ไม่ได้ | สลับกับ 401 |
| 404 Not Found | ไม่มี resource ตาม URL นี้ | ใช้กับ "มีของ แต่คุณไม่มีสิทธิ์ดู" ทั้งที่ควรเป็น 403 |
| 409 Conflict | คำขอถูกทุกอย่าง แต่ชนกับสถานะปัจจุบัน | ตอบ 400 หรือ 500 แทน |
| 500 Internal Server Error | ฝั่งเราพัง — และต้องมี log ที่ฝั่งเรา | ใช้กับความผิดของ client |
4 · ฝั่งสำเร็จ: 200 vs 201 vs 204 ต่างกันที่ "มีอะไรกลับไปไหม"
ทั้งสามตัวแปลว่าสำเร็จเหมือนกัน คำถามที่ใช้แยกมีข้อเดียว: หลังทำเสร็จ มีของส่งกลับหรือเปล่า และของนั้นเพิ่งเกิดใหม่ไหม
| มี body กลับไป? | ใช้กับ | |
|---|---|---|
| 200 OK | มี | GET /users/1 · PUT /users/1 ที่ตอบของที่แก้แล้ว |
| 201 Created | มี (ของที่เพิ่งสร้าง) + ควรมี header Location | POST /users |
| 204 No Content | ไม่มี — ห้ามมี | DELETE /users/1 · PUT ที่ไม่ตอบอะไรกลับ |
Location ในคำตอบ 201 คือการบอกว่า "ของที่คุณเพิ่งสร้าง ไปหาได้ที่ URL นี้"
ผมรันแล้วได้ Location: /users/2 จริงตามผลในหัวข้อ 3 —
client ที่ฉลาดหน่อยจะเอาไปใช้ต่อได้เลยโดยไม่ต้องเดาว่า id อยู่ตรงไหนใน body
ส่วน 204 มีข้อบังคับที่ทำให้คนใหม่งงบ่อยที่สุด: มันห้ามมี body ไม่ใช่ "ไม่ควร" แต่ "ห้าม" ระดับ protocol — แล้วถ้าเราเผลอเขียน body ตามไปล่ะ?
ทายก่อนรัน — handler นี้สั่ง 204 แล้วยัง Write ตามไปอีก
อ่านโค้ดก่อน (เจอครั้งแรก): mux.HandleFunc("GET /x", f) = บอก router ว่า
"ถ้ามี GET /x เข้ามา ให้เรียก f" ·
w (http.ResponseWriter) = ปลายปากกาที่เราใช้เขียนคำตอบออกไป ·
r = คำขอที่เข้ามา ·
w.WriteHeader(code) = สั่ง status · w.Write(b) = เขียน body
mux.HandleFunc("GET /x", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
n, err := w.Write([]byte(`{"message":"deleted"}`))
fmt.Printf("[server] w.Write -> n=%d err=%v\n", n, err)
})
ฝั่ง client จะได้ status อะไร body อะไร · และบรรทัด [server] จะพิมพ์ว่าอะไร —
มี error ไหม และถ้ามี ใครเห็นมัน
[server] w.Write -> n=0 err=http: request method or response status code does not allow body
[client] status="204 No Content" ContentLength=0
Go บอกคุณแล้ว — แต่บอกผ่านค่าที่แทบไม่มีใครรับ
w.Write คืน (n int, err error) เสมอ และตรงนี้มันคืน error จริง ๆ
แต่โค้ดที่เราเขียนกันทุกวัน (fmt.Fprintln(w, ...),
json.NewEncoder(w).Encode(v)) ไม่ได้เช็คค่านั้น
ผลคือ body หายไปเงียบ ๆ ไม่มี log ไม่มี panic
บทเรียนที่ต้องเก็บ: ถ้าอยากส่งข้อความกลับ อย่าใช้ 204 ใช้ 200 แล้วส่ง body ไป
เลือก 204 เมื่อตั้งใจจะไม่ส่งอะไรกลับเท่านั้น — ไม่ใช่เพราะเห็นว่ามันดูเท่กว่า
5 · 401 vs 403 — "ไม่รู้ว่าคุณเป็นใคร" กับ "รู้แล้ว แต่คุณทำไม่ได้"
คู่นี้สลับกันบ่อยที่สุดในโลก ส่วนหนึ่งเพราะชื่อมันหลอก: 401 ชื่อ Unauthorized แต่ความหมายจริงคือ "unauthenticated" = ยังไม่ได้พิสูจน์ตัวตน ส่วน 403 Forbidden ต่างหากที่แปลว่า "ไม่ได้รับอนุญาต"
401 — ยังไม่รู้ว่าคุณเป็นใคร
$ DELETE /users/1 # ไม่ส่ง token
-> 401 Unauthorized
WWW-Authenticate: Bearer realm="api"
{"error":"missing token"}
ทางแก้ของ client: ล็อกอินแล้วลองใหม่
เจอตอน: ไม่มี token · token หมดอายุ · token ปลอม
403 — รู้แล้วว่าคุณเป็นใคร
$ DELETE /users/1
Authorization: Bearer user-token
-> 403 Forbidden
{"error":"admin only"}
ทางแก้ของ client: ไม่มี — ล็อกอินใหม่กี่รอบก็ได้ 403 เหมือนเดิม
เจอตอน: สิทธิ์ไม่ถึง · ไม่ใช่เจ้าของข้อมูล
ทำไมต้องแยก? เพราะ frontend ทำคนละอย่างกับสองรหัสนี้
เจอ 401 → เด้งไปหน้า login (หรือใช้ refresh token ต่ออายุแล้วยิงซ้ำ)
เจอ 403 → ต้องขึ้นข้อความ "คุณไม่มีสิทธิ์" ห้ามเด้งไป login
เพราะผู้ใช้ล็อกอินอยู่แล้ว การเด้งไปหน้า login จะกลายเป็นลูปที่ผู้ใช้ออกไม่ได้
ตอบสลับสองตัวนี้ = frontend ทำงานผิด ทั้งที่โค้ด frontend ถูก
คุณเดินเข้าล็อบบี้คอนโด มีสองสถานการณ์ที่ยามไม่ให้ขึ้นลิฟต์ และมันคนละเรื่องกัน
· คุณยังไม่ได้แตะบัตร — ยามไม่รู้ว่าคุณเป็นใคร ลูกบ้านหรือคนนอก
จึงบอกว่า “ขอบัตรก่อนครับ” · ทางแก้มี: ไปเอาบัตรมาแตะ = 401
· คุณแตะบัตรแล้วแต่ไฟขึ้นแดง เพราะบัตรหมดอายุหรือเป็นบัตรปลอม —
ยามยังยืนยันไม่ได้ว่าคุณเป็นใคร จึงยังเป็น 401 ไม่ใช่ 403 ·
จุดตัดของสองรหัสนี้อยู่ที่ “ยืนยันตัวตนสำเร็จหรือยัง” ไม่ใช่ “ยื่นบัตรมาหรือยัง”
(ตรงกับตารางข้างบนที่เขียนว่า 401 เจอตอน ไม่มี token · token หมดอายุ · token ปลอม)
· คุณแตะบัตรแล้ว ยามรู้ว่าคุณคือคนห้อง 5/2 แต่คุณกดจะขึ้นดาดฟ้า
ที่เปิดให้เฉพาะช่าง ยามบอกว่า “รู้จักคุณครับ แต่ชั้นนั้นขึ้นไม่ได้” ·
ทางแก้ไม่มี — แตะบัตรใบเดิมอีกร้อยครั้งก็ขึ้นไม่ได้ = 403
นี่คือเหตุผลที่ frontend ต้องแยกสองตัวนี้ให้ออก: เจอ 401 แปลว่า “ไปเอาบัตรมา” จึงเด้งไปหน้า login ได้ · เจอ 403 แปลว่า “มีบัตรแล้วแต่ยังไงก็ไปไม่ได้” การเด้งไปหน้า login จึงกลายเป็นลูปที่ผู้ใช้ออกไม่ได้
ที่ไม่เหมือนกัน: ยามจำหน้าคุณได้ แตะบัตรรอบเดียวตอนเช้าก็เดินเข้าออกได้ทั้งวัน แต่ HTTP ไม่จำอะไรเลยระหว่าง request แต่ละครั้ง — ทุกครั้งที่ยิงเข้ามาต้องแนบ token ไปใหม่เสมอ ไม่งั้นก็ได้ 401 อยู่ดี
💡 WWW-Authenticate คืออะไร — header ที่แนบไปกับ 401 เพื่อบอกว่า
"ถ้าจะพิสูจน์ตัวตน ให้ใช้วิธีไหน" ในผลรันข้างบนคือ Bearer realm="api"
แปลว่า "ส่ง token มาทาง Authorization: Bearer ..."
เป็นคู่หูประจำตัวของ 401 (403 ไม่มี เพราะไม่มีอะไรให้ลองใหม่)
ส่วนเรื่อง header ทั้งชุดจะลงรายละเอียดใน
บทเรียน 35
สองคำแรกหน้าตาคล้ายกันจนคนใหม่เรียกสลับกันตลอด แต่มันคือ 401 กับ 403 ที่เพิ่งดูไปนี่เอง
- authentication (ย่อ authn)
- “พิสูจน์ว่าคุณเป็นใคร” = ตอนแตะบัตร · ล้มตรงนี้ตอบ 401 · เวลาถามให้พูดว่า “เคสนี้ล้มที่ authentication หรือ authorization” senior จะรู้ทันทีว่าต้องดูตรงไหน
- authorization (ย่อ authz)
- “รู้แล้วว่าคุณเป็นใคร แล้วคุณทำสิ่งนี้ได้ไหม” = ตอนกดชั้นดาดฟ้า · ล้มตรงนี้ตอบ 403
- token
- “บัตรผ่าน” ที่ client แนบมากับทุก request เพื่อบอกว่าตัวเองเป็นใคร ·
แบบที่เจอบ่อยสุดคือ Bearer token ส่งมาใน header
Authorization: Bearer <ค่า> - stateless
- “ไม่จำอะไรจากครั้งก่อน” · HTTP เป็นแบบนี้ ทุก request ต้องพกหลักฐานมาเองครบทุกครั้ง · ประโยคที่จะได้ยิน: “HTTP มัน stateless เลยต้องส่ง token ไปทุกครั้ง”
- frontend / backend
- frontend = ฝั่งที่ผู้ใช้เห็นและกด (เว็บ/แอป) · backend = ฝั่ง server ที่เราเขียน · ประโยคที่ทีมใช้จริง: “backend ตอบ 403 มา frontend เลยไม่ควรเด้ง login”
6 · 400 vs 422 vs 409 — ผิดที่รูปแบบ · ผิดที่ค่า · ผิดที่จังหวะ
ทั้งสามตัวเป็น 4xx เหมือนกัน (client ต้องแก้) ต่างกันที่ คำขอเดินมาได้ไกลแค่ไหนก่อนตาย ผมเรียง handler ให้ตรวจตามลำดับนี้เป๊ะ ๆ แล้วรันจริง:
request เข้ามา
│
├─ แกะ JSON ไม่ออกเลย ─────────────▶ 400 {"error":"invalid json body"}
│ (วงเล็บไม่ปิด · ไม่ใช่ JSON)
│
├─ แกะออกแล้ว แต่ค่าไม่ผ่านกฎ ─────▶ 422 {"error":"email is not a valid address"}
│ (อีเมลไม่มี @ · อายุติดลบ)
│
├─ ค่าถูกทุกอย่าง แต่ชนของที่มีอยู่ ──▶ 409 {"error":"email already registered"}
│ (อีเมลซ้ำ · ลบของที่ถูกลบไปแล้ว)
│
└─ ผ่านหมด ───────────────────────▶ 201 + Location
โครง if ที่เรียงแบบนี้ก็คือ guard clause ตัวเดียวกับที่ฝึกใน
บทเรียน 4:
ตรวจเคสที่ผิดแล้ว return ทันที เรียงจากผิดหยาบไปผิดละเอียด
เส้นทางสำเร็จจึงเหลืออยู่ล่างสุดโดยไม่มี else สักตัว
422 ไม่ได้อยู่ในสไลด์ของคอร์สนี้ และหลายทีมก็ไม่ใช้มันเลย —
ใช้ 400 คลุมทั้ง "JSON พัง" และ "ค่าไม่ผ่าน validation" (การตรวจว่าค่าที่ส่งมาถูกกฎไหม
เช่น อีเมลต้องมี @) ก็ไม่ผิด
ที่ผิดจริงคือใช้ไม่คงเส้นคงวา: endpoint นี้ 400 endpoint หน้า 422
ทั้งที่เป็นความผิดแบบเดียวกัน — client เขียนโค้ดรับมือไม่ถูก
สิ่งที่ทีมต้องทำคือตกลงกันครั้งเดียวแล้วเขียนลงเอกสาร API
ส่วน 409 ต่างออกไปชัดเจนจนไม่ควรเอาไปรวมกับ 400: มันแปลว่า
"คำขอคุณไม่ได้ผิดอะไรเลย แต่โลกตอนนี้ไม่ยอมให้ทำ" — ลองใหม่พรุ่งนี้อาจสำเร็จก็ได้
🔮 เรื่องที่จะย้อนมาหลอนตอนเรียน Gin — ในบทท้าย ๆ ของ Day 8 จะเจอ
c.BindJSON ที่เขียน status 400 ให้เองทันทีที่ validate ไม่ผ่าน
ทดสอบบน gin v1.12.0 แล้วเขียน c.JSON(422, ...) ต่อ จะได้แบบนี้:
[GIN-debug] [WARNING] Headers were already written. Wanted to override status code 400 with 422
STATUS=400
ขอ 422 แต่ได้ 400 เพราะ status ถูกส่งออกไปแล้ว เปลี่ยนไม่ได้อีก —
เก็บไว้ก่อนว่า status เขียนได้ครั้งเดียวต่อหนึ่ง response เดี๋ยวหัวข้อ 9 จะเจอกฎเดียวกันนี้ในฝั่ง net/http
7 · กับดักที่แพงที่สุด: ตอบ 200 พร้อม {"error":"not found"}
นี่คือโค้ดที่เจอในโปรเจกต์จริงบ่อยมาก และมักเกิดจากความหวังดี —
"ตอบ 200 ไว้ก่อน เดี๋ยว frontend อ่าน field error เอง จะได้ไม่ต้องมี try/catch"
ผมเขียน client แบบที่ทุกคนเขียนกัน แล้วยิงใส่ server สองตัวที่ body เหมือนกันเป๊ะ
ต่างกันแค่ตัวเลข:
ทายก่อนรัน — client ตัวเดียวกันข้างล่างนี้ ยิงใส่ server สองตัวที่ตอบ
body {"error":"user not found"} เหมือนกันเป๊ะ
ต่างกันแค่ตัวเลข: A ตอบ 200 · B ตอบ 404
func fetchUser(url string) (string, error) {
resp, err := http.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 { // ← ด่านเดียวที่ client มี
return "", fmt.Errorf("api error: %s", resp.Status)
}
b, _ := io.ReadAll(resp.Body)
var u struct {
ID string `json:"id"`
Name string `json:"name"`
}
json.Unmarshal(b, &u)
return fmt.Sprintf("โหลดสำเร็จ: id=%q name=%q", u.ID, u.Name), nil
}
ฝั่ง A: err ที่ได้กลับมาเป็นอะไร · แล้วสตริงที่พิมพ์ออกมาหน้าตายังไง —
เทียบกับฝั่ง B ที่ body เดียวกันเป๊ะ
=== A: server ตอบ 200 + {"error":"user not found"} ===
err = <nil>
out = โหลดสำเร็จ: id="" name=""
=== B: server ตอบ 404 + {"error":"user not found"} ===
err = api error: 404 Not Found
out =
ฝั่ง A ไม่มี error สักตัว — json.Unmarshal ก็ไม่บ่น
เพราะ JSON ถูกต้องตามไวยากรณ์ทุกประการ แค่ไม่มี field id กับ name
ที่ struct ต้องการ ผลคือได้ zero value ทั้งคู่
โปรแกรมจึงเดินต่อไปพร้อมกับ user ที่ Name == "" —
แล้วไปโผล่บนหน้าเว็บว่า "สวัสดีคุณ " หรือถูกเซฟลง DB เป็นแถวว่าง
สังเกตว่าฝั่ง B ที่ตอบ 404 ใช้ body เดียวกันเป๊ะ แต่ client จับได้ทันที — สิ่งเดียวที่เปลี่ยนคือตัวเลข
คุณสั่งของออนไลน์ วันรุ่งขึ้นแอปขึ้นสถานะว่า “จัดส่งสำเร็จ”
แต่พอเปิดกล่องจริง ในนั้นมีแค่กระดาษแผ่นเดียวเขียนว่า “สินค้าหมด”
· ระบบติดตามพัสดุ อ่านแค่คำว่า “สำเร็จ” → นับว่าส่งถึงแล้ว
· รายงานประจำเดือนของบริษัทขนส่ง ก็ขึ้นสวยงาม ไม่มีเคสตกหล่น
· คนเดียวในโลกที่รู้ว่ามีปัญหา คือคุณที่เปิดกล่อง — และคุณต้องโทรไปบอกเขาเอง
ตอบ 200 พร้อม body ที่มี error อยู่ข้างในก็คือแบบนี้เป๊ะ ๆ:
ตัวเลขข้างนอกบอกว่าสำเร็จ ข้างในบอกว่าไม่สำเร็จ
แล้วทุกเครื่องที่อ่านแต่ตัวเลขก็เชื่อตัวเลข
ที่ไม่เหมือนกัน: คนเปิดกล่องแล้วรู้ทันทีว่าไม่ได้ของ แต่โปรแกรมที่แกะ JSON ไม่รู้ตัวเลยแม้แต่นิดเดียว — มันเติมค่าว่างให้ทุกช่องแล้วเดินต่อเหมือนปกติ แล้วบางทีก็เอาค่าว่างนั้นไปเซฟลงฐานข้อมูลต่ออีกหลายพันแถวกว่าจะมีคนสังเกตเห็น
ความเสียหายไม่ได้จบแค่ client ตัวเดียว ผมนับ error rate แบบเดียวกับที่ monitoring ทำ (คือดูแค่หลักแรกของ status) ได้ผลนี้:
A(200) -> StatusCode=200 class=2xx นับเป็น error? false
B(404) -> StatusCode=404 class=4xx นับเป็น error? true
1. client library เชื่อตัวเลข — ไม่ว่าจะเป็น http.Get ของ Go,
axios, retrofit หรือ SDK ที่ลูกค้าเขียนเอง ทั้งหมดตัดสินใจจาก status ก่อน
เสมอ ตอบ 200 = สั่งให้ทุกตัวคิดว่าสำเร็จ
2. monitoring มองไม่เห็น — กราฟ error rate ของคุณจะเป็นเส้นตรงสวยงาม
ตลอดเวลาที่ระบบตอบผิดอยู่ ไม่มี alert ดัง ไม่มีใครรู้จนกว่าลูกค้าจะโทรมา
3. มันพังแบบเงียบและสะสม — bug ที่ crash เราเจอใน 5 นาที
ส่วน bug ที่ตอบ 200 เราเจอตอนมีข้อมูลว่าง ๆ ค้างใน DB เป็นหมื่นแถว
→ กฎที่ถือไว้ได้ตลอดชีวิตการทำงาน:
ถ้า body มีคำว่า error อยู่ในนั้น ตัวเลขต้องไม่ใช่ 2xx
สี่คำนี้คือคำที่ dev ใช้เรียก "อาการ" ที่เพิ่งเห็นไป — รู้คำแล้วจะอธิบายให้คนอื่นฟังได้ในประโยคเดียว
- zero value
- “ค่าเริ่มต้นเวลาไม่มีใครใส่ค่าให้” ของ Go —
stringได้"", ตัวเลขได้0,boolได้false· ประโยคที่ใช้ได้เลย: “field นี้ไม่มีใน JSON เลยได้ zero value ไป” - silent failure (พังเงียบ)
- พังจริงแต่ไม่มีใครร้อง — ไม่มี error ไม่มี log ไม่มี panic · เวลาเล่าให้ senior ฟังให้พูดว่า “เคสนี้มัน fail silently” เขาจะรู้ทันทีว่าปัญหาคือ “ไม่มีสัญญาณเตือน” ไม่ใช่ “โปรแกรมล้ม”
- happy path
- เส้นทางที่ทุกอย่างเป็นไปตามแผน · บั๊กแบบในหัวข้อนี้ไม่เคยทำให้ happy path พัง มันซ่อนอยู่ในเส้นทาง error เสมอ — เป็นเหตุผลที่ต้องเทสเคสที่ผิดด้วย ไม่ใช่เทสแต่เคสที่ถูก
- monitoring
- ระบบที่เก็บตัวเลขของระบบไว้แล้วทำกราฟ/ยิง alert ให้ · ตัวเก็บตัวเลขเช่น Prometheus, Datadog, CloudWatch ส่วน Grafana คือหน้าจอที่เอาตัวเลขจากตัวเก็บพวกนั้นมาวาดเป็นกราฟอีกที (พูดว่า “เก็บ metric ไว้ใน Grafana” กับทีม platform จะโดนแก้) · คำถามที่ถามได้จริง: “monitoring ของเรานับ error จาก status code อย่างเดียวหรือเปล่า” — ถ้าใช่ การตอบ 200 พร้อม error ก็แปลว่ามองไม่เห็นเลย
8 · status ที่คุณไม่ได้สั่ง แต่มันตอบไปแล้ว
ก่อนจะโทษตัวเองว่าเขียน status ผิด ต้องรู้ก่อนว่าไม่ใช่ทุกตัวเลขที่ออกจาก handler ของคุณ บางตัว router ตอบไปตั้งแต่ก่อนถึงโค้ดที่คุณเขียนแล้ว — และของแต่ละเจ้าไม่เหมือนกันด้วย
net/http — method ผิดได้ 405 ฟรี
$ 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
Method Not Allowed
ลงทะเบียนไว้แค่ "GET /hello" แต่ Allow โผล่มาเป็น
GET, HEAD — Go แถม HEAD ให้เอง
Gin — method ผิดได้ 404 ไม่ใช่ 405
=== default HandleMethodNotAllowed = false ===
GET /only-post -> 404 body=404 page not found
DELETE /users/42 -> 404 body=404 page not found
=== ตั้ง r.HandleMethodNotAllowed = true ===
GET /only-post -> 405 Allow="POST, PUT"
Gin ปิดไว้เป็น default → path ที่มีอยู่จริงแต่ยิงผิด method ดูเหมือนไม่มี endpoint นี้เลย
ทั้งสองผลรันจริงบน Go 1.26.5 / gin v1.12.0 และเป็นเรื่องที่ต้องรู้ตอน debug: เจอ 404 ทั้งที่แน่ใจว่าเขียน route ไว้แล้ว — คำถามแรกที่ต้องถามคือ "method ตรงไหม" ไม่ใช่ "path พิมพ์ผิดหรือเปล่า"
อีกสองตัวที่โผล่มาเองบ่อย:
- 307 Temporary Redirect — ลงทะเบียน pattern ไว้เป็น
/files/(มี/ปิดท้าย = คลุมทุก path ที่ขึ้นต้นด้วย/files/) แล้วมีคนยิง/filesเฉย ๆ ไม่มีสแลช —ServeMuxจะ redirect ให้เอง (Location: /files/) · ตำราเก่าเขียนว่า 301 แต่ผลรันบน Go 1.26.5 คือ 307 - ไม่มี status เลย — handler ที่
panic(ฟังก์ชันเจอเหตุร้ายจนไปต่อไม่ได้ เลยเลิกกลางคันแล้วไล่ย้อนออกมาทีละชั้น — ใน server ของnet/httpไม่ได้ล้มทั้งโปรแกรม แต่ตัด connection เส้นนั้นทิ้ง แล้วรับ request เส้นอื่นต่อตามปกติ) โดยไม่มีใครจับ ผมรันสองเส้นทางเทียบกัน:/users/1คือ handler ที่ เขียน 500 เอง ·/boomคือ handler ที่panicเฉย ๆGET /users/1 -> 500 Internal Server Error body="{\"error\":\"internal error\"}\n" GET /boom -> client error: Get "http://127.0.0.1:55237/boom": EOFEOFไม่ใช่ status code — มันคือ "สายถูกตัดกลางคัน" (net/httpเจอ panic แล้วปิด connection ทิ้ง ไม่ส่งตัวเลขอะไรกลับมาเลย) ซึ่งแย่กว่า 500 เพราะ client แยกไม่ออกว่าเป็นเน็ตหลุดหรือ server พังฝั่ง server ไม่ได้เงียบนะ — มันพิมพ์
http: panic serving 127.0.0.1:55462: explodeพร้อม stack trace (รายการว่าโค้ดวิ่งผ่านฟังก์ชันไหนมาบ้างก่อนพัง) ยาว ๆ ลง log ให้ แต่ client ไม่มีทางเห็นบรรทัดนั้นGin ต่างออกไป:
gin.Default()มี Recovery (ตัวดัก panic แล้วตอบ 500 กลับไปแทนที่จะตัดสายทิ้งเงียบ ๆ) ติดมาให้ จึงตอบstatus=500แทน · ส่วนgin.New()ที่ไม่มี Recovery ได้curl status=000(คือ curl ไม่ได้รับ status เลย = อาการเดียวกับEOFข้างบน)
ทั้งสองแบบ server ไม่ได้ตาย — ยิงเส้นทางอื่นต่อก็ยังตอบปกติ · สิ่งที่ Recovery ซื้อให้คุณจึงไม่ใช่ "ไม่ให้ server ล้ม" แต่คือ เปลี่ยนจากสายขาดที่ไม่มีตัวเลขให้นับ ให้กลายเป็น 500 ที่ขึ้นกราฟและตั้ง alert ได้
9 · เขียนใน Go ยังไงไม่ให้พลาด
อย่าพิมพ์เลขดิบ — net/http มีค่าคงที่ให้ครบทุกตัว
ผมพิมพ์ออกมาดูพร้อมชื่อข้อความประจำรหัส:
http.StatusOK = 200 StatusText = "OK"
http.StatusCreated = 201 StatusText = "Created"
http.StatusNoContent = 204 StatusText = "No Content"
http.StatusBadRequest = 400 StatusText = "Bad Request"
http.StatusUnauthorized = 401 StatusText = "Unauthorized"
http.StatusForbidden = 403 StatusText = "Forbidden"
http.StatusNotFound = 404 StatusText = "Not Found"
http.StatusConflict = 409 StatusText = "Conflict"
http.StatusUnprocessableEntity = 422 StatusText = "Unprocessable Entity"
http.StatusInternalServerError = 500 StatusText = "Internal Server Error"
http.StatusNotFound ยาวกว่า 404 ก็จริง แต่มันทำสองอย่างที่เลขดิบทำไม่ได้:
1) อ่านแล้วรู้เจตนาทันที — StatusNotFound บอกตัวเองว่าแปลว่าอะไร
2) พิมพ์ชื่อผิดแล้ว compile ไม่ผ่าน — เคาะเป็น http.StatusNotFund
คอมไพเลอร์ฟ้องทันทีตั้งแต่ยังไม่ได้รัน
เทียบกับเลขดิบ: เผลอพิมพ์ 440 แทน 404
คอมไพเลอร์ไม่มีทางรู้ เพราะมันก็เป็นแค่ int ตัวหนึ่ง — จะรู้ตอน client บ่นเท่านั้น
ต่อมาคือลำดับที่ห้ามสลับ ทดสอบมาแล้วทั้งสามแบบ:
| เขียนแบบนี้ | ผลจริงที่ client ได้ |
|---|---|
Header().Set(...) → WriteHeader(201) → Encode(u) |
✅ 201 Created + Content-Type: application/json; charset=utf-8 |
WriteHeader(201) → Header().Set(...) → Encode(u) |
201 แต่ Content-Type: text/plain; charset=utf-8 — ตั้งช้าไป header ที่ Set ทีหลังไม่มีผล และไม่มี error ไม่มี log ให้เห็นเลย |
เขียน body ก่อน แล้วค่อย WriteHeader(400) |
ได้ 200 + log ที่ฝั่ง server: http: superfluous response.WriteHeader call from main.main.func8 (main.go:60) |
เหตุผลเบื้องหลังทั้งตารางมีข้อเดียว: WriteHeader คือจังหวะที่ status กับ header
ถูกส่งออกสายไปแล้ว ของที่ตั้งทีหลังจึงตามไปไม่ทัน (แถว 2) และ Write ครั้งแรก
ก็เรียก WriteHeader(200) ให้เองอัตโนมัติถ้ายังไม่เคยเรียก (แถว 3)
แถวสุดท้ายคือต้นตอคลาสสิกของ "ทำไม error ตอบ 200" ที่ไม่ได้เกิดจากตั้งใจเลย —
แค่เผลอ fmt.Fprintln(w, ...) ไปก่อนหนึ่งบรรทัด status ก็ถูกล็อกที่ 200 ทันที
ข่าวดีคือ log บอกชื่อฟังก์ชันและเลขบรรทัดมาให้เสร็จ — เห็น superfluous เมื่อไหร่
ให้ตามไปที่บรรทัดนั้นได้เลย
กฎเดียวที่ต้องจำ: Header().Set() → WriteHeader() → Write()/Encode()
และเขียน status ได้ครั้งเดียวต่อหนึ่ง response
ใบเสร็จออกจากเครื่องทีละบรรทัดจากบนลงล่าง และกระดาษเลื่อนออกทางเดียว ย้อนกลับไม่ได้
· หัวใบเสร็จ (ชื่อร้าน เลขที่บิล) = status + header ต้องออกมาก่อน
· รายการสินค้าที่ไล่ตามลงมา = body
จากตรงนี้อธิบายทั้งตารางข้างบนได้หมด:
· แคชเชียร์เผลอสั่งพิมพ์รายการสินค้าก่อนโดยยังไม่ได้ตั้งหัวบิล →
เครื่องใส่หัวมาตรฐานให้เองอัตโนมัติ ตรงกับที่ Write ครั้งแรก
เรียก WriteHeader(200) ให้เองถ้าเรายังไม่เคยสั่ง (แถว 3)
· พอกระดาษเลื่อนพ้นหัวพิมพ์ไปแล้ว จะย้อนขึ้นไปแก้บรรทัดบนไม่ได้อีก
ตรงกับที่ header ซึ่ง Set ทีหลัง WriteHeader ไม่มีผล (แถว 2)
ที่ไม่เหมือนกัน: ใบเสร็จพิมพ์ผิด แคชเชียร์ฉีกทิ้งแล้วพิมพ์ใบใหม่ได้ แต่ response ที่ออกสายไปแล้วเรียกคืนไม่ได้เลย — Go จึงทำได้แค่พิมพ์ log เตือนไว้ที่ฝั่งเรา แล้วปล่อยให้ของที่ส่งไปแล้วเป็นคำตอบสุดท้าย
คำพวกนี้จะโผล่ในข้อความ error และในคำตอบของ senior/AI ตรง ๆ — อ่านออกแล้วจะแก้เองได้ทันที
- http.ResponseWriter
- “ปลายปากกา” ที่ handler ใช้เขียนคำตอบออกไป — ทั้ง status, header และ body ออกทางนี้ทางเดียว · ถามได้ว่า “ตรงนี้เขียนอะไรลง ResponseWriter ไปแล้วบ้าง”
- header
- “ข้อมูลกำกับ” ที่ไปพร้อมคำตอบ เช่น
Content-Typeบอกว่า body เป็นชนิดอะไร ·Locationบอกว่าของที่เพิ่งสร้างอยู่ที่ไหน - headers already written (บางภาษาเรียก response committed)
- “คำตอบถูกส่งออกไปแล้ว แก้ไม่ได้อีก” · ข้อความที่ Gin พิมพ์จริง ในหัวข้อ 6 คือ
Headers were already written— ส่วนคำว่า response committed เป็นคำเรียกเรื่องเดียวกันในโลก Java/Spring (isCommitted) ฝั่ง Node/Express เรียกres.headersSent· เวลาค้นให้ยกข้อความที่เห็นใน log ของภาษาตัวเองไปค้นตรง ๆ จะเจอเร็วกว่าค้นว่า “status ไม่เปลี่ยน” - superfluous response.WriteHeader call
- log ของ
net/httpที่แปลว่า “สั่ง status ซ้ำ หรือสั่งช้าไปแล้ว” · ข้อดีคือมันบอกชื่อฟังก์ชันและเลขบรรทัดมาให้เสร็จ เห็นเมื่อไหร่ตามไปที่บรรทัดนั้นได้เลย
10 · พิสูจน์ด้วยเทส — status คือสิ่งที่เทสง่ายที่สุดในทั้ง API
ข้อดีของ status code คือมันเป็น int ตัวเดียว
จะ assert (เขียนเงื่อนไขให้เทสตรวจว่าค่าที่ได้ตรงกับที่คาดไว้ไหม) ก็ง่ายที่สุดในบรรดาทุกอย่างของ API
ใช้ httptest ที่ติดมากับ Go ได้เลย ไม่ต้องเปิด server จริง
โครงเป็น table-driven test แบบเดียวกับ
บทเรียน 16:
สามชิ้นที่เจอครั้งแรก:
NewRouter() = ฟังก์ชันของเราเอง ที่คืน *http.ServeMux
ที่ลงทะเบียน route ไว้หมดแล้ว (ในภารกิจข้างล่างต้องเขียนเอง) ·
httptest.NewRequest = ปั้น request ปลอมขึ้นมาหนึ่งอัน ·
httptest.NewRecorder() = กระดาษเปล่าที่รับคำตอบแทน client จริง
แล้วอ่านผลได้ที่ w.Code กับ w.Body
เรียก router.ServeHTTP(w, req) = สั่งให้ router ทำงานตรง ๆ
โดยไม่ต้องเปิดพอร์ตจริงเลย เทสจึงเร็วและไม่ชนกันเอง
func TestUserStatus(t *testing.T) {
tests := []struct {
name string
path string
wantCode int
}{
{"เจอ user -> 200", "/users/1", http.StatusOK},
{"ไม่เจอ user -> 404", "/users/999", http.StatusNotFound},
}
router := NewRouter()
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
w := httptest.NewRecorder() // recorder ใหม่ทุกเคส
router.ServeHTTP(w, httptest.NewRequest("GET", tc.path, nil))
if w.Code != tc.wantCode {
t.Fatalf("อยากได้ %d แต่ได้ %d body=%s", tc.wantCode, w.Code, w.Body.String())
}
})
}
}
✅ handler ที่ตอบ 404 ตอนไม่เจอ
$ go test -v -count=1 ./prove/
=== RUN TestUserStatus
=== RUN TestUserStatus/เจอ_user_->_200
=== RUN TestUserStatus/ไม่เจอ_user_->_404
--- PASS: TestUserStatus (0.00s)
--- PASS: TestUserStatus/เจอ_user_->_200 (0.00s)
--- PASS: TestUserStatus/ไม่เจอ_user_->_404 (0.00s)
PASS
ok check34/prove 0.452s
❌ handler เดิม แต่ตอบ 200 พร้อม error body
$ BUG=1 go test -v -count=1 ./prove/
=== RUN TestUserStatus
=== RUN TestUserStatus/เจอ_user_->_200
=== RUN TestUserStatus/ไม่เจอ_user_->_404
app_test.go:24: อยากได้ 404 แต่ได้ 200 body={"error":"user not found"}
--- FAIL: TestUserStatus (0.00s)
--- PASS: TestUserStatus/เจอ_user_->_200 (0.00s)
--- FAIL: TestUserStatus/ไม่เจอ_user_->_404 (0.00s)
FAIL
FAIL check34/prove 0.236s
FAIL
นี่คือเทสที่จับกับดักของหัวข้อ 7 ได้ก่อนขึ้น production — และสังเกตว่าเคส "เจอ user" ยัง PASS อยู่ทั้งสองฝั่ง ซึ่งตรงกับความเป็นจริง: bug แบบนี้ไม่เคยทำให้ happy path พัง มันซ่อนอยู่ในเส้นทาง error เสมอ
⚠️ กับดักในตัวเทสเอง — httptest.NewRecorder()
มีค่า w.Code เริ่มต้นเป็น 200 ตั้งแต่ยังไม่ถูกเขียนอะไรเลย
แปลว่า assert ว่า w.Code == 200 เฉย ๆ
พิสูจน์ไม่ได้ว่า handler ทำงานจริง — ต้อง assert body ควบไปด้วยเสมอ
และต้องสร้าง recorder ใหม่ทุกเคส: ใช้ตัวเดิมยิงซ้ำ w.Code จะค้างที่ครั้งแรก
และ body ต่อกันเป็นพืด — เทสจะ "ผ่าน" แบบผิด ๆ
ทดสอบความเข้าใจ
1. ระบบสมัครสมาชิกตอบ 500 ทุกครั้งที่อีเมลซ้ำ — ผลเสียที่ตรงที่สุดคืออะไร?
4xx = client ต้องแก้ · 5xx = server ต้องแก้ —
อีเมลซ้ำคือคำขอที่ชนกับสถานะปัจจุบัน จึงเป็น 409 Conflict
ซึ่งเป็น 4xx การตอบ 500 จึงเท่ากับรับผิดแทน client ทำให้กราฟ 5xx พุ่ง
alert ดัง คน on-call ตื่นมาไล่ log แล้วพบว่าไม่มีอะไรพังเลย
ที่แพงกว่านั้นคือ ครั้งต่อไปที่ 5xx พุ่งเพราะ DB ล่มจริง จะไม่มีใครเชื่อกราฟนั้นอีก
ตัวเลือกที่ 1 ผิด — HTTP ไม่ได้ห้าม retry 500 (ตรงกันข้าม 5xx คือกลุ่มที่ retry แล้วมีโอกาสสำเร็จมากกว่า 4xx ด้วยซ้ำ) ·
ตัวเลือกที่ 2 ผิดเพราะ 500 ไม่ใช่คำตอบที่ถูก cache โดยปริยาย ·
ตัวเลือกที่ 4 ผิดชัดเจน เราเขียน w.WriteHeader(http.StatusInternalServerError) เองได้ตลอด
2. ผู้ใช้ล็อกอินสำเร็จแล้วในฐานะสมาชิกธรรมดา กด DELETE /users/1 ที่ให้เฉพาะแอดมิน ควรตอบอะไร?
401 = ยังไม่รู้ว่าคุณเป็นใคร · 403 = รู้แล้ว แต่คุณทำไม่ได้
เคสนี้ระบบอ่าน token ออกและรู้ว่าเป็นสมาชิกคนไหนแล้ว จึงเป็น 403
ที่สำคัญคือมันเปลี่ยนพฤติกรรม frontend: เจอ 401 ให้เด้งไปหน้า login ·
เจอ 403 ห้ามเด้ง เพราะล็อกอินอยู่แล้ว จะกลายเป็นลูปที่ผู้ใช้ออกไม่ได้
ผมรันจริงได้ทั้งสองเคส: ไม่ส่ง token → 401 Unauthorized พร้อม
WWW-Authenticate: Bearer realm="api" · ส่ง token ที่ไม่ใช่แอดมิน → 403 Forbidden
ตัวเลือกที่ 1 ผิดเพราะ token ใช้ได้ปกติ แค่สิทธิ์ไม่ถึง ·
ตัวเลือกที่ 3 ผิดเพราะ 400 ไว้ใช้กับคำขอที่รูปแบบผิด ไม่ใช่เรื่องสิทธิ์ ·
ตัวเลือกที่ 4 ผิดหนักสุด เพราะ 204 เป็น 2xx = บอก client ว่าลบสำเร็จแล้ว
3. server ตอบ 200 พร้อม body {"error":"user not found"} — client ที่เช็ค if resp.StatusCode >= 400 จะเป็นยังไง?
ผลรันจริงของ client ตัวเดียวกันยิงใส่ server สองตัวที่ body เหมือนกันเป๊ะ:
ตอบ 200 → err = <nil> และ โหลดสำเร็จ: id="" name=""
ตอบ 404 → err = api error: 404 Not Found
สิ่งเดียวที่ต่างกันคือตัวเลข และมันตัดสินทุกอย่าง
ตัวเลือกที่ 1 กับ 3 ผิดด้วยเหตุผลเดียวกัน: {"error":"user not found"}
เป็น JSON ที่ถูกไวยากรณ์ทุกประการ Unmarshal จึงสำเร็จ แค่ไม่มี field ให้เติม
ก็ปล่อยเป็น zero value ไป ไม่ error และไม่ panic ·
ตัวเลือกที่ 2 ผิดเพราะไม่มีมาตรฐานไหนบอกว่า field ชื่อ error มีความหมายพิเศษ —
มันเป็นชื่อที่เราตั้งเองล้วน ๆ
ผลตามมาคือ monitoring ก็มองไม่เห็น: นับ class ได้ 2xx → นับเป็น error? false
4. handler สั่ง w.WriteHeader(http.StatusNoContent) แล้วเขียน {"message":"deleted"} ต่อ — client ได้อะไร?
ผลรันจริง:
[server] w.Write -> n=0 err=http: request method or response status code does not allow body
[client] status="204 No Content" ContentLength=0
Go บอกเราแล้ว แต่บอกผ่าน return value ที่เราไม่ได้รับ —
fmt.Fprintln(w, ...) และ json.NewEncoder(w).Encode(v)
ไม่ได้เช็ค error ตัวนั้นให้ ผลคือ body หายเงียบ ไม่มี log ไม่มี panic
ตัวเลือกที่ 2 ผิดเพราะ 204 ห้ามมี body ระดับ protocol ·
ตัวเลือกที่ 3 ผิดเพราะ status เขียนได้ครั้งเดียว การเขียน body ทีหลังไม่เปลี่ยนตัวเลข
(ตรงกันข้าม ถ้าเขียน body ก่อนแล้วค่อย WriteHeader(400) จะได้ 200 พร้อม log
superfluous response.WriteHeader call) ·
ตัวเลือกที่ 4 ผิดเพราะไม่มี panic เลย
สรุปสั้น: อยากส่งข้อความกลับ ใช้ 200 · เลือก 204 เมื่อจะไม่ส่งอะไรจริง ๆ
ใช้ net/http ล้วน ๆ ไม่ต้องลง dependency อะไรเลย เริ่มจากศูนย์แบบนี้:
mkdir status-lab && cd status-lab
go mod init statuslab
แล้วสร้างไฟล์ main.go (ขึ้นต้นด้วย package main) ที่มี
func main() สร้าง mux := http.NewServeMux() ·
ลงทะเบียน route · ปิดท้ายด้วย http.ListenAndServe(":8080", mux)
ทุกคำสั่งข้างล่างนี้รันจากในโฟลเดอร์ status-lab:
เปิด server ด้วย go run . ไว้เทอร์มินัลหนึ่ง แล้วเปิดอีกเทอร์มินัลไว้ยิง curl
ไปที่ http://127.0.0.1:8080/...
- สร้าง resource เดียวก่อน —
POST /usersกับDELETE /users/{id}เก็บข้อมูลไว้ในmapในหน่วยความจำก็พอ
ยังไม่ต้องมี DB ยังไม่ต้องมี framework - ไล่ตอบให้ครบเก้ารหัส ตามตารางในหัวข้อ 3:
201 (สร้างสำเร็จ +
Location) · 409 (อีเมลซ้ำ) · 400 (JSON พัง) · 401 (ไม่มี token) · 403 (token ไม่ใช่แอดมิน) · 204 (ลบสำเร็จ) · 404 (ลบซ้ำ) · 200 (GETที่เจอ) · 500 (จำลอง dependency ล่ม)
ใช้ค่าคงที่http.Status...เท่านั้น ห้ามพิมพ์เลขดิบสักตัว - ยิงด้วย
curl -iทุกเคส แล้วเก็บบรรทัดแรกของแต่ละอันไว้
ยิง method ที่ไม่ได้ลงทะเบียนด้วย เช่นcurl -i -X PATCH http://127.0.0.1:8080/users/1— ได้อะไร และใครเป็นคนตอบ (ใบ้: บรรทัดAllow:จะบอกเอง) - ลอง 204 ให้พัง — หลัง
WriteHeader(204)ให้เขียนn, err := w.Write(...)แล้วlogค่าerrออกมาดู
เทียบกับตอนใช้json.NewEncoder(w).Encode(v)— ทำไมแบบหลังถึงเงียบสนิท - สลับลำดับให้พัง — ย้าย
w.Header().Set("Content-Type", ...)ไปไว้หลังWriteHeaderแล้วดูContent-Typeที่ client ได้จริง
จากนั้นลองเขียน body ก่อนแล้วค่อยWriteHeader(400)— ดู log ฝั่ง server ให้เจอคำว่าsuperfluous - ทำกับดักหัวข้อ 7 ให้เกิดกับตัวเอง — เปลี่ยนเคส "ไม่เจอ user"
ให้ตอบ 200 พร้อม
{"error":"user not found"}แล้วเขียน client สั้น ๆ ที่เช็คแค่resp.StatusCode >= 400ยิงใส่
พิมพ์ค่าที่ได้ออกมาให้เห็นกับตาว่าerrเป็นnil - เขียนเทสพิสูจน์ — สร้าง
main_test.goไว้ในโฟลเดอร์เดียวกัน เขียน table-driven test ด้วยhttptest.NewRecorder()ครอบคลุมทั้งเก้ารหัส แต่ละเคส assert ทั้งw.Codeและ body แล้วรันด้วยgo test -v -count=1 ./...
เงื่อนไขที่ถือว่าผ่านภารกิจ: ย้อนข้อ 6 กลับมา (ตอบ 200) แล้วเทสต้องแดง และแก้กลับเป็น 404 แล้วต้องเขียว
ถ้าตอบ 200 แล้วเทสยังเขียวอยู่ แปลว่าเทสยังไม่ได้เทสอะไร (เช็คว่าสร้าง recorder ใหม่ทุกเคสหรือยัง) - ท้าทาย: ลองเรียก
w.WriteHeaderสองครั้งในเส้นทางเดียว แล้วตอบสามข้อ — client ได้เลขไหน · ฝั่ง server มี log ไหม · โปรแกรมตายหรือเปล่า
ถ้าลง route ไว้แค่ "DELETE /users/{id}" แล้วยิง PATCH เข้าไป
คุณจะไม่ได้เข้า handler ตัวเองเลย — ServeMux ตอบให้ก่อน
ด้วย 405 Method Not Allowed พร้อม Allow: DELETE
ข้างล่างคือผลรันของพฤติกรรมเดียวกัน แต่บน route ตัวอย่างที่ลงไว้แค่
"GET /hello" (ดูบรรทัด Allow เทียบกัน):
$ 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
Method Not Allowed
สองจุดที่ควรสังเกต: 1) ลงไว้แค่ GET แต่ Allow
ขึ้น GET, HEAD เพราะ Go แถม HEAD ให้เอง ·
2) body เป็น text/plain ไม่ใช่ JSON —
ถ้า client ของคุณ Unmarshal ทุก response โดยไม่ดู status ก่อน มันจะพังตรงนี้
และถ้าย้ายโค้ดนี้ไปอยู่บน Gin เมื่อไหร่ เคสเดียวกันจะได้ 404 แทน 405
เพราะ HandleMethodNotAllowed ปิดไว้เป็น default (หัวข้อ 8)
💬 ข้อ 6 กับข้อ 7 คือสองข้อที่ผมอยากเห็นผลที่สุด —
เพราะวันที่คุณเห็น err = <nil> ออกมาจากคำตอบที่มีคำว่า
"error" อยู่ในนั้นด้วยตาตัวเอง คุณจะไม่มีวันเขียน c.JSON(200, gin.H{"error": ...})
อีกเลยตลอดชีวิต
ถ้ารันแล้วได้ผลไม่ตรงกับในบท ส่ง output มาได้เลย —
โดยเฉพาะถ้าคุณใช้ Go คนละเวอร์ชัน เพราะเรื่อง 307/301 กับ 405 เปลี่ยนไปตามเวอร์ชันของ ServeMux จริง ๆ
บทถัดไป: ข้อมูลที่ client ส่งมามีสามช่องทาง — query, body, header —
และการเลือกช่องผิดทำให้ได้ค่าว่างแบบเงียบ ๆ ได้เหมือนกัน
อ้างอิง
- MDN — HTTP response status codes — คำนิยามของทุกรหัสในบทนี้ อ่านหน้าเดียวได้ครบ
- MDN — 401 Unauthorized · 403 Forbidden — อ่านคู่กัน จะเห็นว่าทำไม 401 ถึงต้องมี
WWW-Authenticateส่วน 403 ไม่มี - RFC 9110 §15 — Status Codes — ตัวข้อกำหนดจริง รวมถึงข้อความที่ระบุว่า 204 ต้องไม่มี body
- pkg.go.dev —
net/httpconstants — รายชื่อhttp.Status...ทั้งหมด ใช้แทนเลขดิบ - pkg.go.dev —
http.ResponseWriter— เอกสารอธิบายเองว่าทำไมลำดับต้องเป็นHeader→WriteHeader→Write - pkg.go.dev —
net/http/httptest—NewRecorder/NewRequest/NewServerที่ใช้ในหัวข้อ 10 - Cheat sheet Day 8 — ตารางเลือก status code และกับดักรวมทั้งวัน