🧰 บทนี้พิมพ์ตามได้ แต่ยังไม่ต้องเขียน HTTP server —
ผลรันทุกอันในหน้านี้มาจากเซิร์ฟเวอร์เล็ก ๆ ที่ผมเขียนไว้ก่อนแล้วยิงด้วย curl
(Go 1.26.5 darwin/arm64) กับเทส go test ที่ใช้แค่ encoding/json
เลข port 53691 ที่เห็นในคำสั่ง curl เป็นของเซิร์ฟเวอร์สาธิตตัวนั้น
— คุณ copy ไปยิงตามตรง ๆ ไม่ได้ ให้อ่านเอาผลลัพธ์อย่างเดียว
โค้ดฝั่ง server จะสอนกันจริงจังใน
บทเรียน 37 — บทนี้โฟกัสที่การตัดสินใจ
ว่า endpoint ควรหน้าตายังไง ก่อนที่จะมีบรรทัดโค้ดสักบรรทัด
1 · URL ไม่ใช่เรื่องความสวย มันคือ signature ของฟังก์ชันที่ compiler ไม่ช่วยเช็ค
ตลอด 35 บทที่ผ่านมา เวลาคุณเรียกฟังก์ชันผิดชื่อ Go จะด่าคุณทันทีตอน compile —
พิมพ์ repo.FindById ทั้งที่ของจริงชื่อ FindByID โปรแกรมไม่ยอมสร้างไฟล์ให้ด้วยซ้ำ
แต่ HTTP ไม่มีขั้นตอนนั้น คนที่เรียก API ของคุณคือโปรแกรมของคนอื่น
ที่พิมพ์ path เป็น "สตริง" — พิมพ์ผิดตัวเดียวไม่มีใครเตือน จะรู้ตัวก็ตอน request วิ่งจริงแล้ว
ผมลงทะเบียน (= บอก router ว่ามี path นี้อยู่ ตามที่เจอใน
บทเรียน 34 · router คือตัวจัดเส้นทางของ server
ที่คอยดูว่า path ไหนให้โค้ดชิ้นไหนรับ) ไว้แค่ GET /payment-methods
เส้นเดียว แล้วยิงด้วยการสะกดแบบต่าง ๆ:
GET /payment-methods -> 200
GET /Payment-Methods -> 404
GET /PAYMENT-METHODS -> 404
GET /payment_methods -> 404
GET /paymentMethods -> 404
สี่บรรทัดล่างคือคนที่เข้าใจว่า API ของคุณมีอยู่จริง เขาแค่เดาการสะกดผิด และสิ่งที่เขาได้กลับไปคือ 404 — status code เดียวกับ "ไม่มีข้อมูลนี้" เขาจะแยกไม่ออกเลยว่า ผมพิมพ์ path ผิด หรือ ข้อมูลไม่มีจริง
คุณส่งพัสดุไปบ้านเพื่อนที่ "ลาดพร้าว ซอย 15" แต่เขียนไปว่า "ซอย 51"
บุรุษไปรษณีย์ไม่ได้โกรธคุณ เขาแค่ตีซองกลับมาพร้อมข้อความเดียวว่า "ไม่พบผู้รับ"
ซึ่งเป็นข้อความเดียวกับตอนที่เพื่อนคุณย้ายบ้านออกไปแล้วจริง ๆ —
คนละเรื่องกันเลย แต่ได้คำตอบกลับมาอันเดียวกัน
URL ก็คือที่อยู่ · สะกดผิดตัวเดียว server ตอบ 404
ซึ่งเป็นเลขเดียวกับตอนที่ข้อมูลไม่มีอยู่จริง คนเรียกจึงแยกไม่ออกว่าตัวเองพิมพ์ผิดหรือของไม่มี
กฎการสะกดทั้งหลาย (ตัวพิมพ์เล็ก · ขีดกลาง · ใช้พหูพจน์เหมือนกันหมด)
จึงมีไว้เพื่อให้ที่อยู่ของคุณมีวิธีเขียนแบบเดียว คนอื่นเดาครั้งแรกก็ถูกเลย
ที่ไม่เหมือนกัน: บุรุษไปรษณีย์เป็นคน เห็น "ซอย 51" แล้วยังพอเอะใจได้ว่าน่าจะหมายถึง 15 แต่ server ไม่เดาให้เลยสักนิด — แค่เปลี่ยนตัวเล็กเป็นตัวใหญ่ก็ถือว่าคนละที่อยู่แล้ว
💡 ทำไม /Users กับ /users ถึงคนละเส้น
มาตรฐาน URI (RFC 3986) บอกว่า scheme กับ host เท่านั้นที่ไม่สนตัวพิมพ์
(HTTP://EXAMPLE.COM = http://example.com)
ส่วนทุกอย่างหลังจากนั้น รวมถึง path — สนตัวพิมพ์เล็กใหญ่
นี่คือเหตุผลที่กฎข้อ 3 (lowercase) ไม่ใช่รสนิยม: มันคือการเลือกตัวสะกดแบบเดียว
จากหลายแบบที่ระบบมองว่าเป็นคนละเส้นทางกันหมด
สิ่งที่เพิ่งเข้าใจไปทั้งหมด วงการเขาเรียกแบบนี้ — ใช้คำให้ตรง แล้วคำตอบที่ได้จาก senior หรือจาก AI จะตรงตาม
- endpoint
- “ที่อยู่หนึ่งที่” ที่ยิงเข้าไปได้จริง = คู่ของ method + path เช่น
GET /payment-methods· แปลว่าGET /payment-methodsกับPOST /payment-methodsคือ คนละ endpoint ทั้งที่ path เดียวกัน · เวลาถามให้พูดว่า “path /payment-methods นี้มี endpoint อะไรบ้าง” จะสื่อกว่าถามว่า “ลิงก์นี้ใช้ยังไง” · (บางคนพูดคำว่า endpoint หลวม ๆ แปลว่า path เฉย ๆ — ถ้าคุยแล้วชักไม่แน่ใจ ระบุ method ไปเลยจะจบเร็วกว่า) - path
- ส่วนที่อยู่หลังชื่อเว็บ เช่น
/payment-methods· ต่างจาก query (ส่วนที่อยู่หลัง?) ซึ่งจะเจอในกฎข้อ 6 - route / routing
- การจับคู่ว่า path ไหนวิ่งเข้าโค้ดชิ้นไหน · ที่บทนี้พูดว่า “ลงทะเบียน” คือคำเดียวกับ “register route” ที่จะได้เจอตัวเป็น ๆ ใน บทเรียน 37
- case-sensitive
- “สนตัวพิมพ์เล็กใหญ่” · ประโยคที่เอาไปใช้ได้เลย:
“path มัน case-sensitive นะ
/Usersกับ/usersคนละเส้น” - kebab-case
- สไตล์การเขียนหลายคำด้วยขีดกลาง
payment-methods(กฎข้อ 4) · ญาติของมันคือ snake_case (payment_methods) และ camelCase (paymentMethods) — รู้ชื่อทั้งสามไว้ เวลาคุยจะไม่ต้องสะกดให้ฟังทีละตัว - 404 Not Found
- “ไม่มีของที่ที่อยู่นี้” · จุดที่ต้องจำคือมันตอบทั้งกรณีพิมพ์ path ผิด และกรณีข้อมูลไม่มีจริง — ตอนดีบักให้ถามตัวเองก่อนเสมอว่ากำลังเจอแบบไหน
2 · กฎ 7 ข้อ จริง ๆ แล้วตอบอยู่แค่สองคำถาม
ถ้าท่องเป็น 7 ข้อจะลืม แต่ถ้าเห็นว่ามันแบ่งเป็นสองกลุ่มจะจำได้ตลอดชีวิต
| กฎ | ✅ สไลด์บอก | ❌ สไลด์บอก | กลุ่ม |
|---|---|---|---|
| 1 ใช้ Noun มากกว่า Verb | GET /usersPOST /users |
GET /getUsersPOST /createUser |
A · "ของสิ่งนี้ชื่ออะไร" เป้าหมาย = คนอื่นเดาถูก |
| 2 ชื่อ resource consistent | /users /orders /products |
/user /orders /product-list |
|
| 3 lowercase | /users /orders /products |
/Users /Orders /PRODUCTS |
|
4 หลายคำใช้ - |
/payment-methods |
/paymentMethods/payment_methods/PaymentMethods |
|
| 5 relationship ใช้ path | /users/123/orders |
— | B · "ข้อมูลชิ้นนี้ควรไปช่องไหน" path / query / method |
| 6 filter ใช้ query | /orders?status=paid |
/orders/status/paid |
|
| 7 อย่าใส่ action ใน URI ถ้า method สื่อได้แล้ว |
หัวข้อ 3 ของบทนี้ทั้งหัวข้อ | ||
กลุ่ม A (ข้อ 1–4) คือกฎการสะกด — ทุกข้อแก้ปัญหาเดียวกับที่คุณเพิ่งเห็นในหัวข้อ 1
คือมี "ตัวสะกดที่ถูก" อยู่แบบเดียว แล้วที่เหลือกลายเป็น 404 หมด
ข้อ 2 ที่ดูจุกจิกที่สุด (/user เอกพจน์ปนกับ /orders พหูพจน์)
จริง ๆ แล้วแพงที่สุด เพราะมันทำให้คนที่ใช้ API ของคุณเดาไม่ได้อีกต่อไป —
พอเจอ /user ครั้งหนึ่ง เขาต้องเปิดเอกสารทุกครั้งไปตลอด ไม่กล้าเดาอีกเลย
กลุ่ม B (ข้อ 5–7) คือกฎการจัดที่อยู่ของข้อมูล —
ต่อยอดจาก บทเรียน 35 โดยตรง:
ข้อ 5 = สิ่งที่ระบุตัวของ (id, ความเป็นเจ้าของ) ไปอยู่ที่ path
ข้อ 6 = สิ่งที่กรอง/เรียง/แบ่งหน้า ไปอยู่ที่ query
ข้อ 7 = สิ่งที่บอกว่าจะทำอะไรกับของชิ้นนั้น ไปอยู่ที่ method
/orders/status/paid ดูสวยดี จนวันที่ PM ขอเพิ่มตัวกรองที่สอง —
คุณจะได้ /orders/status/paid/from/2026-01-01 แล้วก็ต้องลงทะเบียน route ใหม่
ทุกครั้งที่มีตัวกรองเพิ่ม และลำดับก็มีความหมายขึ้นมาเฉย ๆ
ส่วน /orders?status=paid&from=2026-01-01 คือเส้นเดิม
— สลับลำดับได้ ตัดออกได้ เพิ่มได้ โดยไม่แตะ routing สักบรรทัด
หลักที่ใช้ตัดสิน: path = ของชิ้นไหน · query = เอาเซ็ตย่อยแบบไหนของของชุดนั้น
3 · จับผิดสไลด์หน้า 35
หน้า 29–34 ของสไลด์ไล่กฎข้อ 1–6 มาถูกหมด พอถึงหน้า 35 ซึ่งเป็นตัวอย่างของกฎข้อ 7 หน้าจอโชว์แบบนี้ (ผมลอกมาทั้งบล็อกตามที่เห็นในสไลด์ รวมเลขบรรทัด):
1 // ✅ แนะนำ
2 POST /users/create
3 POST /users/123/delete
4
5 // ❌ แบบนี้ไม่แนะนำ
6 POST /users
7 DELETE /users/123
หน้านี้อยู่ใต้หัวข้อ Rule 7 — อย่าใส่ Action ใน URI ถ้า HTTP Method สื่อได้แล้ว
อ่านมันเทียบกับตัวกฎเอง แล้วตอบสองข้อ:
(ก) มีอะไรขัดกันเองในสไลด์หน้านี้ และ
(ข) ถ้าย้อนไปดูสไลด์หน้า 22 (REST – Method) จะเจอหลักฐานอะไรเพิ่ม
สไลด์ติดเครื่องหมายกลับด้าน — บรรทัด 1 ควรเป็น ❌ และบรรทัด 5 ควรเป็น ✅
(ก) มันขัดกับหัวข้อของตัวเอง: create กับ delete
คือ action ที่วางอยู่ใน URI เต็ม ๆ ทั้งที่ HTTP method สื่อได้อยู่แล้ว —
นั่นคือสิ่งที่กฎข้อ 7 ห้าม ตรงตัวอักษร แต่กลับถูกติด ✅ ว่าแนะนำ
แถมยังขัดกฎข้อ 1 ด้วย เพราะ create/delete เป็น verb
ซึ่งหน้า 29 เพิ่งบอกไปเองว่าอย่าใช้
(ข) หน้า 22 (REST – Method) เขียนไว้ตรง ๆ ว่า:
// สร้าง User → POST /users
// ลบ User 123 → DELETE /users/123
ซึ่งคือคู่ที่หน้า 35 ติด ❌ ไว้พอดี — สไลด์ชุดเดียวกันสอนสองแบบขัดกันเอง
สิ่งที่ถูกคือ:
คู่นี้ต่างหากที่ต้องติด ❌
POST /users/create
POST /users/123/delete
verb ซ้อนอยู่ใน URI · POST ถูกใช้ทำทุกอย่าง
จน method ไม่เหลือความหมาย
คู่นี้ต่างหากที่ต้องติด ✅
POST /users
DELETE /users/123
URI บอกว่าของอะไร · method บอกว่าทำอะไร — ตรงตามกฎข้อ 1 และข้อ 7
⚠️ ย้ำให้ชัด: นี่คือสไลด์พิมพ์ผิด ไม่ใช่คุณอ่านผิด
ผมเปิดภาพสไลด์หน้า 35 ยืนยันแล้วว่าเครื่องหมายเป็นอย่างนั้นจริง —
ถ้าคุณอ่านหน้านั้นแล้วรู้สึกว่า "เอ๊ะ มันไม่ใช่หรือเปล่า" ความรู้สึกนั้นถูกแล้ว
และนี่คือทักษะที่มีค่ากว่าการจำกฎ: เอกสารทุกฉบับพิมพ์ผิดได้
วิธีตรวจคือเอาตัวอย่างไปทาบกับกฎที่มันอ้างอิง ถ้าตัวอย่างขัดกับกฎของตัวเอง
ให้เชื่อกฎ แล้วไปหาหลักฐานยืนยันจากที่อื่นในเอกสารเดียวกัน (ในเคสนี้คือหน้า 22)
(สไลด์ชุดนี้มีอีกจุดหนึ่งคล้าย ๆ กัน: หน้า 47 หัวข้อเขียนว่า "CORS Middleware"
แต่คำอธิบายใต้หัวข้อกลับเป็นนิยามของ idempotency — เป็นข้อความตกค้างจากการ copy-paste)
4 · แล้ว PATCH /users/123/suspend ล่ะ ทำไมถึงถูก
หน้า 36 ของสไลด์ต่อจากหน้า 35 ทันที และหน้านี้เขียนถูก:
// ✅ แนะนำ
PATCH /users/123/suspend
PATCH /users/123/reset-password
ตรงนี้มือใหม่งงกันเยอะมาก — เมื่อกี้บอกว่าห้ามใส่ verb ใน URI แล้วนี่มันไม่ใช่ verb เหรอ
คำตอบอยู่ในตัวกฎเอง: "อย่าใส่ Action ใน URI ถ้า HTTP Method สื่อได้แล้ว"
— เงื่อนไขท้ายประโยคคือหัวใจ
HTTP มี method ให้เลือกอยู่ไม่กี่ตัว และมันสื่อได้แค่ 5 อย่าง:
อ่าน (GET) / สร้าง (POST) / แทนที่ทั้งก้อน (PUT) /
แก้บาง field (PATCH) / ลบ (DELETE)
ทีนี้ลองถามว่า "ระงับบัญชี" คือหนึ่งใน 5 อย่างนั้นไหม
| คำถามตรวจสอบ | create / delete | suspend |
|---|---|---|
| method ตัวไหนสื่อได้ตรง ๆ ไหม | ได้ — POST / DELETE |
ไม่ได้ — ไม่มี method ชื่อ SUSPEND |
| มันคือการแก้ field เฉย ๆ หรือมีกฎธุรกิจของตัวเอง | — | มีกฎของตัวเอง (ยกเลิก session, ส่งเมล, เขียน audit log) |
ถ้าเขียนเป็น PATCH /users/123 เฉย ๆ จะเป็นยังไง |
— | client ต้องส่ง {"status":"suspended"} เอง= เปิดให้ client เซ็ต status เป็นอะไรก็ได้ |
| สรุป | ผิดกฎ 7 | เป็นข้อยกเว้นที่ถูกต้อง |
แถวที่สามคือเหตุผลที่แข็งแรงที่สุด: PATCH /users/123 ที่รับ
{"status":"suspended"} แปลว่าคุณกำลังบอกโลกว่า
status เป็น field ที่แก้ได้ตามใจ —
พรุ่งนี้จะมีคนส่ง {"status":"admin"} มาให้เห็น
ส่วน PATCH /users/123/suspend คือประตูบานเดียวที่เปิดไปได้ทางเดียว
— ตัว URI เองกลายเป็นการจำกัดสิทธิ์
📌 กฎง่าย ๆ ที่ใช้ได้จริงเวลาตัดสินใจ
ถ้า action ที่คุณอยากทำแปลเป็นภาษา CRUD ได้ (สร้าง/อ่าน/แทนที่/ลบ ของสิ่งหนึ่ง)
→ ห้ามใส่ verb ใช้ method อย่างเดียว
ถ้ามันเป็น "การเปลี่ยนสถานะ" ที่มีกฎธุรกิจของตัวเอง
(suspend, reset-password, publish, cancel, refund)
→ ใส่เป็น sub-path ได้ และใช้ POST หรือ PATCH
เกร็ด: อีกสำนักหนึ่งจะแปลง action นั้นให้กลายเป็น resource ไปเลย เช่น
PUT /users/123/suspension — ทั้งสองแบบมีคนใช้จริงในระดับ production
ข้อสำคัญกว่าคือทั้งโปรเจกต์เลือกแบบเดียวกัน (นั่นคือกฎข้อ 2 อีกที)
5 · ก่อนเขียนโค้ด ต้องตอบ 5 ช่องนี้ให้ครบ
สไลด์หน้า 38 ให้ checklist มา 5 บรรทัด และมันสั้นจนอ่านผ่านได้ง่ายมาก แต่นี่คือสิ่งที่แยก "endpoint ที่ทีมอื่นเอาไปใช้ได้" ออกจาก "endpoint ที่ต้องเดินไปถามคนเขียน"
| ช่อง | คำถามที่ต้องตอบ | ตัวอย่าง: POST /recipes ของ Wongnok |
|---|---|---|
| Request body | ส่งอะไรเข้ามา field ไหนบังคับ | 4 field: name · cooking_time · difficulty · ingredients— บังคับแค่ name |
| Response status | สำเร็จแล้วตอบเลขอะไร | 201 Created (สร้างของใหม่ ไม่ใช่ 200) |
| Response body | ตอบอะไรกลับ | สูตรที่เพิ่งสร้าง พร้อม id ที่ server เป็นคนตั้ง |
| Error status | พังแบบไหนได้บ้าง แต่ละแบบเลขอะไร | 400 กรอกไม่ครบ · 401 ไม่ได้ล็อกอิน · 409 ชื่อซ้ำ |
| Error body | หน้าตา error เหมือนกันทั้งระบบไหม | {"error":"..."} รูปแบบเดียวทุก endpoint |
ช่องที่คนลืมบ่อยที่สุดคือช่องสุดท้าย และมันเจ็บที่สุด
ถ้า endpoint หนึ่งตอบ {"error":"..."} อีก endpoint ตอบ {"message":"..."}
อีกอันตอบข้อความเปล่า ๆ ไม่ใช่ JSON — ฝั่ง frontend ต้องเขียนโค้ดอ่าน error สามแบบ
และจะมีวันที่เขาอ่านพลาดแล้วโชว์ undefined ให้ลูกค้าเห็น
ทั้ง 5 ช่องนี้รวมกันเรียกว่า contract และคำนี้ต้องเข้าใจตามตัวอักษร: มันคือสัญญา
พอมีคนเอา endpoint ของคุณไปใช้แล้ว คุณแก้ contract ฝ่ายเดียวไม่ได้อีก
นั่นคือเหตุผลที่ต้องมี versioning ซึ่งเป็นหัวข้อสุดท้ายของบทนี้
— แต่ก่อนจะไปถึงตรงนั้น มีคำถามที่แพงกว่ารออยู่
6 · Idempotency — คำที่แปลว่า "ยิงซ้ำได้ไม่พัง"
สไลด์หน้า 39 นิยามไว้ว่า
"หากมีการเรียก operation เดิมซ้ำหลายครั้ง ผลลัพธ์สุดท้ายควรเหมือนเรียกเพียงครั้งเดียว"
แล้วยกตัวอย่างที่ทุกคนกลัว: POST /payments แล้ว retry
(= ยิงคำสั่งเดิมซ้ำอีกรอบเพราะรอคำตอบไม่ได้) — จ่ายสองรอบไหม?
ยืนรอลิฟต์แล้วใจร้อน กดปุ่มรัว ๆ สิบที — ผลก็คือ ลิฟต์กำลังมาชั้นนี้ เท่าเดิม
กดทีเดียวหรือสิบทีก็ได้ลิฟต์ตัวเดียวกัน ไม่มีลิฟต์เพิ่มมาอีกสิบตัว
แต่ถ้ายืนหน้าร้านชานมแล้วพูดว่า "แก้วนึงครับ" สิบครั้ง — คุณจะได้ชานมสิบแก้ว และจ่ายสิบเท่า
ความต่างไม่ได้อยู่ที่กดแรงหรือเบา แต่อยู่ที่ความหมายของคำสั่ง:
· ปุ่มลิฟต์แปลว่า "ทำให้ลิฟต์มาอยู่ที่ชั้นนี้" — พูดซ้ำก็ยังเป็นสภาพเดิมที่ต้องการ
· สั่งชานมแปลว่า "เพิ่มอีกหนึ่งแก้ว" — พูดซ้ำก็บวกเพิ่มไปเรื่อย ๆ
PUT กับ DELETE พูดแบบปุ่มลิฟต์ · POST พูดแบบสั่งชานม
ที่ไม่เหมือนกัน: ปุ่มลิฟต์กดซ้ำได้เพราะช่างเดินวงจรไว้ให้แล้ว กดผิดยังไงก็ไม่พัง
ฝั่ง API สเปก (RFC 9110) ก็กำหนดไว้ล่วงหน้าเหมือนกันว่า GET ·
PUT · DELETE ต้อง idempotent — แต่มันเป็นแค่สัญญาบนกระดาษ
ไม่มีวงจรมาบังคับ · คนที่ต้องเขียน handler ให้ทำตามสัญญานั้นจริงคือคุณ
เขียนพลาดเมื่อไหร่ PUT ก็ยิงซ้ำแล้วพังได้
ส่วน PATCH สเปกไม่ได้กำหนดไว้ให้ — จะยิงซ้ำได้หรือไม่ได้
ขึ้นกับว่าคุณเขียนคำสั่งลงไปว่าอะไร ("ตั้งชื่อเป็น A" ซ้ำได้ · "บวกแต้มเพิ่ม 10" ซ้ำไม่ได้)
ซึ่งจะเห็นในตารางถัดไป
ก่อนไปต่อ ต้องเข้าใจว่าทำไมถึงมี retry ตั้งแต่แรก —
ไม่ใช่เพราะผู้ใช้กดปุ่มรัว แต่เพราะเน็ตขาดตอนกลางทางแล้วไม่มีใครรู้ว่าถึงหรือยัง
(ในภาพข้างล่าง client คือฝั่งที่ยิงคำสั่งเข้ามา — เว็บ แอปมือถือ หรือ service อื่น ·
server คือฝั่งที่รับแล้วตอบกลับ)
client server
│ │
│──── POST /payments ────────────────▶│ ตัดเงินเรียบร้อย ✔
│ │
│◀╌╌╌ 201 Created ╌╌╌╌✂╌╌ (เน็ตขาด) ╌╌│ server ทำงานเสร็จแล้ว
│ │
timeout — client เห็นแค่ "ไม่มีคำตอบ"
│
├─▶ ยิงซ้ำ = เสี่ยงตัดเงินสองรอบ
└─▶ ไม่ยิงซ้ำ = เสี่ยงลูกค้าจ่ายไม่สำเร็จทั้งที่เงินออกไปแล้ว
client ไม่มีทางรู้เลยว่า request ตายก่อนถึง server หรือตายตอนขากลับ — สองเคสนี้หน้าตาเหมือนกันเป๊ะจากฝั่ง client นี่คือเหตุผลที่ทุก HTTP client ในโลกจริงมี retry และเป็นเหตุผลที่ idempotency ต้องมีอยู่
| method | safe (ไม่เปลี่ยนอะไรเลย) | idempotent (ยิงซ้ำแล้ว state ปลายทางเหมือนเดิม) | เหตุผล |
|---|---|---|---|
GET | ✅ | ✅ | แค่อ่าน ยิงกี่ครั้งก็ไม่แตะข้อมูล |
PUT | ❌ | ✅ | "แทนที่ทั้งก้อนด้วยค่านี้" ทับซ้ำก็ได้ผลเดิม |
DELETE | ❌ | ✅ | ลบซ้ำ ปลายทางก็คือ "ไม่มีของชิ้นนี้" เหมือนกัน |
POST | ❌ | ❌ | "สร้างของใหม่" ยิงสองครั้งได้ของสองชิ้น |
PATCH | ❌ | แล้วแต่ที่คุณเขียน | {"name":"A"} ซ้ำได้ · {"add_points":10} ซ้ำไม่ได้ |
⚠️ idempotent วัดที่ "สถานะปลายทางของ server" ไม่ใช่ที่ "response ที่ client ได้"
นี่คือประโยคที่ต้องอ่านสามรอบ เพราะเดี๋ยวหัวข้อ 7 จะโชว์เคสที่
status code ต่างกันในสองครั้ง แต่ยัง idempotent อยู่
และสังเกตว่า PATCH ไม่ได้ถูกกำหนดมาให้ idempotent —
มันขึ้นกับว่าคุณออกแบบ body ยังไง body ที่บอก "ค่าใหม่คือเท่านี้" ซ้ำได้
ส่วน body ที่บอก "เอาของเดิมบวกเพิ่ม" ซ้ำไม่ได้
- idempotent
- “ยิงซ้ำได้ไม่พัง” = ปุ่มลิฟต์ · อ่านว่า ไอ-เด็ม-โพ-เทนต์ · เวลารีวิวโค้ดกันให้ถามว่า “endpoint นี้ idempotent ไหม ถ้า client retry จะเกิดอะไรขึ้น” — ตรงกว่าถามว่า “กดสองทีแล้วพังไหม” เยอะ
- safe method
- method ที่ไม่เปลี่ยนข้อมูลเลย — ที่เจอบ่อยคือ
GETกับHEAD(สเปก RFC 9110 นับOPTIONSกับTRACEเป็น safe ด้วย แต่แทบไม่มีใครเขียนเองในงานจริง อย่าเผลอตอบว่า “มีแค่สองตัว”) · อย่าสับสนกับ idempotent —DELETEไม่ safe (มันลบของจริง) แต่ idempotent - state
- “สภาพของข้อมูลบน server ตอนนี้” · ประโยคหลักของหัวข้อนี้คือ “idempotency วัดกันที่ state ปลายทาง ไม่ใช่ที่ status code” — จำประโยคนี้ไว้ทั้งประโยคได้เลย
- retry
- การยิงคำสั่งเดิมซ้ำเมื่อไม่ได้คำตอบ · ระบบจริงมี retry อยู่แทบทุกเส้นทาง แต่
อย่าเหมาว่า library พื้นฐานทำให้เอง —
fetchกับaxiosไม่ retry ให้เลยถ้าไม่ได้ใส่เอง · Gohttp.Clientretry เฉพาะเคสแคบมาก (connection เก่าที่ค้างใน pool แล้วดันตายพอดีตอนจะยิง และเฉพาะ request ที่มันถือว่ายิงซ้ำได้) · ตัว retry ที่เจอจริงมักมาจากโค้ดที่ทีมเขียนเอง หรือชั้นที่คุณมองไม่เห็น — SDK ของผู้ให้บริการ, load balancer, service mesh · คำถามที่ควรถามก่อนปล่อยของขึ้น production คือ “เส้นทางนี้มีใคร retry ให้บ้าง กี่ครั้ง” - timeout
- “รอจนหมดเวลาแล้วยังไม่มีคำตอบ” · จุดสำคัญคือ timeout ไม่ได้แปลว่างานไม่สำเร็จ มันแปลว่าไม่รู้ — และความไม่รู้นี่แหละคือต้นเหตุของทั้งหัวข้อ
ทายก่อนรัน — endpoint POST /payments ตัดเงินครั้งละ 500 บาท
แล้วคืนยอดสะสมที่ตัดไปแล้วทั้งหมดมาด้วย ผมยิงมันสามครั้งติดกันโดยไม่เปลี่ยนอะไรเลย
(จำลอง client ที่ retry เพราะ timeout)
$ curl -X POST http://127.0.0.1:53691/payments # ยิงสามครั้งติด
ทั้งสามครั้งได้ status อะไร · payment_id เหมือนกันไหม ·
และสุดท้าย charged_total เป็นเท่าไหร่
{"payment_id":"pay_1","amount":500,"charged_total":500} <- 201
{"payment_id":"pay_2","amount":500,"charged_total":1000} <- 201
{"payment_id":"pay_3","amount":500,"charged_total":1500} <- 201
$ curl http://127.0.0.1:53691/total
charged_total=1500 payments=pay_1,pay_2,pay_3
ทั้งสามครั้งสำเร็จหมด ได้ 201 ครบ ไม่มี error สักตัว — และลูกค้าโดนตัดเงินไป 1,500 บาท จากการกดจ่าย 500 บาทครั้งเดียว
จุดที่ต้องเห็นให้ชัด: ไม่มีอะไรพังเลย ไม่มี exception ไม่มี 500 log สวยงามทั้งสามบรรทัด — server ทำถูกต้องตามที่ถูกสั่งทุกประการ ปัญหาไม่ได้อยู่ที่โค้ดพัง ปัญหาอยู่ที่คำสั่ง "สร้าง payment ใหม่" ถูกส่งมาสามครั้งจริง ๆ — และ server ไม่มีทางรู้ว่าสามครั้งนั้นคือเจตนาเดียวกัน
เทียบกับ PUT ที่ยิงซ้ำแบบเดียวกัน — ผมยิงสองครั้งด้วย body เดิม:
$ curl -X PUT -d '{"name":"Somchai"}' http://127.0.0.1:53691/users/9 # ยิงสองครั้ง
{"id":"9","name":"Somchai"} <- 200
{"id":"9","name":"Somchai"} <- 200
เหมือนกันทุกตัวอักษร เพราะ PUT แปลว่า "ทำให้ user 9 มีหน้าตาเป็นแบบนี้"
ไม่ใช่ "เพิ่มอีกหนึ่ง" — ความต่างอยู่ที่ความหมายของคำสั่ง ไม่ได้อยู่ที่ความเก่งของโค้ด
7 · DELETE /users/1 สองครั้ง ครั้งที่สองควรตอบ 404 หรือ 204
นี่เป็นคำถามที่ถูกเถียงกันในทุกทีมที่เคยทำ API และมันไม่มีคำตอบเดียว —
แต่การรู้ว่าทำไมถึงไม่มีคำตอบเดียว มีค่ากว่าการจำคำตอบ ผมเขียนทั้งสองแบบแล้วยิงจริง
(กล่องแดง/เขียวข้างล่างเป็นแค่สีของกล่องเปรียบเทียบ ไม่ได้แปลว่าแบบ A ผิด
— เดี๋ยวจะเห็นว่าถูกทั้งคู่)
แบบ A — ครั้งที่สองตอบ 404
1st DELETE /users/1 -> 204
2nd DELETE /users/1 -> 404
{"error":"user not found"}
พูดความจริงว่า "ตอนคุณสั่ง มันไม่มีอยู่แล้ว"
แต่ client ที่ retry อัตโนมัติจะเห็น error ทั้งที่งานสำเร็จไปแล้ว
แบบ B — ตอบ 204 เสมอ
1st DELETE /users/2 -> 204
2nd DELETE /users/2 -> 204
พูดถึงผลลัพธ์ว่า "ตอนนี้ไม่มี user 2 แล้ว ตามที่คุณต้องการ"
retry เงียบสนิท แต่ client แยกไม่ออกว่าเพิ่งลบ หรือไม่เคยมี
สำคัญที่สุด: ทั้งสองแบบ idempotent ทั้งคู่
เพราะ idempotency วัดที่สถานะปลายทางของ server — และปลายทางของทั้งสองแบบเหมือนกันเป๊ะ
คือ "ไม่มี user คนนี้" ไม่ว่าจะยิงกี่ครั้ง · status code ที่ต่างกันไม่ได้ทำให้มันเลิก idempotent
เวลาเลือกจริง ให้ถามว่าใครเป็นคนเรียก endpoint นี้:
| สถานการณ์ | เลือก | เพราะ |
|---|---|---|
| หน้าเว็บที่ผู้ใช้กดปุ่มลบเอง | 404 | ถ้าของหายไปแล้ว ผู้ใช้ควรได้รู้ว่าหน้าจอตัวเองเก่า ไม่ใช่กดแล้วเงียบ |
| job / service อื่นที่มี retry อัตโนมัติ | 204 | retry ที่ "สำเร็จซ้ำ" ไม่ควรกลายเป็น error ใน log จนคนตามหาสาเหตุผิดทาง |
| ตัดสินใจไม่ได้จริง ๆ | อันไหนก็ได้ | แต่ต้องเขียนลงช่อง "Error status" ของ contract แล้วทำเหมือนกันทั้งระบบ |
⚠️ สิ่งที่ผิดแน่ ๆ ไม่ว่าจะเลือกแบบไหน
1. ตอบ 500 เพราะ "ลบไม่ได้" — ไม่มีอะไรพังที่ฝั่ง server เลย
การลบของที่ไม่มีไม่ใช่ความผิดพลาดของระบบ
2. ตอบ 200 พร้อม body ว่างเปล่า — ถ้าไม่มีอะไรจะส่งกลับ
204 No Content คือ status ที่ตรงความหมายกว่า
3. ตอบไม่เหมือนกันในแต่ละ endpoint — DELETE /recipes/1 ตอบ 404
แต่ DELETE /users/1 ตอบ 204 คือฝันร้ายของคนที่ใช้ API คุณ
8 · แล้ว POST ที่ retry ไม่ได้ล่ะ — Idempotency-Key
POST จะไม่มีวัน idempotent ได้ด้วยตัวมันเอง เพราะความหมายของมันคือ "สร้างของใหม่"
ทางออกจึงไม่ใช่การแก้ method แต่คือให้ client บอก server ว่า
"สาม request นี้คือเจตนาเดียวกันนะ" — ด้วย header ที่ client เป็นคนสร้างค่าเอง
ร้านตามสั่งช่วงเที่ยง คนแน่น เสียงดัง เด็กเสิร์ฟตะโกนบอกครัวว่า "ต้มยำกุ้งหนึ่ง!" แล้วไม่แน่ใจว่าครัวได้ยินไหม เลยตะโกนซ้ำอีกสองรอบ — ผลคือลูกค้าได้ต้มยำสามถ้วย
ร้านที่จัดการเรื่องนี้เป็น จะไม่ตะโกนลอย ๆ แต่เขียนบิลเลข 77 แล้วเสียบไว้ที่ราวหน้าครัว
· ตะโกนย้ำกี่รอบก็อ้างบิลใบเดิม ครัวเห็นว่าบิล 77 ทำไปแล้ว ก็ไม่ทำซ้ำ
แต่ยกถ้วยเดิมที่ทำเสร็จไว้แล้วส่งออกมา — หน้าตาเหมือนเพิ่งทำใหม่ทุกประการ
ทั้งที่ไม่ได้ทำเพิ่มสักถ้วย
· ส่วนลูกค้าที่ตั้งใจสั่งเพิ่มจริง ๆ จะได้บิลใบใหม่เลข 78 ครัวก็ทำให้ตามปกติ
บิลเลข 77 นี่แหละคือ Idempotency-Key — และคนที่เขียนเลขบิลคือเด็กเสิร์ฟ (client)
ไม่ใช่ครัว (server) เพราะคนที่รู้ว่า "นี่คือออเดอร์เดียวกันนะ" คือฝั่งที่รับออเดอร์มา
ที่ไม่เหมือนกัน: ร้านอาหารมีราวเสียบบิลอยู่ที่เดียว ทุกคนในครัวเห็นตรงกันหมด แต่ระบบจริงมักมี server หลายเครื่องช่วยกันรับงาน — ถ้าแต่ละเครื่องจำบิลไว้ในหัวตัวเอง เครื่องที่ไม่เคยเห็นบิล 77 ก็จะทำซ้ำอยู่ดี (ตรงกับกับดักข้อ 2 ในกล่องข้างล่าง)
client สร้าง key ครั้งเดียว ก่อนยิงครั้งแรก → key = "8f3c-01"
│
├─▶ POST /payments Idempotency-Key: 8f3c-01
│ server: ยังไม่เคยเห็น key นี้ → ตัดเงินจริง → เก็บ (key → คำตอบ) ไว้
│
├─▶ POST /payments Idempotency-Key: 8f3c-01 (retry เพราะ timeout)
│ server: เคยเห็นแล้ว → ไม่ทำอะไรใหม่ → คืนคำตอบเดิมที่เก็บไว้
│
└─▶ POST /payments Idempotency-Key: 8f3c-02 (ผู้ใช้ตั้งใจจ่ายรอบใหม่จริง ๆ)
server: key ใหม่ → ตัดเงินอีกรอบ ถูกต้องแล้ว
ผมทำ endpoint แบบนี้แล้วยิง key เดิมสามครั้ง แล้วตามด้วย key ใหม่หนึ่งครั้ง
(... ในคำสั่งข้างล่างคือ http://127.0.0.1:53691 ตัวเดิม ย่อไว้ให้อ่านง่าย):
$ curl -X POST -H 'Idempotency-Key: 8f3c-01' .../idem/payments # สามครั้ง
{"payment_id":"pay_4","amount":500,"charged_total":2000} <- 201
{"payment_id":"pay_4","amount":500,"charged_total":2000} <- 201
{"payment_id":"pay_4","amount":500,"charged_total":2000} <- 201
# คนละ key = คนละเจตนา
$ curl -X POST -H 'Idempotency-Key: 8f3c-02' .../idem/payments
{"payment_id":"pay_5","amount":500,"charged_total":2500} <- 201
charged_total ขยับจาก 1500 → 2000 ครั้งเดียว ตลอดสาม request แรก
แล้วขยับเป็น 2500 ตอนเปลี่ยน key — นี่คือพฤติกรรมที่เราต้องการเป๊ะ ๆ
สังเกตว่าครั้งที่ 2 และ 3 ก็ยังตอบ 201 Created
ทั้งที่ไม่ได้สร้างอะไรใหม่ — เพราะ server กำลังเล่นเทปคำตอบเดิม ให้ฟัง
จุดนี้คนออกแบบมักเติม header บอกไว้ให้ client รู้ตัว ผมใส่ไว้ด้วย:
# -D - = พิมพ์ response header ออกจอ · -o /dev/null = ทิ้ง body ไม่ต้องโชว์
$ curl -D - -o /dev/null -X POST -H 'Idempotency-Key: 8f3c-01' .../idem/payments
HTTP/1.1 201 Created
Idempotent-Replay: true
⚠️ กับดักที่ทำให้ Idempotency-Key ไร้ประโยชน์ทันที
1. client สร้าง key ใหม่ทุกครั้งที่ retry — key ต้องเกิด
ครั้งเดียวตอนผู้ใช้กดปุ่ม แล้วใช้ค่าเดิมตลอดทุก retry ถ้าสร้างใหม่ทุกรอบ
มันก็แค่ POST ธรรมดาที่มี header ประดับ
2. เก็บ key ไว้ใน RAM ของ process — deploy ใหม่หรือมีหลาย instance
(= โปรแกรมตัวเดียวกันรันหลายตัวพร้อมกันเพื่อช่วยกันรับงาน) แล้ว key หายทันที (นี่คือปัญหาเดียวกับที่
บทเรียน 32 พูดถึง —
ต้องเก็บใน store กลาง)
3. ไม่ผูก key กับผู้ใช้ — ถ้า key เป็นของกลางทั้งระบบ
คนอื่นเดา key ชนแล้วจะได้เห็นคำตอบของคนอื่น · เก็บเป็น (user_id, key) เสมอ
4. เก็บตลอดกาล — ตั้งอายุให้มันด้วย (24 ชม. เป็นค่าที่หลายเจ้าใช้)
ไม่งั้นตารางนี้จะโตไม่หยุด
📌 สถานะของ header ตัวนี้ — Idempotency-Key
ยังไม่ใช่มาตรฐาน HTTP อย่างเป็นทางการ ตอนนี้เป็น Internet-Draft ของ IETF อยู่
(= ร่างที่ยังอยู่ระหว่างพิจารณา ยังไม่ประกาศใช้เป็นมาตรฐาน)
แต่มันเป็นธรรมเนียมที่ผู้ให้บริการชำระเงินใช้ตรงกันมานาน (Stripe ใช้ชื่อนี้)
จนถือเป็นชื่อมาตรฐานโดยพฤตินัยไปแล้ว — ใช้ชื่อนี้ อย่าไปตั้งชื่อเองใหม่
- Idempotency-Key
- “เลขที่บิล” ที่ client สร้างเองแล้วแนบมาทุก retry เพื่อบอกว่าเป็นเจตนาเดียวกัน · ประโยคที่เอาไปคุยได้เลย: “endpoint จ่ายเงินตัวนี้รับ Idempotency-Key ไหม แล้วเก็บ key ไว้ที่ไหน อายุเท่าไหร่” — สามคำถามนี้ครอบคลุมกับดักเกือบทั้งหมด
- replay
- “เล่นเทปคำตอบเดิม” — server ไม่ได้ทำงานใหม่ แต่คืนคำตอบที่เคยเก็บไว้ ·
ตัวอย่างในบทนี้ใช้ header
Idempotent-Replay: trueบอก client ว่า “นี่คือเทปนะ” — แต่ชื่อ header ตัวนี้ไม่มีมาตรฐานกลาง แต่ละเจ้าตั้งเอง (IETF draft ของIdempotency-Keyไม่ได้นิยาม header แจ้ง replay ไว้เลย · Stripe ใช้ชื่อIdempotent-Replayed) — ถ้าจะใช้ ตกลงชื่อกับทีมแล้วเขียนลง contract ก่อน - shared store / store กลาง
- ที่เก็บข้อมูลที่ server ทุกเครื่องมองเห็นเหมือนกัน (เช่น Redis, ฐานข้อมูล) = “ราวเสียบบิลอันเดียวที่ทุกคนในครัวเห็น” · ตรงข้ามกับการเก็บไว้ในหน่วยความจำของเครื่องใครเครื่องมัน
- TTL (time to live)
- “อายุของข้อมูล” — ครบเวลาแล้วลบทิ้งเอง · ที่กับดักข้อ 4 บอกว่าอย่าเก็บ key ตลอดกาล คำที่เขาใช้เรียกคือ “ตั้ง TTL ให้ key 24 ชั่วโมง”
- de facto standard
- “มาตรฐานโดยพฤตินัย” — ไม่มีใครประกาศเป็นกฎ แต่ทั้งวงการใช้ตรงกันจนกลายเป็นมาตรฐานไปเอง ·
Idempotency-Keyคือตัวอย่างชั้นดี
9 · Versioning — "breaking change" คืออะไรกันแน่
สไลด์หน้า 37 บอกว่า versioning มีไว้ "รองรับกรณีที่เกิด Breaking Change
เพื่อให้ Client เดิมที่ยังไม่ Migrate สามารถทำงานต่อไปได้ในช่วงเวลาที่กำหนด"
แล้วโชว์ /api/v1/users → /api/v2/users
ประโยคนั้นถูก แต่มันข้ามคำถามที่ยากที่สุดไป: รู้ได้ยังไงว่าอะไรคือ breaking change
คำจำกัดความที่ใช้ได้จริงมีข้อเดียว — "client ตัวเก่าที่ไม่ได้แก้อะไรเลย
ยังทำงานถูกต้องอยู่ไหม" ถ้าคำตอบคือไม่ นั่นคือ breaking
ผมเลยทดลองเป็น client เก่าจริง ๆ: struct ตัวนี้เขียนไว้ตั้งแต่ v1 แล้วไม่เคยแก้อีกเลย
// client เก่า — ไม่เคยแก้ตั้งแต่ v1
type Recipe struct {
ID int `json:"id"`
Name string `json:"name"`
CookingTime int `json:"cooking_time"`
}
// แล้วเอา JSON 5 แบบนี้มา json.Unmarshal ใส่ struct เดิม
เพิ่ม field ใหม่ {"id":7,"name":"ต้มยำกุ้ง","cooking_time":30,"difficulty":"medium"}
ลบ field ทิ้ง {"id":7,"name":"ต้มยำกุ้ง"}
เปลี่ยนชื่อ field {"id":7,"name":"ต้มยำกุ้ง","cookingTime":30}
เปลี่ยน type {"id":7,"name":"ต้มยำกุ้ง","cooking_time":"30 นาที"}
เปลี่ยนความหมาย {"id":7,"name":"ต้มยำกุ้ง","cooking_time":1800} // เปลี่ยนหน่วยนาที→วินาที
ห้าแบบนี้ แบบไหนได้ err != nil บ้าง และ
แบบไหน "ผ่านฉลุยแต่ค่าผิด" — อันหลังนี่แหละที่อันตรายกว่า
// แถวแรกคือ JSON v1 เดิม ใส่ไว้เป็นตัวเทียบ ที่เหลือคือ 5 แบบในโจทย์
v1 เดิม err=<nil>
ได้ {ID:7 Name:ต้มยำกุ้ง CookingTime:30}
เพิ่ม field ใหม่ err=<nil>
ได้ {ID:7 Name:ต้มยำกุ้ง CookingTime:30}
ลบ field cooking_time err=<nil>
ได้ {ID:7 Name:ต้มยำกุ้ง CookingTime:0}
เปลี่ยนชื่อ field err=<nil>
ได้ {ID:7 Name:ต้มยำกุ้ง CookingTime:0}
เปลี่ยน type err=json: cannot unmarshal string into Go struct field Recipe.cooking_time of type int
ได้ {ID:7 Name:ต้มยำกุ้ง CookingTime:0}
เปลี่ยนความหมาย err=<nil>
ได้ {ID:7 Name:ต้มยำกุ้ง CookingTime:1800}
มีแค่แบบเดียวที่ error คือเปลี่ยน type — อีกสี่แบบเงียบสนิท
และแบบที่อันตรายที่สุดคือแถวสุดท้าย: err=<nil>
ค่าเป็นเลขจำนวนเต็มสวย ๆ ไม่มีอะไรน่าสงสัยเลย —
แต่หน้าจอลูกค้าจะขึ้นว่าเมนูนี้ใช้เวลาทำ 1800 นาที
เทียบกับแถว "ลบ field" ที่ได้ 0 — อันนั้นยังพอมีโอกาสมีคนเอะใจว่า
"ทำอาหาร 0 นาทีได้ไง" แต่ 1800 ดูเหมือนตัวเลขจริงทุกประการ
| การเปลี่ยนแปลง | breaking ไหม ✅ = ปลอดภัย · ❌ = พัง | client เก่าเจออะไร |
|---|---|---|
| เพิ่ม field ใหม่ ใน response | ✅ ไม่ breaking | err=<nil> · field เดิมครบ · field ใหม่ถูกข้ามไปเฉย ๆ |
| เพิ่ม field ที่ไม่บังคับ ใน request | ✅ ไม่ breaking | client เก่าไม่ส่งมา server ใช้ค่า default |
| ลบ field ออกจาก response | ❌ breaking | ได้ zero value เงียบ ๆ (0, "") ไม่มี error |
| เปลี่ยนชื่อ field | ❌ breaking | เหมือนโดนลบทุกประการ — cookingTime ไม่แมตช์ cooking_time |
เปลี่ยน type (int → string) | ❌ breaking | error ชัดเจน — อันนี้โชคดีที่สุดแล้ว |
| เปลี่ยนความหมาย/หน่วย | ❌❌ breaking ที่แย่ที่สุด | ไม่มีอะไรเตือนเลย ทุกอย่างดูปกติ แต่คำตอบผิด |
| เพิ่ม field บังคับ ใน request | ❌ breaking | client เก่ายิงเหมือนเดิมแล้วโดน 400 ทันที |
แผงหมูติดป้ายว่า "สามชั้น 180" มาตลอด ทุกคนเข้าใจตรงกันว่า 180 บาทต่อกิโล
วันหนึ่งแม่ค้าเปลี่ยนมาคิดต่อขีด แต่ป้ายยังเขียน "180" เหมือนเดิมเป๊ะ
ลูกค้าประจำอ่านป้ายออก ตัวเลขก็ดูปกติ ไม่มีอะไรน่าสงสัย — จนถึงตอนจ่ายเงิน
ทีนี้ลองเทียบกับอีกสองกรณี:
· แม่ค้าเอาป้ายออกไปเลย → เครื่องคิดเงินไม่ได้ฟ้องอะไรสักคำ
มันคิดราคาช่องนั้นเป็น 0 บาท ให้เงียบ ๆ · ยังพอมีคนเอะใจได้บ้างว่า "หมูฟรีเหรอ"
แต่ไม่มีอะไรเตือน
· แม่ค้าเขียนเป็นตัวหนังสือว่า "หนึ่งร้อยแปดสิบ" → เครื่องคิดเงินอ่านไม่ออก ฟ้องทันที
สามกรณีนี้คือ เปลี่ยนความหมาย · ลบ field · เปลี่ยน type ตามลำดับ
และเรียงจากแย่ที่สุดไปหาโชคดีที่สุดพอดี —
อันที่ไม่มีอะไรฟ้องเลย คืออันที่แพงที่สุด
ที่ไม่เหมือนกัน: ที่ตลาดยังมีแม่ค้ายืนอยู่ให้ถามได้ และถามครั้งเดียวก็จบ แต่ API ของคุณคุยกับโปรแกรมของคนที่คุณไม่เคยเจอ เขาไม่มีใครให้ถาม และไม่มีทางรู้ว่าคุณเปลี่ยนหน่วยไปเมื่อวาน — ทางเดียวที่บอกเขาได้คือขึ้นเวอร์ชันใหม่ ซึ่งเป็นเรื่องที่จะพูดถึงต่อจากนี้
ข้อแรกของตารางมีเงื่อนไขเงียบ ๆ ซ่อนอยู่ — client ต้องยอมข้าม field ที่ไม่รู้จัก
ซึ่งเป็นพฤติกรรม default ของ json.Unmarshal แต่ถ้า client เปิดโหมดเข้มงวด:
// js คือสตริง JSON ก้อนเดิมที่มี "difficulty" เพิ่มเข้ามา
dec := json.NewDecoder(strings.NewReader(js))
dec.DisallowUnknownFields() // เจอ field แปลกปลอม = error
err := dec.Decode(&recipe) // ใช้ Decode ไม่ใช่ json.Unmarshal
DisallowUnknownFields err=json: unknown field "difficulty"
การเพิ่ม field กลายเป็น breaking change ทันที
บทเรียนที่ได้: "breaking หรือไม่" ไม่ได้ขึ้นกับฝั่ง server ฝ่ายเดียว
มันขึ้นกับว่า client อ่านคำตอบยังไงด้วย — เวลาออกแบบ API สาธารณะ
จึงต้องเขียนลง contract ไปเลยว่า "client ต้องข้าม field ที่ไม่รู้จัก"
- breaking change
- “แก้แล้วของเดิมพัง” = client เก่าที่ไม่ได้แก้อะไรเลย ทำงานไม่ถูกต้องอีกต่อไป · ประโยคที่ต้องพูดให้เป็นในที่ประชุม: “อันนี้ breaking นะ ต้องขึ้น v2” แทนที่จะพูดว่า “แก้แล้วน่าจะมีคนพัง”
- backward compatible
- ตรงข้ามกับ breaking — “ของเก่ายังใช้ได้เหมือนเดิม” · การเพิ่ม field ใน response ถือว่า backward compatible (ถ้า client ยอมข้าม field ที่ไม่รู้จัก)
- contract
- “สัญญา” = 5 ช่องจากหัวข้อ 5 (request body / response status / response body / error status / error body) · เวลาถาม senior ให้ถามว่า “contract ของ endpoint นี้เขียนไว้ที่ไหน” จะได้เอกสารมาเลย แทนที่จะได้คำอธิบายปากเปล่า
- versioning
- การติดเลขเวอร์ชันให้ API (
/api/v1→/api/v2) เพื่อให้ของเก่ากับของใหม่อยู่ด้วยกันได้ชั่วคราว - deprecate / sunset
- deprecate = ประกาศว่า “ของชิ้นนี้เลิกใช้แล้วนะ แต่ยังวิ่งอยู่” · sunset = วันที่จะปิดจริง · คำถามที่ต้องถามทุกครั้งที่ขึ้น v2 คือ “v1 จะ sunset วันไหน” — ถ้าไม่มีคำตอบ แปลว่ายังไม่มีแผน
- zero value
- ค่าตั้งต้นที่ Go ใส่ให้เมื่อไม่มีข้อมูล (
0สำหรับ int,""สำหรับ string) · นี่คือสาเหตุที่ “ลบ field” ไม่เกิด error —json.Unmarshalปล่อยให้ field นั้นเป็น zero value เงียบ ๆ
พอรู้ว่าอะไรคือ breaking แล้ว กฎของ /api/v1 → /api/v2 ก็ตรงไปตรงมา:
| คำถาม | คำตอบ |
|---|---|
| เมื่อไหร่ขึ้น v2 | เมื่อมีอย่างน้อยหนึ่งอย่างในตารางข้างบนที่เป็น breaking · ไม่ใช่ทุกครั้งที่มีฟีเจอร์ใหม่ |
| ขึ้น v2 แล้ว v1 ทำยังไง | ยังต้องวิ่งอยู่ ไปอีกช่วงเวลาที่ประกาศไว้ชัดเจน — ถ้าปิด v1 วันเดียวกับที่เปิด v2 การมี v2 ก็ไม่มีความหมาย |
| เอา version ไว้ที่ path หรือ header | สไลด์เลือก path (/api/v1/...) ซึ่งมองเห็นด้วยตาใน log และ curl ได้ทันที เหมาะกับทีมที่เพิ่งเริ่ม |
| v2 ต้องเขียนใหม่ทั้งชุดไหม | ไม่ — endpoint ที่ contract ไม่เปลี่ยน ให้ v2 เรียกโค้ดตัวเดิม เปลี่ยนเฉพาะเส้นที่ contract เปลี่ยนจริง |
10 · เอาทั้งบทมาทาบกับ Wongnok
สไลด์หน้า 71–72 ให้ API spec ของโปรเจกต์ Wongnok (เว็บรวมสูตรอาหาร) มาแล้ว ลองเอากฎทั้งหมดในบทนี้ไปทาบดู
| endpoint | ตรวจตามกฎ 7 ข้อ | idempotent? |
|---|---|---|
GET /recipes · GET /recipes/:id | ✅ noun, พหูพจน์, lowercase | ✅ safe ด้วย |
POST /recipes | ✅ ตรงตามที่หน้า 22 บอก | ❌ ต้องระวังการยิงซ้ำ |
PUT /recipes/:id | ✅ | ✅ |
DELETE /recipes/:id | ✅ — และนี่คือเส้นที่ต้องตัดสินใจ 404 vs 204 | ✅ |
POST /recipes/:id/favorite | ✅ relationship อยู่ที่ path (กฎ 5) | ควรทำให้ idempotent — กดถูกใจซ้ำไม่ควรได้ 2 ดาว |
DELETE /recipes/:id/favorite | ✅ | ✅ |
POST /recipes/:id/rating | ✅ | ขึ้นกับกติกา: 1 คน 1 คะแนน → ควรเป็น PUT ด้วยซ้ำ |
GET /users/:id · PUT /users/:id | ✅ | ✅ |
GET /login · GET /callbackPOST /exchange · GET /logout | แหกกฎ 1 และ 7 เต็ม ๆ — เป็น verb ล้วน | — |
แถวสุดท้ายไม่ใช่ความผิดพลาด — endpoint กลุ่ม auth คือข้อยกเว้นที่โลกยอมรับกันทั่วไป
เพราะ /login ไม่ได้กำลัง "สร้าง resource" มันคือขั้นตอนของโปรโตคอล
(OAuth เรียกชื่อพวกนี้มาตายตัวอยู่แล้ว ทีมคุณเปลี่ยนชื่อเองไม่ได้)
นี่คือบทเรียนสำคัญของทั้งบท: กฎพวกนี้เป็นค่าตั้งต้นที่ดี ไม่ใช่กฎหมาย
— แต่คุณควรแหกกฎเพราะมีเหตุผลที่พูดออกมาได้ ไม่ใช่เพราะไม่รู้ว่ามีกฎ
ส่วนแถว POST /recipes/:id/favorite คือของจริงที่คุณจะเจอในโปรเจกต์นี้:
ผู้ใช้กดหัวใจแล้วเน็ตช้า เขากดซ้ำอีกที ถ้าคุณเขียนเป็น "เพิ่มแถวใน favorites"
เขาจะได้สองแถว — แต่ถ้าเขียนเป็น "ทำให้ (user, recipe) คู่นี้อยู่ในตาราง favorites"
กดกี่ครั้งก็ได้ผลเดียว · ความต่างอยู่ที่ประโยคที่คุณคิดในหัว ไม่ใช่ที่ syntax
1. URI บอกว่าของอะไร · method บอกว่าทำอะไร ·
query บอกว่าเอาชุดย่อยแบบไหน — ถ้าจำได้แค่นี้ กฎ 7 ข้อจะตามมาเอง
2. idempotent วัดที่สถานะปลายทางของ server
ไม่ใช่ที่ status code — DELETE ที่ตอบ 204 กับ 404 idempotent เท่ากัน
3. breaking change ที่แพงที่สุดไม่ทำให้เกิด error —
มันคือการเปลี่ยนความหมาย ของ field ที่หน้าตาเหมือนเดิมทุกประการ
ทดสอบความเข้าใจ
1. สไลด์หน้า 35 ติด ✅ ไว้เหนือ POST /users/create กับ POST /users/123/delete — ข้อไหนอธิบายสถานการณ์นี้ได้ถูกต้อง?
กฎข้อ 7 เขียนไว้เองว่า "อย่าใส่ Action ใน URI ถ้า HTTP Method สื่อได้แล้ว"
— create กับ delete มี POST/DELETE สื่อให้อยู่แล้ว
ตัวอย่างที่ติด ✅ จึงขัดกับหัวข้อของตัวเอง และขัดกฎข้อ 1 (noun ไม่ใช่ verb) ด้วย
หลักฐานยืนยันอยู่ในสไลด์ชุดเดียวกัน หน้า 22 (REST – Method) ที่เขียนว่า
// สร้าง User → POST /users และ // ลบ User 123 →
DELETE /users/123 ตรงกับคู่ที่หน้า 35 ติด ❌ ไว้พอดี
ตัวเลือกที่ 1 ผิดเพราะความอ่านง่ายมาจากการเดาได้ ซึ่งได้จากรูปแบบที่สม่ำเสมอ ไม่ใช่จากการเติม verb ·
ตัวเลือกที่ 3 ผิดเพราะสไลด์ตั้งกฎไว้เองแล้ว 7 ข้อ การบอกว่า "อะไรก็ได้" คือละเลยกฎที่ตัวเองเพิ่งสอน ·
ตัวเลือกที่ 4 ยกเหตุผลที่ไม่เกี่ยว — การลบด้วยเงื่อนไขใช้ query หรือ path param ได้ ไม่ต้องพึ่ง body
2. ยิง DELETE /users/1 สองครั้งติดกัน ครั้งแรกได้ 204 ครั้งที่สองได้ 404 — แปลว่า endpoint นี้ไม่ idempotent ใช่ไหม?
นิยามของ idempotent คือ "เรียกซ้ำแล้วผลลัพธ์สุดท้ายเหมือนเรียกครั้งเดียว"
ซึ่ง "ผลลัพธ์สุดท้าย" หมายถึงสถานะบน server — หลังยิงหนึ่งครั้งกับสิบครั้ง
สถานะเหมือนกันคือ "ไม่มี user 1" จึง idempotent
ผมรันทั้งสองแบบให้ดูในหัวข้อ 7: แบบตอบ 204/404 กับแบบตอบ 204/204
idempotent เท่ากันทั้งคู่ ต่างกันแค่ว่าจะเล่าเรื่องให้ client ฟังยังไง
ตัวเลือกที่ 2 และ 3 ผิดที่จุดเดียวกัน คือเอา status code มาเป็นเกณฑ์วัด ·
ตัวเลือกที่ 4 สรุปถูกแต่ให้เหตุผลผิดหนัก — DELETE ไม่ใช่ safe method
(safe = ไม่เปลี่ยนอะไรเลย มีแค่ GET/HEAD) มันเปลี่ยนข้อมูลจริง เพียงแต่เปลี่ยนซ้ำแล้วผลเท่าเดิม
3. client ยิง POST /payments แล้ว timeout ไปเฉย ๆ ไม่รู้ว่าเงินถูกตัดไปหรือยัง — ทางแก้ที่ถูกต้องคือข้อไหน?
ผมรันให้ดูแล้วว่า POST /payments เปล่า ๆ ยิงสามครั้งได้
pay_1, pay_2, pay_3 และ charged_total=1500 —
สำเร็จ 201 ครบทั้งสามครั้ง ไม่มี error สักตัว
พอใส่ Idempotency-Key: 8f3c-01 เหมือนกันทั้งสามครั้ง ได้ pay_4
เท่ากันหมดและ charged_total ขยับครั้งเดียว · สำคัญคือ key ต้องสร้างครั้งเดียว
ตอนผู้ใช้กดปุ่ม ถ้าสร้างใหม่ทุก retry จะไม่ต่างจาก POST ธรรมดาเลย
ตัวเลือกที่ 1 ผิดเพราะ IP+เวลาเป็นการเดา — ผู้ใช้คนละคนหลัง NAT เดียวกันจ่ายพร้อมกันได้จริง ·
ตัวเลือกที่ 2 ผิดหนักที่สุด: GET ต้อง safe คือห้ามเปลี่ยนอะไร เอามาตัดเงินไม่ได้
และ browser/proxy จะ cache มันด้วย ·
ตัวเลือกที่ 4 ผิดเพราะจะบล็อกคนที่ตั้งใจจ่ายยอดเดิมซ้ำจริง ๆ (เช่นเติมเงิน 500 สองรอบติดกัน)
4. ทีมกำลังจะแก้ response ของ GET /recipes/:id — การเปลี่ยนแบบไหนทำให้ client เก่าพังแบบเงียบที่สุด คือไม่มี error ให้เห็นเลยสักบรรทัด?
ผมรัน json.Unmarshal ทั้งสี่แบบใส่ struct เดิม:
เปลี่ยนหน่วย → err=<nil> ได้ CookingTime:1800
— เลขจำนวนเต็มหน้าตาปกติทุกประการ ไม่มีอะไรผิดสังเกตเลย ลูกค้าเห็น "1800 นาที" ·
เปลี่ยน type → error ชัด ๆ ว่า
cannot unmarshal string into Go struct field Recipe.cooking_time of type int
ซึ่งโชคดีที่สุดเพราะรู้ตัวทันที ·
ลบ field → err=<nil> แต่ได้ CookingTime:0
ซึ่งเงียบเหมือนกันแต่ยังพอมีคนเอะใจว่าทำอาหาร 0 นาทีได้ยังไง ·
เพิ่ม field → err=<nil> ค่าเดิมครบ = ไม่ breaking
(ยกเว้น client เปิด DisallowUnknownFields ซึ่งจะได้
json: unknown field "difficulty")
ทั้งข้อ 2 3 4 เป็น breaking หมด แต่คำถามถามหาอันที่เงียบที่สุด — คือข้อที่ค่าผ่านเข้าไปได้
โดยดูสมเหตุสมผล
ภารกิจนี้ยังไม่ต้องเขียน HTTP server (รอบทเรียน 37) —
ทั้งหมดทำได้ด้วยกระดาษหนึ่งแผ่นกับไฟล์เทส Go หนึ่งไฟล์ที่ใช้แค่ encoding/json
เริ่มจากศูนย์แบบนี้ — ข้อ 1–5 เขียนลงกระดาษ/ไฟล์ .md ก็พอ
ส่วนข้อ 6–7 ต้องมีโปรเจกต์ Go:
$ mkdir wongnok-contract && cd wongnok-contract
$ go mod init wongnok-contract
$ touch recipe_test.go # ไฟล์เดียวพอ ไม่ต้องมี main.go
$ go test ./... # รันจากในโฟลเดอร์นี้
- ตรวจ spec ด้วยกฎ 7 ข้อ: เขียน endpoint ทั้งหมดของ Wongnok
(
recipes,favorite,rating,users, auth) แล้วทำเครื่องหมายว่าข้อไหนแหกกฎข้อไหน
ต้องอธิบายให้ได้ว่าทำไมGET /loginถึงยอมรับได้ แต่POST /recipes/createยอมไม่ได้ - เติมกฎที่สไลด์ไม่ได้ให้มา: spec มีแค่ "ค้นสูตรจากชื่อเมนู/วัตถุดิบ/เวลา/ความยาก"
— ออกแบบ
GET /recipesให้รับตัวกรองพวกนี้ครบ
ใช้กฎข้อ 5 กับข้อ 6 ตัดสินว่าอะไรไป path อะไรไป query แล้วเขียน URL เต็ม ๆ ออกมาหนึ่งเส้นที่กรองสามอย่างพร้อมกัน - ทำตาราง idempotency: ทุก endpoint ตอบสามช่อง —
safe? · idempotent? · ยิงซ้ำแล้วเกิดอะไร
ข้อที่ต้องคิดหนักที่สุด:POST /recipes/:id/rating— ถ้ากติกาคือ 1 คน 1 คะแนน มันควรเป็นPOSTหรือPUT - ตัดสินใจเรื่อง DELETE: เลือกว่า
DELETE /recipes/:idยิงซ้ำจะตอบ 404 หรือ 204 แล้วเขียนเหตุผลหนึ่งบรรทัด — เหตุผลต้องอ้างถึงว่า "ใครเป็นคนเรียก endpoint นี้" ไม่ใช่ "ผมชอบแบบนี้" - เขียน contract 5 ช่องของ
POST /recipesให้ครบ (request body / response status / response body / error status / error body)
แล้วตอบว่า ถ้าผู้ใช้กดปุ่ม "บันทึกสูตร" สองครั้งเพราะเน็ตช้า จะเกิดอะไรขึ้น และคุณจะแก้ด้วยวิธีไหน - เขียนเทสพิสูจน์: สร้าง
RecipeResponseเวอร์ชัน v1 หนึ่ง struct แล้วเขียน table-driven test (บทเรียน 16) ที่json.UnmarshalJSON ของ "v2" ใส่ struct v1 อย่างน้อย 4 เคส: เพิ่ม field · ลบ field · เปลี่ยน type · เปลี่ยนหน่วย
assert ให้ครบสองอย่าง: เคสไหนerr != nilและ เคสไหนerr == nilแต่ค่าที่ได้ผิด
เทสนี้คือเอกสารที่บอกทีมคุณว่าอะไรคือ breaking change โดยไม่ต้องเถียงกัน
รู้ได้ยังไงว่าทำถูก:go test ./...ขึ้นokโดยที่เคส "เปลี่ยน type" เป็นเคสเดียวที่คุณ assert ว่าerr != nilส่วนเคส "ลบ field" กับ "เปลี่ยนหน่วย" assert ว่าerr == nilแต่ค่าที่ได้ ไม่เท่ากับค่าที่ควรจะเป็น - ท้าทาย: เติม
dec.DisallowUnknownFields()เข้าไปในเทสเคส "เพิ่ม field" — ทายก่อนรันว่าเคสนี้จะยังผ่านไหม แล้วอธิบายว่าผลที่ได้ เปลี่ยนคำตอบของข้อ "อะไรคือ breaking change" ไปยังไง
คำถามที่ต้องถามคือ "ผลลัพธ์ปลายทางที่ถูกต้องคืออะไร" ไม่ใช่ "ผู้ใช้ทำกริยาอะไร"
ถ้ากติกาคือ 1 คน ให้คะแนนสูตรหนึ่งได้ครั้งเดียว (ให้ใหม่ = ทับของเดิม)
แปลว่าคำสั่งจริง ๆ คือ "ทำให้คะแนนของ (ผู้ใช้คนนี้, สูตรนี้) เท่ากับ 4"
ซึ่งเป็นการแทนที่ ไม่ใช่การสร้างเพิ่ม →
PUT /recipes/:id/rating ตรงความหมายกว่า และได้ idempotency มาฟรี
แต่ถ้ากติกาคือ ให้คะแนนกี่ครั้งก็ได้ แล้วเอามาเฉลี่ยกัน
คำสั่งคือ "เพิ่มคะแนนใหม่หนึ่งใบ" → POST ถูกแล้ว
แต่คุณต้องรับผิดชอบเคสกดซ้ำเองด้วย Idempotency-Key
ประเด็นคือ method ถูกเลือกจากความหมายของคำสั่ง ไม่ใช่จากชื่อปุ่มบนหน้าจอ
💬 ข้อ 6 คือข้อที่ผมอยากเห็นผลที่สุด — เพราะเทสนั้นจะกลายเป็นคำตอบถาวร
ให้กับคำถามที่ทีมคุณจะเถียงกันอีกหลายรอบว่า "แก้แค่นี้ต้องขึ้น v2 ไหม"
และถ้าคุณลองรันแล้วได้ผลไม่ตรงกับที่เขียนไว้ในบทนี้ ส่ง output มาได้เลย
ผมอยากรู้ว่าต่างกันตรงไหน (ทุกผลรันในหน้านี้มาจาก Go 1.26.5 darwin/arm64)
อีกอย่าง — ถ้าคุณเจอจุดอื่นในสไลด์ที่รู้สึกว่ามันขัดกันเอง อย่าเพิ่งคิดว่าตัวเองอ่านผิด
เอามาทาบกับกฎแบบที่เราทำกับหน้า 35 แล้วมาเล่าให้ฟัง
บทถัดไปเราจะหยุดออกแบบบนกระดาษ แล้วเปิด server จริงตัวแรกด้วย
net/http — บทเรียน 37
อ้างอิง
- RFC 9110 §9.2.2 — Idempotent Methods · §9.2.1 Safe Methods — นิยามทางการของ safe/idempotent และประโยคสำคัญที่ว่า status code ที่ต่างกันไม่ได้ทำให้เลิก idempotent
- RFC 3986 §6.2.2.1 — Case Normalization — ทำไม path ถึงสนตัวพิมพ์เล็กใหญ่ แต่ scheme/host ไม่สน
- MDN — HTTP request methods · 204 No Content — ตารางสรุป safe / idempotent / cacheable ของทุก method
- IETF Internet-Draft — The Idempotency-Key HTTP Header Field — ร่างมาตรฐานของ header ตัวนี้ (ย้ำว่ายังเป็น draft)
- Stripe API — Idempotent requests — ตัวอย่างการใช้จริงระดับ production รวมถึงอายุของ key และการผูก key กับผู้ใช้
- pkg.go.dev —
Decoder.DisallowUnknownFields· บทเรียน 13 — พฤติกรรม default ของjson.Unmarshalกับ field ที่ไม่รู้จัก - Google — API Design Guide: Resource Oriented Design — อีกสำนักหนึ่งที่บังคับให้ทุก action กลายเป็น resource ใช้เทียบกับกฎข้อ 7 ได้ดี