🧰 ของที่ใช้ในบทนี้ — ทุก output ในหน้านี้มาจากการรันจริงบน
go version go1.26.5 darwin/arm64
github.com/gin-gonic/gin v1.12.0
swag CLI v1.16.4 · github.com/swaggo/swag v1.16.6
ส่วนที่ 2 ผมเปิด server ของ Gin ตัวเดียวที่ลงทะเบียนทั้งเส้นแบบแย่และเส้นแบบดี
ไว้ด้วยกัน แล้วยิงเทียบกันทีละคู่ด้วย curl — จะได้เห็นว่าสองแบบนี้
ทำงานได้เหมือนกัน แต่คนละคุณภาพ ตรงไหนบ้าง
📖 วิธีใช้บทนี้ให้ได้ผล — ทุกกล่องที่มีปุ่ม “ดูเฉลย”
ให้เขียนคำตอบลงกระดาษหรือพิมพ์ในโน้ตก่อน แล้วค่อยกด
ถ้าตอบไม่ได้ นั่นไม่ใช่เรื่องแย่ — มันคือรายชื่อบทที่ต้องกลับไปอ่าน
ซึ่งมีค่ากว่าการเดาถูกแบบไม่รู้เหตุผล
ส่วนที่ 1 · จับคู่ REST (สไลด์หน้า 41–42)
สไลด์หน้า 41 ให้โจทย์ 6 ข้อทางซ้าย และตัวเลือก 6 ตัวทางขวา แล้วให้ลากเส้นจับคู่
สังเกตก่อนว่าตัวเลือกทั้งหกไม่ได้อยู่ระนาบเดียวกัน: มีช่องใส่ข้อมูลของ request
(path / query / header / body — บทเรียน 35) ·
มีHTTP method (บทเรียน 33) ·
และมีสิ่งที่ server ตอบกลับ (status code —
บทเรียน 34)
แค่รู้ว่าโจทย์แต่ละข้อพูดถึงระนาบไหน ก็ตัดตัวเลือกไปได้ครึ่งหนึ่งแล้ว
เปิดแอปสั่งอาหารขึ้นมา คุณทำได้สองอย่างที่ดูคล้ายกัน แต่คนละเรื่องกันเลย
· กดเข้าไปที่ร้านใดร้านหนึ่ง — กดแล้วคุณอยู่ในหน้าของร้านนั้นร้านเดียว
ถ้าเอาชื่อร้านออก แอปก็ไม่รู้ว่าจะเปิดหน้าร้านไหนให้
· กดตัวกรองในหน้ารายการร้าน เช่น “ส่งฟรี” หรือ “เปิดอยู่ตอนนี้” —
ตัวกรองพวกนี้เอาออกได้ เอาออกแล้วก็ยังเห็นรายการร้านอยู่ดี แค่ไม่ได้คัดให้เท่านั้น
อันแรกคือสิ่งที่ URL เรียกว่า path (/users/123) ·
อันหลังคือ query (/orders?status=paid)
วิธีแยกที่ใช้ได้ตลอด: ลองลบค่านั้นทิ้งดู
ลบแล้วยังมีความหมาย (แปลว่า “เอาทั้งหมด”) = เป็น query ·
ลบแล้วไม่รู้จะเอาของชิ้นไหน = เป็น path
(มีข้อยกเว้น — บาง query จำเป็นจริง ๆ เช่น ?q= ของ search
ลบแล้วไม่เหลืออะไรให้ค้น แต่มันก็ยังเป็น query เพราะมันไม่ได้ชี้ว่าเป็นของชิ้นไหน)
ที่ไม่เหมือนกัน: ตัวกรองในแอปเราเห็นเป็นปุ่ม
แต่สิ่งที่วิ่งไปถึง server จริงคือตัวหนังสือที่ต่อท้าย URL —
และมันถูกจดลงไฟล์บันทึกของ server ทุกครั้งที่กด
ต่างจากปุ่มตรงที่เราไม่เห็นตอนมันถูกจด —
เดี๋ยวส่วนที่ 2 จะให้เห็นกับตาว่ามันทำให้รหัสผ่านหลุดได้ยังไง
| # | สิ่งที่ต้องการทำ | ตัวเลือก |
|---|---|---|
| 1 | ระบุว่าต้องการ User หมายเลข 123 | A. Header |
| 2 | กรอง Order เฉพาะที่ paid | B. POST |
| 3 | ส่ง Access Token | C. Path Parameter |
| 4 | สร้าง User ใหม่ | D. Query Parameter |
| 5 | ส่ง Name + Email สำหรับสร้าง User | E. Status Code |
| 6 | บอกว่าสร้าง Resource สำเร็จ | F. Request Body |
จับคู่ให้ครบทั้ง 6 คู่ก่อนกดเฉลย — และสำหรับข้อ 1 กับข้อ 2 ให้ตอบเพิ่มอีกอย่างว่า ทำไมสองข้อนี้ถึงไปคนละช่องกัน ทั้งที่ดูเผิน ๆ ก็ “ส่งค่าหนึ่งค่าไปให้ server” เหมือนกัน
| # | โจทย์ | คำตอบ | หน้าตาจริง | เพราะอะไร |
|---|---|---|---|---|
| 1 | ระบุ User 123 | C. Path Parameter | GET /users/123 |
“ระบุตัวของ” = path · หนึ่ง URL ชี้ไปที่ของชิ้นเดียวเสมอ (กฎข้อ 5, บทเรียน 36) |
| 2 | กรอง Order ที่ paid | D. Query Parameter | GET /orders?status=paid |
“เอาเซ็ตย่อยของของชุดเดิม” = query · ตัดออก/เพิ่ม/สลับลำดับได้โดยไม่แตะ routing — การจับคู่ URL เข้ากับฟังก์ชันที่จะทำงาน (กฎข้อ 6) |
| 3 | ส่ง Access Token | A. Header | Authorization: Bearer … |
token คือ metadata (ข้อมูลกำกับตัว request ไม่ใช่ตัวเนื้อข้อมูล) ของการเชื่อมต่อ ไม่ใช่เนื้อข้อมูล · และห้ามอยู่ใน URL เด็ดขาด (บทเรียน 35 หัวข้อ 8) |
| 4 | สร้าง User ใหม่ | B. POST | POST /users |
“ทำอะไรกับของชิ้นนั้น” = method ไม่ใช่ชื่อใน URI (กฎข้อ 7) |
| 5 | ส่ง Name + Email | F. Request Body | {"name":"…","email":"…"} |
เนื้อข้อมูลที่จะบันทึกลงระบบ ไปที่ body · ยาวได้ ซ้อนชั้นได้ ไม่ถูกจดลง access log |
| 6 | บอกว่าสร้างสำเร็จ | E. Status Code | 201 Created |
สี่ข้อบนคือสิ่งที่ client ส่งไป ข้อนี้คือสิ่งที่ server ตอบกลับ (บทเรียน 34) |
คำตอบของคำถามพ่วง (ข้อ 1 กับ 2 ต่างกันตรงไหน) —
ข้อ 1 ตอบคำถามว่า “ของชิ้นไหน” ส่วนข้อ 2 ตอบว่า “เอาชุดย่อยแบบไหนของของชุดนั้น”
ทดสอบง่าย ๆ: ตัดค่านั้นทิ้งแล้ว URL ยังมีความหมายอยู่ไหม
/users/ ที่ไม่มี 123 → ไม่รู้ว่าจะเอา user คนไหน = ค่านั้นต้องอยู่ใน path
/orders ที่ไม่มี ?status=paid → ยังมีความหมายอยู่ แปลว่า “เอาทั้งหมด” = ค่านั้นเป็น query
และในโค้ด Gin สองอย่างนี้ดึงด้วยคนละเมธอด — ผมยิงจริงแล้ว echo ค่าที่ดึงได้กลับมาให้ดู ว่าใครดึงด้วยอะไร:
$ curl -s 'http://127.0.0.1:18080/users/123'
{"extracted_by":"c.Param(\"id\")","id":"123","user":{...}}
$ curl -s 'http://127.0.0.1:18080/orders?status=paid'
{"extracted_by":"c.Query(\"status\")","filter_status":"paid","orders":[...]}
หกคำนี้คือคำที่ dev ใช้เรียก “ช่องใส่ข้อมูล” ที่เพิ่งจับคู่ไป — พูดให้ตรงคำ แล้วจะได้คำตอบที่ตรง ทั้งจาก senior และจาก AI
- resource
- “ของ” ที่ API พูดถึง เช่น user หนึ่งคน order หนึ่งใบ · ก่อนตั้งชื่อ URL ให้ถามตัวเองว่า “resource ของเส้นนี้คืออะไร” แล้วเอาคำนามนั้นเป็นชื่อเส้น
- endpoint
- API หนึ่งเส้น = method + path คู่กัน เช่น
GET /users/123· ประโยคที่ใช้บ่อยคือ “endpoint นี้รับอะไรบ้าง ตอบอะไรกลับมา” - path parameter (path param)
- ค่าที่ฝังอยู่ในเส้นทางของ URL เพื่อระบุว่าของชิ้นไหน ·
Gin ดึงด้วย
c.Param("id")· เวลาถามให้พูดว่า “ตัว id นี้เป็น path param หรือ query param” จะสื่อกว่าถามว่า “ตัวนี้คืออะไร” - query parameter / query string
- ส่วนที่ต่อท้ายเครื่องหมาย
?ใช้คัดกรอง เรียงลำดับ หรือแบ่งหน้า · Gin ดึงด้วยc.Query("status")· คำว่า “query string” หมายถึงทั้งก้อนหลัง? - request body
- ก้อนข้อมูลที่แนบไปกับ request (ปกติเป็น JSON) สำหรับเนื้อข้อมูลที่จะบันทึกลงระบบ · ประโยคที่เอาไปพูดในห้องรีวิวได้เลย: “อันนี้ส่งทาง body ดีกว่าส่งทาง query นะ เดี๋ยวมันลง log”
- header
- ข้อมูลกำกับตัว request เอง เช่น ใครเป็นคนส่ง ส่งมาเป็นรูปแบบไหน — ไม่ใช่เนื้อข้อมูล ·
Authorizationคือ header ที่ใช้ส่ง token
ส่วนที่ 2 · API Code Review (สไลด์หน้า 43–47)
โจทย์บนสไลด์เขียนไว้แบบนี้: “ทีมออกแบบ API แบบนี้ ให้ช่วยปรับให้ RESTful และปลอดภัยขึ้น”
GET /getUser?id=123 POST /deleteUser?id=123 POST /createUser?name=Alice&password=123456 GET /getPaidOrders
ก่อนจะไล่ทีละบรรทัด มีเรื่องหนึ่งที่ต้องพูดให้ชัดก่อน เพราะมันเป็นเหตุผลที่ code review แบบนี้
จำเป็น: สี่บรรทัดนี้ทำงานได้จริงทุกบรรทัด
ผมลงทะเบียนทั้งแบบแย่และแบบดีไว้ใน Gin ตัวเดียวกัน แล้วให้มันพิมพ์ตาราง route ของตัวเองออกมา:
$ curl -s http://127.0.0.1:18080/__routes
[
{"method":"GET","path":"/getUser"},
{"method":"GET","path":"/getPaidOrders"},
{"method":"GET","path":"/users/:id"},
{"method":"GET","path":"/orders"},
{"method":"GET","path":"/__routes"},
{"method":"POST","path":"/deleteUser"},
{"method":"POST","path":"/createUser"},
{"method":"POST","path":"/users"},
{"method":"DELETE","path":"/users/:id"}
]
ไม่มีบรรทัดไหนใน 4 บรรทัดนั้น compile ไม่ผ่าน และไม่มีบรรทัดไหน crash
ทุกเส้นตอบ 200 สวย ๆ กลับมาได้หมด — ถ้าเกณฑ์ของคุณคือ “มันทำงานไหม”
API ชุดนี้ผ่านฉลุย
เกณฑ์จริงของ code review จึงไม่ใช่ “ทำงานไหม” แต่คือ
“คนอื่นเดาถูกไหม · ขยายได้ไหม · มีอะไรรั่วออกไปไหม”
บรรทัดที่ 1 — GET /getUser?id=123
ผิดกฎกี่ข้อ ข้อไหนบ้าง (อ้างกฎ 7 ข้อจาก บทเรียน 36)
แล้วเขียนเส้นที่ถูกออกมา
คำใบ้: มันผิดมากกว่าเรื่อง “ชื่อไม่สวย” หนึ่งเรื่อง
❌ ของเดิม
GET /getUser?id=123
ทำงานได้ · แต่ผิดกฎ 3 ข้อพร้อมกัน
✅ ที่ควรเป็น
GET /users/123
URI บอกว่าของอะไร · method บอกว่าทำอะไร
ผิดสามข้อ:
- กฎข้อ 1 (ใช้ noun ไม่ใช่ verb) —
getUserเป็นกริยาเต็มตัว สไลด์บทเรียน 36 ยกตัวอย่างGET /getUsersไว้ในช่อง ❌ ตรง ๆ เลย - กฎข้อ 7 (อย่าใส่ action ใน URI ถ้า method สื่อได้แล้ว) —
คำว่า
getซ้ำกับ methodGETที่อยู่ข้างหน้าอยู่แล้ว พูดสองรอบเปล่า ๆ - กฎข้อ 5 (relationship/ตัวระบุใช้ path) —
123คือ ตัวระบุว่าเป็นของชิ้นไหน ไม่ใช่ตัวกรอง มันจึงต้องอยู่ที่ path ไม่ใช่ query (ตรงกับส่วนที่ 1 ข้อ 1 ที่เพิ่งจับคู่ไป)
และเมื่อยิงจริง สองเส้นนี้ให้ข้อมูลชุดเดียวกันเป๊ะ ต่างกันแค่ “ดึงค่าจากช่องไหน”:
=== BAD GET /getUser?id=123 ===
{"extracted_by":"c.Query(\"id\")","id":"123","user":{"id":"123","name":"Alice"}}
HTTP 200
=== GOOD GET /users/123 ===
{"extracted_by":"c.Param(\"id\")","id":"123","user":{"id":"123","name":"Alice"}}
HTTP 200
⚠ สังเกตสิ่งที่ไม่มีใน response
ทั้งสองเส้นไม่คืนฟิลด์ password กลับมาเลย
ทั้งที่ตอนสร้าง user เรารับ password เข้าไป — เพราะ รับเข้า กับ ส่งออก
ไม่จำเป็นต้องเป็น struct เดียวกัน (บทเรียน 39
แยก request DTO กับ response DTO คนละ struct กัน ไว้ด้วยเหตุผลนี้)
ถ้า GET /users/123 คืน password ออกมาด้วย ต่อให้ URI สวยตามกฎครบทุกข้อ
มันก็คือช่องโหว่ — กฎตั้งชื่อไม่ได้ช่วยอะไรถ้าข้อมูลที่ส่งออกไปผิดตั้งแต่แรก
นี่แหละคือหัวใจของทั้งส่วนนี้ — ผลลัพธ์เหมือนกัน 200 เท่ากัน
ความต่างทั้งหมดอยู่ที่คนอ่านคนต่อไปเดาถูกหรือเปล่า
คนที่รู้ว่าคุณมี /users/123 จะเดา /users/456 ได้ทันที
แต่คนที่เห็น /getUser?id=123 จะเดา /getOrder?id=… หรือ
/getOrderById?orderId=… ดี? — เขาต้องเปิดเอกสารทุกครั้งไปตลอด
บรรทัดที่ 2 — POST /deleteUser?id=123
บรรทัดนี้ผิดทุกอย่างเหมือนบรรทัดแรก บวกอีกหนึ่งเรื่องที่หนักกว่า —
เรื่องนั้นคืออะไร?
และคำถามพ่วงที่สำคัญกว่า: ถ้าเรายิง DELETE /users/123 ซ้ำ ๆ 3 ครั้ง
เทียบกับยิง POST /deleteUser?id=123 ซ้ำ ๆ 3 ครั้ง
status code ที่ได้จะต่างกันไหม
❌ ของเดิม
POST /deleteUser?id=123
✅ ที่ควรเป็น
DELETE /users/123
เรื่องที่หนักกว่า: ใช้ POST ทำงานลบ
ผิดกฎข้อ 7 ในรูปแบบที่เจ็บที่สุด — พอ POST ถูกใช้ทำทุกอย่าง
(สร้างก็ POST ลบก็ POST แก้ก็ POST) method ก็ไม่เหลือความหมายอะไรเลย
ทุกอย่างย้ายไปอยู่ในสตริง URI ทั้งหมด ซึ่งไม่มีเครื่องมือตัวไหนในโลกอ่านเข้าใจ
ผลตามมาที่จับต้องได้: อะไรก็ตามที่อยู่หลัง POST
ไม่มีใครกล้า retry ให้คุณ ตาม RFC 9110 DELETE เป็น
idempotent (ยิงซ้ำแล้วผลลัพธ์ปลายทางเท่าเดิม) ส่วน POST ไม่ใช่ —
proxy · retry layer · client library จึงตัดสินใจต่างกันสิ้นเชิงเวลาเน็ตกระตุก
(บทเรียน 36 หัวข้อ 6)
คำตอบของคำถามพ่วง — และมันคือคำตอบที่คนส่วนใหญ่ตอบผิด ผมยิงจริงทั้งสองแบบ ๆ ละ 3 ครั้ง:
##### GOOD: DELETE /users/123 x3 #####
call 1 -> {"deleted":"123","remaining":1} | HTTP 200
call 2 -> {"error":"user not found","id":"123","remaining":1} | HTTP 404
call 3 -> {"error":"user not found","id":"123","remaining":1} | HTTP 404
##### BAD: POST /deleteUser?id=123 x3 #####
call 1 -> {"deleted":"123","remaining":1} | HTTP 200
call 2 -> {"error":"user not found","id":"123","remaining":1} | HTTP 404
call 3 -> {"error":"user not found","id":"123","remaining":1} | HTTP 404
เหมือนกันเป๊ะ — ทั้ง status code ทั้ง body ทั้ง state ปลายทาง
ถ้าใครบอกว่า “ลองยิงซ้ำดูสิ แล้วจะเห็นความต่างเรื่อง idempotency”
อันนั้นพิสูจน์ไม่ได้จริง ผมรันแล้ว
เพราะ idempotency ไม่ใช่พฤติกรรมที่ server สะท้อนออกมาเป็นตัวเลข แต่เป็น
สัญญาที่ผูกกับตัว method เอง ตาม HTTP spec ซึ่ง
client · proxy · ชั้น retry เอาไปใช้ตัดสินใจว่า “ยิงซ้ำได้ไหม”
พูดอีกแบบ: การเลือก DELETE แทน POST
ไม่ได้เปลี่ยนสิ่งที่ server ทำ — มันเปลี่ยนสิ่งที่คนอื่นกล้าทำกับ server ของคุณ
ยืนรอลิฟต์แล้วใจร้อน กดปุ่มเรียกซ้ำอีก 5 ครั้ง — ลิฟต์ก็มาคันเดียว รอบเดียว
กดกี่ทีผลปลายทางก็เท่าเดิม ทุกคนเลยกดซ้ำได้โดยไม่ต้องคิดอะไร
แต่ถ้าอยู่ในแอปธนาคาร แล้วกด “ยืนยันโอนเงิน” ซ้ำอีก 3 ครั้ง —
เงินอาจออกจากบัญชี 3 รอบ ทุกคนเลยกดครั้งเดียวแล้วนั่งลุ้น
DELETE คือปุ่มลิฟต์ · POST คือปุ่มโอนเงิน
แต่ระวังตรงนี้ — ผลรันข้างบนทั้งสองแบบให้
200 → 404 → 404 เท่ากัน เพราะ handler ทั้งคู่เขียนให้ลบแบบเดียวกัน ·
ตัวเลขชุดนี้จึงไม่ใช่หลักฐานว่าเส้นไหน idempotent
ความต่างระหว่างปุ่มลิฟต์กับปุ่มโอนเงินอยู่ที่ “ข้างในทำอะไร”
(กดซ้ำแล้วเงินออกจากบัญชีเพิ่มอีกรอบไหม) ไม่ใช่ “ยิงแล้วเห็นตัวเลขอะไร”
กดปุ่มลิฟต์ครั้งแรกไฟติด ครั้งที่สองไฟไม่ติดเพิ่ม — สิ่งที่ตอบกลับมาต่างกัน
แต่ผลปลายทางเท่าเดิม คือ “ลิฟต์ถูกเรียกแล้ว” เท่ากันทุกครั้งที่กด
ที่ไม่เหมือนกัน: ลิฟต์ถูกสร้างมาให้กดซ้ำแล้วไม่พังจริง ๆ
แต่ HTTP ไม่ได้บังคับให้ DELETE ของคุณทำตัวแบบนั้น
มันเป็นแค่สัญญาที่เราต้องเขียนโค้ดให้ตรงเอง —
ถ้าเผลอเขียน DELETE ให้หักสต็อกทีละ 1 ทุกครั้งที่ถูกเรียก
มันก็ผิดสัญญาได้ และไม่มีเครื่องมือตัวไหนมาเตือน
- idempotent
- “ยิงซ้ำกี่ครั้ง ผลปลายทางก็เท่าเดิม” = ปุ่มลิฟต์ ·
GETPUTDELETEเป็น idempotent ส่วนPOSTไม่ใช่ · เวลาถามให้พูดว่า “endpoint นี้ idempotent ไหม” แทนที่จะถามว่า “ยิงซ้ำได้ไหม” — คำนี้ทำให้อีกฝ่ายรู้ทันทีว่าคุณถามถึงสัญญาระดับ HTTP ไม่ใช่ถามว่าเซิร์ฟเวอร์จะพังไหม - safe method
- method ที่ไม่เปลี่ยนแปลงข้อมูลเลย เช่น
GET· ตัวที่ safe จะ idempotent ด้วยเสมอ แต่ตัวที่ idempotent ไม่จำเป็นต้อง safe —DELETEเปลี่ยนข้อมูล จึง idempotent แต่ไม่ safe - retry
- การ “ยิงซ้ำอัตโนมัติ” เวลา request ล้มเหลว — ทั้งตอนไม่ได้คำตอบ
(timeout / connection ขาด) และตอนได้
5xxหรือ429กลับมา · คนที่ยิงซ้ำให้มักไม่ใช่คน แต่เป็นโค้ดที่อยู่ระหว่างทาง ซึ่งใช้ method เป็นเงื่อนไขหลักร่วมกับค่าที่ตั้งไว้ในคอนฟิก — ไม่ใช่ว่าเขียนเป็นDELETEแล้วทุกที่จะ retry ให้อัตโนมัติ (Gonet/httpเองยิงซ้ำให้แค่GET/HEAD/OPTIONS/TRACE) และPOSTก็ถูกยิงซ้ำได้ถ้าคอนฟิกเปิดไว้ - reverse proxy / load balancer
- เครื่องที่ยืนอยู่หน้า server ของเรา คอยรับ request แล้วส่งต่อเข้ามา (และกระจายโหลดให้หลายเครื่อง) · มันอ่านโค้ดเราไม่ได้ อ่านเอกสารเราก็ไม่ได้ — สิ่งที่มันใช้ตัดสินใจ (จะ retry ไหม จะส่งไปเครื่องไหน) คือ method + URL + header เท่านั้น นี่คือเหตุผลที่ method กับ URL ต้องสื่อความหมายด้วยตัวเอง
- HTTP spec / RFC 9110
- เอกสารกลางที่นิยามว่า method ไหนแปลว่าอะไร · ประโยคที่ใช้ปิดการเถียงในห้องรีวิวได้จริง:
“ตาม RFC 9110
DELETEเป็น idempotent เราเลยควรใช้ตัวนี้แทน POST”
1. 404 ที่ได้ตอนยิงซ้ำ ไม่ได้แปลว่า “ลบไม่สำเร็จ” —
และมันหน้าตาแยกไม่ออกจาก 404 ที่เกิดจากการลบของที่ไม่เคยมี ผมยิงยืนยันแล้ว:
$ curl -X DELETE 'http://127.0.0.1:18080/users/999' # id ที่ไม่เคยมีอยู่เลย
{"error":"user not found","id":"999","remaining":1} | HTTP 404 ← ครั้งแรกก็ 404 แล้ว
นี่คือเหตุผลที่หลาย API เลือกให้ DELETE ตอบ 204 No Content
ทุกครั้ง (ลบแล้วหรือไม่มีอยู่ก็ 204) เพื่อให้ผลลัพธ์ที่ client เห็น
สม่ำเสมอตามสัญญา idempotent ไปเลย — เป็นการตัดสินใจออกแบบ ไม่ใช่กฎตายตัว
(บทเรียน 36 หัวข้อ 7 เถียงเรื่องนี้ทั้งหัวข้อ)
2. ยิงผิด method บนเส้นที่ถูกต้อง Gin ตอบ 404 ไม่ใช่ 405 —
ตรงนี้ทำให้คนที่เพิ่งเปลี่ยนมาเขียนแบบ RESTful งงหนักมาก:
$ curl -o /dev/null -w 'HTTP %{http_code}\n' -X POST 'http://127.0.0.1:18080/users/123'
HTTP 404 # ทั้งที่ /users/:id มีอยู่จริง แค่ผิด method
# พิสูจน์สาเหตุด้วย httptest (เซิร์ฟเวอร์ตัวเดียวกัน มีทั้ง GET และ DELETE บน /users/:id):
HandleMethodNotAllowed=false -> POST /users/123 = 404 Allow: ""
HandleMethodNotAllowed=true -> POST /users/123 = 405 Allow: "GET, DELETE"
gin.New() ตั้ง HandleMethodNotAllowed = false เป็นค่า default
ต้องเปิดเองถึงจะได้ 405 พร้อม header Allow ที่ลิสต์ทุก method
ที่ path นั้นรับได้ — ในเคสนี้คือ GET, DELETE ไม่ใช่แค่ตัวเดียว
ถ้าไม่เปิด คนที่เรียก API ของคุณจะเห็น 404 แล้วสรุปว่า “ไม่มี endpoint นี้”
ทั้งที่ความจริงคือ “มี แต่คุณใช้ method ผิด”
บรรทัดที่ 3 — POST /createUser?name=Alice&password=123456
ถึงบรรทัดที่สำคัญที่สุดของทั้งบท · บรรทัดนี้ผิดกฎการตั้งชื่อเหมือนสองบรรทัดบน แต่ถ้าจับได้แค่นั้นแปลว่ายังพลาดเรื่องที่หนักที่สุดไป
สมมติเส้นนี้ขึ้น production แล้วมีคนสมัครสมาชิกวันละหมื่นคน
server ตอบ 200 ทุกครั้ง ไม่มี error สักตัว ไม่มีใครแฮ็กอะไรเลย
แล้ววันหนึ่งรหัสผ่านของผู้ใช้ทุกคนหลุดออกไป — หลุดทางไหน?
(คำใบ้: gin.Default() มีอะไรติดมาให้บ้าง)
หลุดทาง access log ของตัวเราเอง —
gin.Default() มี gin.Logger() ติดมาให้ตามค่า default
และ Logger ตัวนั้นพิมพ์ path พร้อม query string ทั้งก้อนลง log ทุก request
ผมยิงสองแบบติดกัน แล้ว grep หารหัสผ่านในไฟล์ log:
❌ password ทาง query
$ curl -X POST \
'http://127.0.0.1:18080/createUser?name=Alice&password=123456'
$ grep -n '123456' gin-access.log
7:[GIN] 2026/08/21 - 13:01:28 | 200 | 54.083µs |
127.0.0.1 | POST "/createUser?name=Alice&password=123456"
รหัสผ่านถูกเขียนลงไฟล์แบบ plaintext อ่านได้ตรง ๆ
✅ password ทาง body
$ curl -X POST 'http://127.0.0.1:18080/users' \
-H 'Content-Type: application/json' \
-d '{"name":"alice","password":"123456"}'
$ grep -n '123456' gin-access.log # ไม่มีบรรทัดใหม่เพิ่ม
8:[GIN] 2026/08/21 - 13:01:28 | 201 | 195.958µs |
127.0.0.1 | POST "/users"
log เห็นแค่ path เปล่า ๆ · ไม่มีร่องรอยของรหัสผ่านเลย
นับให้เห็นชัด ๆ ว่าทั้งไฟล์มีรหัสผ่านโผล่กี่ครั้ง:
$ grep -c '123456' gin-access.log
1
$ grep -n 'password' gin-access.log
7:[GIN] 2026/08/21 - 13:01:28 | 200 | 54.083µs | 127.0.0.1 | POST "/createUser?name=Alice&password=123456"
เจอครั้งเดียว คือบรรทัดของ /createUser เท่านั้น —
ทั้งที่ฝั่ง POST /users server ก็อ่านรหัสผ่านได้ครบเหมือนกัน
(response ตอบกลับมาว่า password_len:6) แต่ body ไม่ถูก log
เส้นที่ถูก:
POST /users
Content-Type: application/json
{"name":"Alice","password":"123456"}
// server ตอบ:
HTTP 201 Created
{"created":"1000","extracted_by":"c.ShouldBindJSON (request body)","name":"alice","password_len":6}
สังเกต 201 ไม่ใช่ 200 —
เส้นแบบเดิมตอบ 200 ส่วนเส้นที่ถูกตอบ 201 Created
ซึ่งเป็นรหัสของ “สร้างของใหม่ขึ้นมาสำเร็จ” (บทเรียน 34)
— และมันคือคำตอบของข้อ 6 ในส่วนที่ 1 พอดี
เพราะ query เป็นส่วนหนึ่งของ URL และ URL ถูกจดไว้ตลอดทาง —
access log ของ server (เห็นกับตาข้างบนแล้ว) · log ของ reverse proxy กับ load balancer
ที่ request วิ่งผ่านก่อนถึงเรา · ประวัติ browser · header Referer ·
และระบบรวม log ที่ทั้งทีมเปิดดูได้และเก็บย้อนหลังเป็นเดือน
HTTPS ช่วยไม่ได้เลยกับเรื่องนี้ — HTTPS กันคนดักกลางทาง
ไม่ได้กันปลายทางที่ตั้งใจจดเอง
กฎที่เอาไปใช้ได้ตลอดชีวิต: อะไรที่หลุดแล้วต้องรีเซ็ต ห้ามอยู่ใน URL
— password, token, OTP, เลขบัตร (บทเรียน 35 หัวข้อ 8)
และสังเกตด้วยว่าปัญหานี้ไม่หายไปเพราะรีวิวชื่อ endpoint —
ต่อให้เปลี่ยนชื่อเป็น POST /users?password=123456 ที่ชื่อสวยแล้ว
รหัสผ่านก็ยังลง log อยู่ดี · ต้องย้ายช่อง ไม่ใช่แค่เปลี่ยนชื่อ
ส่งพัสดุหนึ่งกล่อง มีของสองชั้นที่ไม่เหมือนกันเลย:
ข้อความที่เขียนบนหน้าซอง กับ ของที่อยู่ข้างในกล่อง
หน้าซองถูกอ่านและจดซ้ำตลอดทาง — ร้านรับฝาก ศูนย์คัดแยก คนขับรถ แอปติดตามพัสดุ
ทุกจุดบันทึกเอาไว้ และย้อนดูได้อีกเป็นเดือน
ส่วนของที่อยู่ในกล่อง ตามปกติไม่มีใครแกะออกมาจด — ยกเว้นจุดที่ตั้งใจแกะดูจริง ๆ
?password=123456 คือการเขียนรหัสผ่านไว้บนหน้าซอง ·
ส่งทาง body คือการใส่ไว้ในกล่อง
นี่คือเหตุผลที่ grep หารหัสผ่านทั้งไฟล์ log แล้วเจอแค่ครั้งเดียว —
ครั้งที่มันถูกเขียนไว้หน้าซองเท่านั้น ส่วนอันที่อยู่ในกล่องไม่โผล่ใน access log
ที่ gin.Default() เปิดให้ดีฟอลต์
แต่ “ไม่โผล่ที่นี่” ไม่เท่ากับ “ไม่มีที่ไหนจดเลย” —
ถ้าทีมเปิด middleware ที่ log body ไว้ดีบัก หรือมี WAF / APM / proxy ที่ถอด TLS อยู่ข้างหน้า
body ก็ถูกจดได้เต็ม ๆ เหมือนกัน · ส่ง body จึงปลอดภัยกว่า ไม่ใช่ปลอดภัยสมบูรณ์
(รหัสผ่านยังต้องแฮชเสมอ)
ที่ไม่เหมือนกัน: หน้าซองพัสดุใครเดินผ่านก็อ่านได้ แต่ URL ที่วิ่งบน HTTPS
ถูกเข้ารหัสระหว่างทางจริง ๆ คนดักกลางทางอ่านไม่ออก
ปัญหาจึงไม่ได้อยู่ที่ “ระหว่างทาง” แต่อยู่ที่ปลายทางที่ตั้งใจจด URL เก็บไว้เอง
— อย่าเผลอสรุปว่า “ใส่ HTTPS แล้วจบ” เพราะมันแก้คนละปัญหากัน
- access log
- ไฟล์ที่ server จดไว้ว่า “ใครยิงอะไรมาเมื่อไหร่ ได้ status อะไรกลับไป” ·
gin.Default()เปิดให้อัตโนมัติ · คำถามที่ควรถามทุกครั้งก่อนใส่อะไรลง URL คือ “ค่านี้จะไปโผล่ใน access log ไหม” - plaintext
- “ข้อความเปล่า ๆ อ่านได้ตรง ๆ” ไม่ได้ถูกเข้ารหัสหรือแฮชไว้ · รหัสผ่านที่โผล่ในไฟล์ log คือ plaintext
- secret
- คำรวมของ “อะไรก็ตามที่หลุดแล้วต้องรีเซ็ตทันที” — password, token, OTP, API key · กฎสั้น ๆ ที่พูดกับทีมได้เลย: “secret ห้ามอยู่ใน URL” ประโยคเดียวจบ ไม่ต้องอธิบายยาว
- DTO (Data Transfer Object)
- struct ที่ทำหน้าที่เป็น “แบบฟอร์ม” ของข้อมูลขาเข้าและขาออก · ที่ต้องแยก request DTO กับ response DTO ก็เพราะ ของที่รับเข้ามาไม่จำเป็นต้องส่งกลับออกไปทั้งหมด (รับ password เข้ามา แต่ไม่คืน password ออกไป)
🔍 เกร็ดเล็ก ๆ ที่ต้องพูด เพราะเดี๋ยวจะงงตอนเทียบกับสไลด์ —
URL ต้นทางบนสไลด์ส่ง name=Alice (A ใหญ่) แต่เฉลยบนสไลด์เขียน body เป็น
{"name":"alice"} (a เล็ก)
ไม่ใช่บั๊ก และไม่ใช่ว่า REST หรือ JSON binding แปลงเป็นตัวพิมพ์เล็กให้ —
มันไม่ได้แปลงอะไรให้เลย เป็นแค่คนพิมพ์สไลด์พิมพ์ไม่ตรงกันเฉย ๆ
(ผมยิงจริงตามเฉลยเป๊ะ ๆ ได้ {"created":"1000","name":"alice",…} คือ
ส่งอะไรไปก็ได้อันนั้นกลับมา)
บรรทัดที่ 4 — GET /getPaidOrders
บรรทัดนี้ดูไม่มีพิษภัยที่สุดในสี่บรรทัด · ไม่มีรหัสผ่าน ไม่มีการลบอะไร
แล้วมันจะพังตอนไหน?
คำใบ้: ลองนึกว่าอาทิตย์หน้า PM ขอ “ดู order ที่ยกเลิก” และอาทิตย์ถัดไปขอ
“ดู order ที่จ่ายแล้วของเดือนนี้”
❌ ของเดิม
GET /getPaidOrders
GET /getPendingOrders ← อาทิตย์หน้า
GET /getCancelledOrders ← อาทิตย์ถัดไป
GET /getPaidOrdersThisMonth ← แล้วก็…
ทุกตัวกรองใหม่ = route ใหม่ + handler ใหม่ + เอกสารใหม่
(handler = ฟังก์ชันที่ทำงานเมื่อมีคนยิงเข้ามาที่เส้นนั้น)
✅ ที่ควรเป็น
GET /orders?status=paid
GET /orders?status=pending
GET /orders?status=cancelled
GET /orders?status=paid&from=2026-08-01
เส้นเดิมตัวเดียว — เพิ่ม/ตัด/สลับลำดับตัวกรองได้โดยไม่แตะ routing
ผิดกฎข้อ 1 (verb) · กฎข้อ 7 (action ใน URI) ·
และตัวหลักคือ กฎข้อ 6 — filter ต้องใช้ query
คำว่า Paid ที่ฝังอยู่ในชื่อ endpoint คือค่าของตัวกรอง
ที่ถูกจับแช่แข็งไว้ในชื่อเส้น พอค่าเปลี่ยน ชื่อเส้นก็ต้องเปลี่ยนตาม
ยิงจริงทั้งสองเส้น ได้ข้อมูลชุดเดียวกัน (ข้อมูลทดสอบมี 4 order — paid 2, pending 1, cancelled 1):
=== BAD GET /getPaidOrders ===
{"orders":[{"id":"o1","status":"paid"},{"id":"o3","status":"paid"}]}
HTTP 200
=== GOOD GET /orders?status=paid ===
{"extracted_by":"c.Query(\"status\")","filter_status":"paid",
"orders":[{"id":"o1","status":"paid"},{"id":"o3","status":"paid"}]}
HTTP 200
o1 กับ o3 เท่ากันทั้งสองเส้น — อีกครั้งที่
ความต่างไม่ได้อยู่ที่ผลลัพธ์วันนี้ แต่อยู่ที่ต้นทุนของวันพรุ่งนี้
c.Query — ลืมส่งตัวกรองแล้วไม่มีใครเตือน
ย้ายไปใช้ query แล้วได้ความยืดหยุ่นมาก็จริง แต่ได้กับดักนี้แถมมาด้วย:
$ curl -w ' | HTTP %{http_code}\n' 'http://127.0.0.1:18080/orders' # ลืมใส่ ?status
{"extracted_by":"c.Query(\"status\")","filter_status":"",
"orders":[{"id":"o1","status":"paid"},{"id":"o2","status":"pending"},
{"id":"o3","status":"paid"},{"id":"o4","status":"cancelled"}]} | HTTP 200
c.Query("status") กับ param ที่ไม่ได้ส่งมา คืน empty string เงียบ ๆ
ไม่ error ไม่ 400 · ผลคือ frontend ที่ลืมแนบ ?status=paid ไปหนึ่งครั้ง
จะได้ order ทั้งหมดรวมที่ยกเลิกแล้ว กลับไปแสดงบนหน้าจอ โดยที่ status ยังเป็น 200
ทางแก้: ถ้าตัวกรองนั้นจำเป็น ต้องเช็คเองว่าว่างหรือเปล่าแล้วตอบ
400 · ถ้าไม่จำเป็นก็ต้องตั้งใจว่า “ว่าง = เอาทั้งหมด” และเขียนไว้ในเอกสาร
ไม่ใช่ปล่อยให้เป็นอุบัติเหตุ
สรุปส่วนที่ 2 เป็นตารางเดียว
| ของเดิม | ควรเป็น | ผิดกฎข้อ | ผลจริงที่วัดได้ |
|---|---|---|---|
GET /getUser?id=123 |
GET /users/123 |
1 (verb) · 7 (action ใน URI) · 5 (ตัวระบุต้องอยู่ path) | ผลเหมือนกันทั้งคู่ 200 — ต่างที่คนอื่นเดาชื่อเส้นถัดไปไม่ได้ |
POST /deleteUser?id=123 |
DELETE /users/123 |
1 · 7 · ใช้ POST ทำงานลบ | ยิงซ้ำ 3 ครั้งได้ 200/404/404 เท่ากันทั้งคู่ — ความต่างคือสัญญา idempotent ที่ retry layer ใช้ |
POST /createUser?name=…&password=… |
POST /users + body |
1 · 7 · ความลับอยู่ใน URL | grep -c '123456' gin-access.log → 1 (มาจากเส้นเดิมเท่านั้น) |
GET /getPaidOrders |
GET /orders?status=paid |
1 · 7 · 6 (filter ต้องใช้ query) | ได้ o1,o3 เหมือนกัน — ต่างที่ตัวกรองตัวถัดไปต้องสร้าง route ใหม่หรือไม่ |
เส้นเรื่องเดียวที่ร้อยทั้งสี่บรรทัด — ไม่มีบรรทัดไหน “ทำงานไม่ได้”
สามบรรทัดแรกเสียตรงความเดาได้และความหมายของ method ·
บรรทัดที่สามเสียตรงความปลอดภัย · บรรทัดที่สี่เสียตรงความขยายได้
ซึ่งทั้งสามอย่างนี้ compiler ไม่มีทางจับได้ และเทสที่เขียนแบบธรรมดาก็ไม่จับ
เพราะเทสจะยิงเส้นเดิมแล้วได้ 200 ตามที่คาดไว้ทุกครั้ง
มีแค่คนอ่านโค้ด เท่านั้นที่จับได้ — นั่นแหละคือเหตุผลที่ code review มีอยู่
ส่วนที่ 3 · จับคู่ annotation ของ Swagger (สไลด์หน้า 75–76)
ส่วนสุดท้ายกลับมาที่ บทเรียน 41 —
โจทย์ 6 ข้อ ตัวเลือก 6 ตัว จับคู่ให้ครบ
เตือนไว้ก่อนอ่าน: ข้อ 6 มีปัญหา ผมทดสอบแล้วเฉลยบนสไลด์ไม่ตรงกับผลจริง
เดี๋ยวจะแยกไปคุยกันหลังเฉลยหลัก
นึกถึงโกดังที่เต็มไปด้วยกล่อง แล้วมีคนแปะป้ายกระดาษไว้ข้างกล่องแต่ละใบ
เขียนว่า “กล่องนี้ใส่อะไร ใครมีสิทธิ์หยิบ ถ้าหาไม่เจอให้ตอบว่าอะไร”
ทุกสิ้นเดือนมีพนักงานคนหนึ่งเดินทั้งโกดัง
อ่านป้ายทุกใบแล้วพิมพ์ออกมาเป็นสารบัญหนึ่งเล่ม
ให้คนนอกเปิดดูได้โดยไม่ต้องเดินเข้าโกดังเอง
· ป้ายบนกล่อง = คอมเมนต์ที่เราเขียนไว้เหนือฟังก์ชันในไฟล์ .go
· พนักงานที่เดินอ่านป้าย = คำสั่ง swag init ที่เราพิมพ์ในเทอร์มินัล
· สารบัญที่ได้ = ไฟล์ swagger.json ที่ Swagger UI เอาไปแสดงเป็นหน้าเว็บ
พอมองแบบนี้ก็เห็นทันทีว่าทำไม --parseInternal ไม่ใช่พวกเดียวกับอีก 5 ตัว —
มันไม่ใช่ป้าย มันคือคำสั่งที่บอกพนักงานว่าให้เดินตรงไหนบ้าง
ที่ไม่เหมือนกัน: พนักงานคนนี้เปิดกล่องดูอยู่บ้าง —
ตอนสร้าง definitions มันไปอ่าน struct จริงในโค้ดพร้อม
json tag ไม่ได้ลอกจากคอมเมนต์ (แก้ชื่อฟิลด์ในโค้ด เอกสารก็เปลี่ยนตาม)
แต่สิ่งที่มันไม่เคยตรวจเลยคือ @Router ที่เราเขียน
ตรงกับ r.GET / r.POST ที่ลงทะเบียนไว้จริงหรือเปล่า —
เขียน @Router ผิดเส้น เอกสารก็ผิดเงียบ ๆ ไม่มี error สักบรรทัด
เก็บภาพนี้ไว้ เดี๋ยวท้ายส่วนนี้จะได้เห็นตัวอย่างจริง
| # | สิ่งที่ต้องการ | ตัวเลือก |
|---|---|---|
| 1 | ตั้งชื่อ API ว่า “Wongnok API” | A. @Param id path string true "..." |
| 2 | รับ ID จาก URL /users/{id} | B. @Router /users [post] |
| 3 | บอกว่า error ใช้ struct จาก httputil | C. @title |
| 4 | กำหนดว่า endpoint นี้ต้องมี token | D. @Failure 404 {object} httputil.ErrorResponse |
| 5 | ผูก handler เข้ากับ POST /users | E. --parseInternal |
| 6 | ให้ swag สแกนโค้ดใน internal/ ด้วย | F. @Security BearerAuth |
จับคู่ให้ครบ 6 คู่ · แล้วตอบเพิ่มอีกข้อว่า ตัวเลือกไหนไม่ใช่ annotation เลย และมันต่างจากอีก 5 ตัวยังไง
เฉลย: 1→C · 2→A · 3→D · 4→F · 5→B · 6→E (ตามสไลด์ — แต่ข้อ 6 มีปัญหา อ่านต่อข้างล่าง)
ผมสร้างโปรเจกต์เล็ก ๆ ที่มี main.go ที่ root และ handler
กับ httputil อยู่ใน internal/ แล้วรัน swag init
จากนั้นเปิด docs/swagger.json ดูทีละจุดด้วย jq
(เครื่องมือ command line สำหรับดึงค่าจาก JSON) — ว่า annotation แต่ละตัวไปโผล่ตรงไหน:
ข้อ 1 → C. @title — ไปอยู่ที่ .info.title คือระดับเอกสารทั้งไฟล์
$ jq -c '.info.title' docs/swagger.json
"Wongnok API"
ข้อ 2 → A. @Param id path string true "User ID" — แตกเป็น 5 ฟิลด์ใต้ operation นั้น
$ jq -c '.paths."/users/{id}".get.parameters' docs/swagger.json
[{"type":"string","description":"User ID","name":"id","in":"path","required":true}]
ข้อ 3 → D. @Failure 404 {object} httputil.ErrorResponse — ทำสองอย่างพร้อมกัน
$ jq -c '.paths."/users/{id}".get.responses."404"' docs/swagger.json
{"description":"Not Found","schema":{"$ref":"#/definitions/httputil.ErrorResponse"}}
$ jq -c '.definitions|keys' docs/swagger.json
["handler.User","httputil.ErrorResponse"]
คือทั้งใส่ response 404 ใต้ endpoint นั้น และสร้าง schema เข้า .definitions
แล้วอ้างกลับด้วย $ref
สังเกตคำว่า "Not Found" — อันนั้น swag เติมให้เองจากเลข 404
ไม่ได้มาจากที่เราเขียน · และชื่อ key เป็นรูปแบบ แพ็กเกจ.ชื่อ struct ไม่ใช่ import path เต็ม
ข้อ 4 → F. @Security BearerAuth
$ jq -c '.paths."/users/{id}".get.security' docs/swagger.json
[{"BearerAuth":[]}]
$ jq -c '.securityDefinitions' docs/swagger.json
{"BearerAuth":{"type":"apiKey","name":"Authorization","in":"header"}}
ข้อ 5 → B. @Router /users [post] — กลายเป็น key ของ path + key ของ method
$ jq -c '.paths | to_entries | map({path:.key, methods:(.value|keys)})' docs/swagger.json
[{"path":"/users","methods":["post"]},{"path":"/users/{id}","methods":["get"]}]
คำตอบของคำถามพ่วง: ตัวเลือก E. --parseInternal
คือตัวเดียวที่ไม่ใช่ annotation — อีก 5 ตัวเป็นคอมเมนต์ในไฟล์ .go
ส่วนตัวนี้เป็น flag ที่พิมพ์ต่อท้ายคำสั่ง swag init ในเทอร์มินัล
คนละที่กันคนละเวลากันเลย
ซึ่งพอมันเป็นของคนละชนิด มันก็เลยเป็นข้อที่ตรวจง่ายที่สุดว่าเฉลยถูกหรือเปล่า —
แค่รันคำสั่งดูสองรอบก็รู้ และผมรันแล้ว…
ข้อ 6 — ตรวจแล้วเฉลยสไลด์ไม่ผ่าน
เรื่องนี้ บทเรียน 41 หัวข้อ
“--parseDependency --parseInternal — ผลจริงต่างจากสไลด์” พิสูจน์ไว้แล้ว
บนโปรเจกต์อีกโครงหนึ่ง · ที่เอามาเล่าซ้ำตรงนี้เพราะสไลด์ Post
เอาความเข้าใจผิดอันเดียวกันมาทำเป็นเฉลยของแบบฝึกหัด —
ถ้าอ่านบท 41 มาแล้วจะข้ามไปหัวข้อถัดไปได้เลย
ก่อนอื่นอ่านข้อความช่วยเหลือของ swag เองก่อน — นี่คือต้นเหตุที่ทุกคนเข้าใจตรงกันแบบนี้:
$ swag init --help # swag version v1.16.4
--parseInternal Parse go files in internal packages, disabled by default (default: false)
อ่านผิวเผินก็เหมือนตรงกับโจทย์เป๊ะ (“ให้สแกนโค้ดใน internal/”)
แต่ก่อนจะเชื่อ ให้ตอบก่อนว่า: จะพิสูจน์ยังไงว่ามันจริง?
แล้วทายว่าถ้าเอาโปรเจกต์ที่มี handler กับ httputil อยู่ใน
internal/ มารัน swag init โดยไม่ใส่ flag นี้เลย —
เอกสารที่ได้จะมี endpoint ทั้งสองเส้นไหม
ไม่ใส่ flag ก็สแกนครบตั้งแต่แรกแล้ว:
$ rm -rf docs && swag init -g main.go -d ./ # ไม่มี --parseInternal
2026/08/21 13:00:44 Generate swagger docs....
2026/08/21 13:00:44 Generate general API Info, search dir:./
2026/08/21 13:00:44 Generating handler.User
2026/08/21 13:00:44 Generating httputil.ErrorResponse
2026/08/21 13:00:44 create docs.go at docs/docs.go
2026/08/21 13:00:44 create swagger.json at docs/swagger.json
2026/08/21 13:00:44 create swagger.yaml at docs/swagger.yaml
handler.User กับ httputil.ErrorResponse อยู่ใน internal/
ทั้งคู่ และออกมาครบทั้ง /users และ /users/{id}
เทียบไบต์ต่อไบต์ระหว่างมี flag กับไม่มี:
$ swag init -g main.go -d ./ # ไม่ใส่ flag
$ cp docs/swagger.json run1_noflag.json
$ swag init -g main.go -d ./ --parseInternal
$ cp docs/swagger.json run2_withflag.json
$ md5 -q run1_noflag.json run2_withflag.json
fd317e5a6fe6f8a6eafea68d2a4fc7f2
fd317e5a6fe6f8a6eafea68d2a4fc7f2 # ← ค่าเดียวกันเป๊ะ
ทำไมใช้ md5 ไม่ใช้ diff? — diff เวลาไฟล์เหมือนกัน
มันไม่พิมพ์อะไรออกมาเลย ซึ่งหน้าตาแยกไม่ออกจาก “คำสั่งไม่ได้รัน”
md5 พิมพ์ค่าออกมาให้เห็นทั้งสองบรรทัด เอาไปเทียบกับของคุณเองได้ด้วย
ลองครบทั้ง 4 ชุด flag บนโปรเจกต์นี้ — ไม่ใส่อะไรเลย · --parseInternal ·
--parseDependency · ใส่ทั้งคู่ — ได้ md5 ค่าเดียวกันหมดทั้ง 4 รอบ
และ definitions เท่ากันที่ 2 ตัว
(handler.User · httputil.ErrorResponse) ทุกรอบ
ทุกชุด flag ที่ผมลอง --parseInternal ไม่เคยทำให้ผลต่างเลยสักครั้ง
โปรเจกต์ตัวอย่างใน บทเรียน 41 โครงต่างจากนี้เล็กน้อย
และที่นั่น --parseDependency ทำให้ชื่อ definition เปลี่ยนเป็น
internal_user.* — แต่ --parseInternal ก็ยังไม่มีผลอยู่ดี
ทั้งสองโปรเจกต์ · แปลว่าผลของ --parseDependency ขึ้นกับโครงโปรเจกต์
ส่วน --parseInternal ไม่ขึ้นกับอะไรเลยเพราะมันไม่ได้แตะเส้นทางนี้ตั้งแต่ต้น
แล้วมันทำอะไรจริง ๆ? ไปดูซอร์สของ swaggo/swag v1.16.6 ตรง ๆ:
$ grep -rn "ParseInternal" . --include "*.go" | grep -v _test.go
golist.go:51: ignoreInternal := pkg.Goroot && !parser.ParseInternal
parser.go:134: // ParseInternal whether swag should parse internal packages
parser.go:135: ParseInternal bool
parser.go:1868: ignoreInternal := pkg.Internal && !parser.ParseInternal
cmd/swag/main.go:259: ParseInternal: ctx.Bool(parseInternalFlag),
gen/gen.go:114: // ParseInternal whether swag should parse internal packages
gen/gen.go:115: ParseInternal bool
gen/gen.go:220: p.ParseInternal = config.ParseInternal
ได้มา 8 บรรทัด แต่ 6 บรรทัดเป็นแค่การประกาศ field และการส่งค่าต่อกันไปมา
(main.go อ่านจาก flag → gen.go ส่งเข้า parser) ·
ที่เหลืออีก 2 บรรทัดที่ไฮไลต์ไว้ คือที่เดียวที่เอาค่านี้ไปตัดสินใจอะไรจริง ๆ:
จุดที่ถูกใช้จริง มีแค่สองที่ และทั้งคู่อยู่ในฟังก์ชันตระกูล
getAllGoFileInfoFromDeps* — คือเส้นทางอ่าน dependency เท่านั้น
(pkg.Goroot = แพ็กเกจของ Go เอง · pkg.Internal = internal ของไลบรารีที่เราพึ่งพา)
แปลว่า flag นี้พูดถึง internal/ ของคนอื่น ไม่ใช่ของโปรเจกต์เรา
และต้องใช้คู่กับ --parseDependency ถึงจะมีความหมาย
ส่วนตัวที่เดินไฟล์ในโปรเจกต์เราจริง ๆ ข้ามอะไรบ้าง — อยู่ในฟังก์ชัน walkWith:
if !parseVendor && f.Name() == "vendor" || // ignore "vendor"
f.Name() == "docs" || // exclude docs
len(f.Name()) > 1 && f.Name()[0] == '.' && f.Name() != ".." {
return filepath.SkipDir
}
ข้ามแค่ vendor · docs · โฟลเดอร์ที่ขึ้นต้นด้วยจุด ·
และรายการใน --exclude — ไม่มีคำว่า internal อยู่ในเงื่อนไขเลย
นี่คือหลักฐานตรง ๆ ว่า ./internal ไม่เคยถูกข้าม
แล้วอะไรทำให้ internal/ หายไปจากเอกสารได้จริง? คือ --exclude ต่างหาก:
$ swag init -g main.go -d ./ --exclude ./internal
$ jq -c '.paths' docs/swagger.json
{}
$ jq -c '.definitions' docs/swagger.json
null
สรุปข้อ 6: คำตอบที่ถูกคือ “ไม่ต้องใส่ flag อะไรเลย — swag
สแกน internal/ ของโปรเจกต์เราให้อยู่แล้ว” ·
ถ้าจะจับคู่กับ flag จริง ๆ ตัวที่คุมเรื่องนี้คือ -d (ไดเรกทอรีที่ให้สแกน)
และ --exclude ไม่ใช่ --parseInternal
และสาเหตุที่คนเข้าใจผิดกันหมดก็ไม่ใช่ความผิดของใคร — ข้อความใน
--help ของ swag เองกำกวม มันไม่ได้บอกว่าหมายถึง
internal ของ dependency
@Security BearerAuth แค่ “ผูก” endpoint เข้ากับ scheme ชื่อ BearerAuth
เท่านั้น · ตัว scheme จริงมาจากคนละ annotation ที่ main.go:
@securityDefinitions.apikey BearerAuth + @in header + @name Authorization
ผมลองลบสามบรรทัดนั้นทิ้งแล้ว gen ใหม่ — swag ไม่ error เลย ผ่านปกติ:
$ swag init -g main.go -d ./ -q && jq -c '{securityDefinitions:.securityDefinitions, opSecurity:.paths."/users/{id}".get.security}' docs/swagger.json
{"securityDefinitions":null,"opSecurity":[{"BearerAuth":[]}]}
ได้เอกสารที่ endpoint อ้าง scheme ที่ไม่มีอยู่จริง →
ปุ่ม Authorize ใน Swagger UI หายไป ใส่ token ไม่ได้ ทดลองยิงจริงไม่ได้
เป็นกับดักตระกูลเดียวกับ บทเรียน 41 หัวข้อ 10
ทั้งหมด: เอกสารผิดแบบไม่มีใครเตือน เพราะ swag
ไม่เคยเช็คว่าสิ่งที่เราเขียนตรงกับความจริงหรือเปล่า
เช่นเดียวกับ @Router ที่ไม่ถูกตรวจกับ route จริงของ Gin เลย —
เขียน path หรือ method ผิด เอกสารก็ผิดเงียบ ๆ ไม่มี warning สักบรรทัด
- annotation
- คอมเมนต์ที่เขียนไว้ให้เครื่องมืออ่าน ไม่ใช่ให้คนอ่านอย่างเดียว ·
ของ swaggo คือบรรทัดที่ขึ้นต้นด้วย
@เหนือ handler · เวลาถามให้พูดว่า “annotation ตัวไหนที่ทำให้ฟิลด์นี้โผล่ใน swagger.json” จะได้คำตอบตรงกว่าถามว่า “แก้ตรงไหน” - flag (CLI flag)
- ตัวเลือกที่พิมพ์ต่อท้ายคำสั่งในเทอร์มินัล เช่น
--parseInternal· อยู่คนละที่กับ annotation และคนละเวลาด้วย — annotation อยู่ในไฟล์ ส่วน flag อยู่ในคำสั่ง - schema / definition
- “หน้าตาของก้อนข้อมูล” ที่เอกสารบรรยายไว้ว่ามีฟิลด์อะไรบ้าง ชนิดอะไร ·
ใน
swagger.jsonอยู่ใต้ key ชื่อdefinitions· อันนี้คือรูปแบบ Swagger 2.0 ที่ swaggo สร้าง — ถ้าเป็น OpenAPI 3 จะย้ายไปอยู่ใต้components.schemasแทน - $ref
- ตัวชี้ไปหา schema ที่นิยามไว้ที่อื่น — แทนที่จะเขียนหน้าตาของ error ซ้ำในทุก endpoint ก็นิยามครั้งเดียวแล้วชี้มาหา
- generated (ไฟล์ที่ gen ออกมา)
- ไฟล์ที่เครื่องสร้างให้ ไม่ใช่คนพิมพ์เอง เช่นทุกอย่างในโฟลเดอร์
docs/· ประโยคที่ต้องจำไว้: “อย่าไปแก้ไฟล์ที่ generated เพราะ gen ใหม่ทีเดียวก็หายหมด ต้องไปแก้ที่ต้นทาง”
ทดสอบความเข้าใจ
1. ทีมส่ง POST /createUser?name=Alice&password=123456 ขึ้น production · ไม่มี error ไม่มีใครแฮ็ก แล้วรหัสผ่านหลุดได้ยังไง?
ผมยิงจริงแล้ว grep หารหัสผ่านในไฟล์ log ได้บรรทัดนี้:
[GIN] … | 200 | … | POST "/createUser?name=Alice&password=123456"
ส่วน POST /users ที่ส่งรหัสผ่านตัวเดียวกันทาง body ได้ log แค่
POST "/users" เปล่า ๆ · grep -c '123456' ทั้งไฟล์ได้ 1 ครั้ง
คือบรรทัดของ /createUser เท่านั้น
ตัวเลือกที่ 1 กับ 2 เป็นความเข้าใจผิดคู่แฝดกัน: HTTPS เข้ารหัสทั้ง query และ body
เหมือนกันหมดตอนวิ่งบนเน็ต — ปัญหาไม่ได้อยู่ระหว่างทาง แต่อยู่ที่ปลายทางที่ตั้งใจจด URL เอาไว้
ทั้ง access log ของ server, log ของ proxy/load balancer, ประวัติ browser และ header Referer
ตัวเลือกที่ 4 ผิดเพราะ *gin.Context เกิดใหม่ต่อหนึ่ง request ไม่ข้ามกัน
และจำไว้ว่าแค่เปลี่ยนชื่อเส้นเป็น POST /users?password=… ก็ยังลง log อยู่ดี — ต้องย้ายช่อง ไม่ใช่แค่เปลี่ยนชื่อ
2. ยิง DELETE /users/123 ซ้ำ 3 ครั้ง เทียบกับ POST /deleteUser?id=123 ซ้ำ 3 ครั้ง — status code ที่ได้ต่างกันไหม?
ผมยิงจริงทั้งสองแบบ ๆ ละ 3 ครั้ง ได้ 200 → 404 → 404 เหมือนกันทุกตัวอักษร
ทั้ง body และ state ปลายทางก็เหมือนกัน
ความต่างของ DELETE กับ POST อยู่ที่ สัญญาที่ผูกกับตัว method
ตาม HTTP spec (RFC 9110) ซึ่ง client · proxy · ชั้น retry เอาไปใช้ตัดสินใจว่า
“ถ้าเน็ตกระตุกแล้วไม่รู้ว่าถึงหรือเปล่า จะยิงซ้ำได้ไหม”
ตัวเลือกที่ 1 กับ 3 เดาว่า server ต้องตอบต่างกัน — ไม่จริง ผมรันแล้ว ·
ตัวเลือกที่ 4 เข้าใจคำว่า idempotent ผิด: มันแปลว่า “ผลลัพธ์ปลายทางเท่าเดิม”
ไม่ได้แปลว่า “status code เท่าเดิม”
เสริมอีกจุด: 204 ในตัวเลือกที่ 1 ไม่ใช่คำตอบผิดในเชิงออกแบบเลย —
หลาย API เลือกให้ DELETE ตอบ 204 ทุกครั้ง (บทเรียน 36 หัวข้อ 7)
แต่นั่นเป็นสิ่งที่เราต้องเขียนโค้ดให้เป็นเอง ไม่ใช่สิ่งที่ได้มาฟรีจากการเปลี่ยน method
3. เปลี่ยนจาก GET /getPaidOrders มาเป็น GET /orders?status=paid เรียบร้อย แล้ว frontend ลืมแนบ ?status ไปหนึ่งครั้ง — เกิดอะไรขึ้น?
ผมยิง GET /orders โดยไม่ใส่ query จริง ๆ ได้
{"filter_status":"", "orders":[o1 paid, o2 pending, o3 paid, o4 cancelled]} | HTTP 200
— ครบทั้ง 4 รายการรวม cancelled
c.Query ไม่เคยบ่นเรื่อง param ที่ไม่ได้ส่งมา มันคืน empty string แล้วเดินต่อ
ตัวเลือกที่ 1 ผิดเพราะ Gin ไม่รู้จัก “query ที่จำเป็น” เลย เว้นแต่เราจะ bind เป็น struct
พร้อม binding:"required" เอง (บทเรียน 39) ·
ตัวเลือกที่ 2 ผิดเพราะ query ไม่ได้เป็นส่วนหนึ่งของการ match route — /orders
กับ /orders?status=paid เข้า handler ตัวเดียวกัน ·
ตัวเลือกที่ 3 ผิดเพราะ c.Query คืน string ไม่ใช่ pointer จึงไม่มี nil
อาการนี้อันตรายตรงที่ status ยังเป็น 200 — ไม่มีอะไรฟ้อง มีแต่ผู้ใช้ที่เห็นข้อมูลผิด
4. โปรเจกต์วาง handler กับ httputil ไว้ใน internal/ · ถ้ารัน swag init -g main.go -d ./ โดยไม่ใส่ --parseInternal จะได้อะไร?
ผมรันเทียบไบต์ต่อไบต์: md5 ของ swagger.json ที่มีและไม่มี --parseInternal
ออกมาเป็นค่าเดียวกันทุกไบต์ — ทั้งแบบเดี่ยวและแบบคู่กับ --parseDependency
และ log ของ swag ตอนไม่ใส่ flag ก็ขึ้น Generating handler.User
กับ Generating httputil.ErrorResponse ครบทั้งคู่ ทั้งที่สองตัวนั้นอยู่ใน internal/
ดูจากซอร์สของ swag v1.16.6 ยิ่งชัด: ParseInternal ถูกอ่านแค่สองจุด
(pkg.Goroot และ pkg.Internal) ซึ่งอยู่ในเส้นทางอ่าน dependency
ทั้งคู่ — มันหมายถึง internal ของไลบรารีที่เราพึ่งพา ไม่ใช่ของเรา
ส่วนตัวเดินไฟล์ในโปรเจกต์เรา (walkWith) ข้ามแค่ vendor, docs,
โฟลเดอร์ที่ขึ้นต้นด้วยจุด และ --exclude · ไม่มีเงื่อนไขคำว่า internal อยู่เลย
ตัวเลือกที่ 2 คือผลของ --exclude ./internal ต่างหาก (ผมรันแล้วได้ paths = {}
และ definitions = null) · ตัวเลือกที่ 3 กับ 4 ไม่เกิดขึ้นเลยจากการรันจริง
บทเรียนที่ใหญ่กว่าคำตอบ: ข้อความใน --help ก็กำกวมได้
วิธีเดียวที่เชื่อถือได้คือรันสองรอบแล้ว diff
บทนี้ทั้งบทคือการอ่านผลที่ผมรันไว้ให้ · ภารกิจนี้คือการรันเอง เพราะสิ่งที่ทำให้คุณเถียงกับ code review ของคนอื่นได้ ไม่ใช่การจำกฎ 7 ข้อ แต่คือหลักฐานที่คุณผลิตเองได้
- ตั้งโปรเจกต์: โฟลเดอร์ใหม่ ·
go mod init apidet·go get github.com/gin-gonic/gin·main.goที่มีr := gin.Default()แล้วr.Run(":18080")(บทเรียน 38) - ลงทะเบียนทั้งแปดเส้นในตัวเดียวกัน — สี่เส้นแบบแย่จากสไลด์
และสี่เส้นที่รีวิวแล้ว · เก็บข้อมูลไว้ใน
mapหรือsliceในหน่วยความจำพอ ไม่ต้องมี database
เพิ่มGET /__routesที่คืนr.Routes()ออกมาเป็น JSON — จะได้ยืนยันด้วยตาว่าลงครบจริง ไม่ใช่คิดไปเอง - พิสูจน์เรื่องรหัสผ่าน (ข้อสำคัญที่สุด): เปลี่ยน
gin.Default()ให้เขียน log ลงไฟล์gin-access.log· ยิงสองแบบ:POST /createUser?password=123456กับPOST /usersที่ส่งรหัสผ่านใน body
แล้วสั่งgrep -c '123456' gin-access.log
ต้องได้1— ถ้าได้2แปลว่าเผลอ log body เอง ให้ไปหาว่าโค้ดตรงไหนทำ - พิสูจน์เรื่อง idempotency ให้ตัวเองเห็นว่า “พิสูจน์ไม่ได้”:
ยิง
DELETE /users/123สามครั้ง และPOST /deleteUser?id=123สามครั้ง เก็บ status code ทุกครั้ง
ต้องได้เหมือนกันทั้งสองชุด — แล้วเขียนสองสามบรรทัดตอบตัวเองว่า ถ้าตัวเลขเท่ากัน แล้วเราเปลี่ยนมาใช้DELETEไปเพื่ออะไร - ลองของแถม: ยิง
POST /users/123(ผิด method) แล้วดู status
จากนั้นตั้งr.HandleMethodNotAllowed = trueแล้วยิงซ้ำ
ตอบให้ได้ว่าเลขเปลี่ยนจากอะไรเป็นอะไร และทำไมค่า default ถึงเป็นแบบแรก - ฝั่ง Swagger: ย้าย handler ของคุณเข้า
internal/แล้วรันswag initสองรอบ — รอบหนึ่งใส่--parseInternalอีกรอบไม่ใส่ — แล้วdiffไฟล์swagger.jsonทั้งสอง
จากนั้นลบ@securityDefinitionsที่main.goทิ้ง แล้ว gen ใหม่ · เปิด Swagger UI ดูว่าปุ่ม Authorize ยังอยู่ไหม (บทเรียน 41) - ท้าทาย: เขียนเทสที่จับการถอยหลังได้ —
เทสที่ fail ทันทีถ้ามีคนเผลอเพิ่ม route ชื่อ
/getSomethingกลับเข้ามา
ใบ้:r.Routes()คืน slice ที่วนอ่านได้ · เขียน assert ว่า ทุกpathต้องไม่มีตัวพิมพ์ใหญ่และต้องไม่ขึ้นต้นด้วยคำกริยาในลิสต์ที่คุณกำหนด
(ต่อยอดจาก บทเรียน 16)
เพราะการเลือก method ไม่ได้เปลี่ยนสิ่งที่ server ของคุณทำ — มันเปลี่ยนสิ่งที่คนอื่นกล้าทำกับ server ของคุณ
ลองนึกภาพ: มือถือของผู้ใช้ยิง request ลบไป แล้วเน็ตหลุดตอนกำลังรอคำตอบ
ตอนนี้ client ไม่รู้เลยว่า request ถึง server หรือยัง — จะยิงซ้ำดีไหม?
ถ้าเป็น DELETE คำตอบคือ ยิงซ้ำได้เลย
เพราะ HTTP spec รับประกันว่าผลลัพธ์ปลายทางเท่าเดิม
ถ้าเป็น POST คำตอบคือ ห้ามยิงซ้ำ
เพราะ POST ไม่มีสัญญานี้ · ชั้น retry ทุกตัวที่เขียนถูกต้องจะยอมแพ้แล้วโยน error ให้ผู้ใช้แทน
พูดให้สั้นที่สุด: ตัวเลข status code เป็นเรื่องของ request ครั้งนี้ ·
ตัว method เป็นเรื่องของ request ครั้งหน้าที่คุณยังไม่รู้ว่าจะมี
เครื่องมือที่อ่านสัญญานี้ก็ไม่ใช่คนด้วยซ้ำ — เป็น proxy, load balancer,
HTTP client library และชั้น retry ที่อยู่ระหว่างทาง ซึ่งไม่มีทางเปิดเอกสารของคุณอ่านได้
อ่านได้แค่ method เท่านั้น
💬 ข้อที่ผมอยากให้จำจากทั้งสามส่วน —
ส่วนที่ 1 สอนว่า “ข้อมูลชิ้นนี้ควรไปช่องไหน” · ส่วนที่ 2 สอนว่า
“มันทำงานได้ ไม่ได้แปลว่ามันถูก” · ส่วนที่ 3 สอนว่า
“เฉลยที่พิมพ์อยู่บนสไลด์ก็ผิดได้ ถ้ายังไม่มีใครรันมันดู”
ข้อ 6 ของส่วนที่ 3 ไม่ได้ผิดเพราะใครสะเพร่า — มันผิดเพราะ
swag init --help เขียนกำกวมจริง ๆ · ทางเดียวที่แยกออกคือรันสองรอบแล้ว diff
ถ้าคุณรันแล้วได้ผลไม่ตรงกับในหน้านี้ ส่งผลมาได้เลย —
ทุกบรรทัดในหน้านี้วัดบน Go 1.26.5 darwin/arm64 · gin v1.12.0 · swag CLI v1.16.4 +
swaggo/swag v1.16.6 เท่านั้น เวอร์ชันอื่นผมไม่รับประกัน
อ้างอิง
- RFC 9110 — HTTP Semantics: Method Definitions — อ่านตาราง Method ที่บอกว่าตัวไหน safe / idempotent ต้นทางของข้อสรุปว่า
DELETEidempotent ส่วนPOSTไม่ - RFC 9110 — 201 Created — นิยามของรหัสที่ควรตอบเมื่อสร้าง resource สำเร็จ (คำตอบข้อ 6 ของส่วนที่ 1)
- MDN — HTTP response status codes — รายการ status ทั้งหมดพร้อมคำอธิบายภาษาคน
- pkg.go.dev —
gin.Context.Query· Param — เอกสารที่ระบุตรง ๆ ว่าQueryคืนสตริงว่างเมื่อไม่มีค่า - pkg.go.dev —
gin.Engine— ดูฟิลด์HandleMethodNotAllowedที่เป็นfalseโดยดีฟอลต์ ต้นเหตุของ 404 แทน 405 - Gin v1.12.0 —
logger.go— หลักฐานว่า access log พิมพ์ query string ด้วย: บรรทัด 279 เก็บraw := c.Request.URL.RawQueryแล้วบรรทัด 307 ต่อกลับเข้าไปเป็นpath = path + "?" + raw - Gin — Custom log format — วิธีเปลี่ยนรูปแบบ access log เอง (เช่น ตัด query ออกจาก log)
- RFC 6750 §5.3 — Bearer Token: Threat Mitigation — เหตุผลอย่างเป็นทางการว่าทำไมความลับไม่ควรอยู่ใน URL
- swaggo/swag — Declarative Comments Format — รายการ annotation ทั้งหมด (
@title,@Param,@Failure,@Security,@Router) - swaggo/swag —
parser.go— ค้นคำว่าParseInternalและwalkWithเพื่อยืนยันด้วยตาเองว่า flag นี้แตะแค่เส้นทาง dependency - Cheat sheet Day 8 — กฎ 7 ข้อ · ตาราง status code · annotation ที่ใช้บ่อย รวมทั้งวัน