บทเรียน 33 · Day 8 · REST

REST — ทุกอย่างคือ resource

~14 นาที · ต้องมี: บทเรียน 13 (JSON กับ struct tag)

32 บทที่ผ่านมาเราคุยกับข้อมูลผ่าน ฟังก์ชันในโปรแกรมเดียวกัน — เรียกผิดคอมไพเลอร์ด่าให้ทันที
ตั้งแต่บทนี้ไป คนเรียกอยู่คนละเครื่อง ไม่มีคอมไพเลอร์คนกลาง เหลือแค่ข้อความสองก้อนวิ่งข้ามสาย — คำขอ กับ คำตอบ
REST คือชุดข้อตกลงว่าข้อความสองก้อนนั้นควรหน้าตายังไง บทนี้จะพิสูจน์ให้ดูสองเรื่องที่คนใหม่พลาดเสมอ: server จำอะไรไม่ได้เลยจริง ๆ และ PUT กินอีเมลของ user หายไปได้โดยไม่มี error สักบรรทัด

📐 บทนี้ไม่มีโค้ด Go ให้พิมพ์ตาม — เป็นบทออกแบบ ทุกตัวอย่างจึงเป็น request/response ที่เป็นข้อความล้วน
แต่ผลลัพธ์ทุกก้อนในหน้านี้ยิงจริงใส่ server จริง (Go 1.26.5 darwin/arm64, net/http ล้วน ไม่มี framework, ยิงด้วย curl 8.13.0)
โค้ดฝั่ง server ที่ทำให้เกิดผลพวกนี้จะได้เขียนเองในบทถัด ๆ ไปของ Day 8
คุณจึงยังรันตามไม่ได้ในบทนี้ — อ่านผลที่แปะไว้ให้เข้าใจก่อนพอ

อ่านบรรทัด curl ยังไง — curl คือโปรแกรมบน terminal ที่ยิง HTTP request ออกไปหนึ่งครั้งแล้วพิมพ์คำตอบออกมา ใช้แทน browser ตอนทดสอบ API
-X PUT = เลือก method (ไม่ใส่ = GET) · -d '…' = ส่ง body ก้อนนี้ไปด้วย · -H 'ชื่อ: ค่า' = แนบ header หนึ่งบรรทัด · -i = ให้พิมพ์ header ของคำตอบ ออกมาด้วย ไม่ใช่แค่ body
เพราะงั้นตัวอย่างที่ขึ้นต้นด้วย HTTP/1.1 200 OK คือตัวอย่างที่ผมใส่ -i ส่วนตัวอย่างที่เห็นแต่ JSON คือไม่ได้ใส่

1 · API คืออะไร — และทำไมมือถือถึงไม่ยิง SQL ตรง ๆ

ลองคิดแบบไร้เดียงสาที่สุดก่อน: เรามี PostgreSQL อยู่แล้ว แอปมือถือก็แค่ต่อเข้า database ตรง ๆ แล้ว SELECT * FROM recipes เอง จบ ไม่ต้องมี server ตรงกลางให้เปลืองแรง
ฟังดูเข้าท่า จนกว่าจะถามสี่คำถามนี้:

ถามว่าถ้าให้ client ต่อ DB ตรง ๆ
รหัส DB ไปอยู่ที่ไหน ต้องฝังอยู่ในแอปที่ติดตั้งบนมือถือของคนทั้งประเทศ — ใครแกะไฟล์แอปออกมาก็ได้รหัสไปเต็ม ๆ และเปลี่ยนรหัสทีต้องบังคับให้ทุกคนอัปเดตแอป
กฎธุรกิจอยู่ที่ไหน "แก้สูตรได้เฉพาะเจ้าของสูตร" ต้องเขียนซ้ำในแอป iOS, แอป Android และเว็บ — สามที่ สามคนเขียน สามแบบ และไม่มีใครบังคับให้ตรงกัน
เปลี่ยน schema แล้วไง
(schema = โครงของตารางใน database — มีคอลัมน์อะไร ชื่อว่าอะไร เก็บชนิดไหน)
เปลี่ยนชื่อคอลัมน์เดียว แอปทุกเวอร์ชันที่ยังมีคนใช้อยู่พังพร้อมกันทันที และเราสั่งให้ทุกคนอัปเดตแอปไม่ได้
ใครกันคนที่พิมพ์คำสั่ง ทุกคนที่ถือรหัสส่ง SQL อะไรก็ได้ — รวมถึง DELETE FROM users

API คือกำแพงที่แก้ทั้งสี่ข้อพร้อมกัน — เราไม่เปิด database ให้โลกภายนอก แต่เปิดรายการคำขอที่อนุญาตให้แทน ข้างในกำแพงจะเป็น PostgreSQL, MySQL หรือไฟล์ JSON ก็เรื่องของเรา ไม่มีใครข้างนอกต้องรู้

  แบบไร้เดียงสา                        แบบที่ใช้ได้จริง

  📱 แอป ──── SQL ────▶ 🗄️ DB        📱 แอป ─┐
  💻 เว็บ ──── SQL ────▶ 🗄️ DB        💻 เว็บ ─┼─ HTTP ─▶ 🧱 API ─ SQL ─▶ 🗄️ DB
  🤖 บอท ──── SQL ────▶ 🗄️ DB        🤖 บอท ─┘         (กฎธุรกิจ+สิทธิ์
                                                       อยู่ตรงนี้ที่เดียว)
  รหัส DB กระจายอยู่ 3 ที่                รหัส DB อยู่ที่เดียว
  กฎธุรกิจเขียนซ้ำ 3 รอบ                  ใครแก้ schema ก็แก้แค่หลังกำแพง

ถ้าประโยคนี้คุ้น ๆ ก็เพราะมันคือเรื่องเดียวกับ บทเรียน 10 เป๊ะ ๆ — interface คือสัญญาที่บอกว่า "เรียกอะไรได้" โดยไม่บอกว่า "ข้างในทำยังไง"
API ก็คือ interface ตัวหนึ่ง เพียงแต่คนเรียกอยู่คนละเครื่อง และไม่มีคอมไพเลอร์คอยตรวจให้ว่าเรียกถูก — ความผิดพลาดทุกอย่างจะไปโผล่ตอน runtime ในมือของลูกค้าจริง ๆ เท่านั้น

2 · จะคุยกันด้วย protocol อะไร — และทำไมคอร์สนี้เลือก HTTP

protocol แปลว่า "ข้อตกลงว่าสองเครื่องจะส่งข้อความหากันด้วยหน้าตาแบบไหน" — เทียบได้กับการตกลงว่าจะเขียนจดหมายด้วยภาษาอะไรและวางหัวจดหมายตรงไหน ก่อนจะเริ่มเขียนเนื้อความ
"คุยข้ามเครื่อง" ไม่ได้มีวิธีเดียว สี่แบบที่เจอบ่อยที่สุดในงานจริงมีจุดขายคนละอย่าง — อ่านตารางนี้เพื่อรู้ว่ามันมีอยู่ก็พอ ไม่ต้องจำรายละเอียด:

protocolรูปแบบการคุยเด่นตรงไหนราคาที่จ่าย
HTTP (+REST) ถาม 1 ครั้ง ตอบ 1 ครั้ง แล้วตัดสาย ทุกอย่างในโลกพูดได้ — browser, curl, มือถือ, proxy, CDN · debug ด้วยตาเปล่าได้เพราะเป็นตัวหนังสือ อยากได้ข้อมูล 3 ก้อนต้องยิง 3 ครั้ง · server ทักไปหา client เองไม่ได้
GraphQL ยิงครั้งเดียว แต่ client เขียนเองว่าอยากได้ field ไหนบ้าง หน้าจอเดียวดึงของหลายก้อนได้ในนัดเดียว ไม่ได้ field เกินมาเปล่า ๆ ฝั่ง server ซับซ้อนขึ้นมาก · cache ยากเพราะทุก request หน้าตาไม่เหมือนกัน
gRPC เรียกฟังก์ชันข้ามเครื่อง ส่งข้อมูลเป็นไบต์ (ไม่ใช่ตัวหนังสือ) เร็วและกินแบนด์วิดท์น้อย · มีไฟล์สัญญา (.proto) ที่ generate โค้ดให้ทั้งสองฝั่ง เปิดดูด้วยตาเปล่าไม่ได้ · browser เรียกตรง ๆ ไม่ได้ · เหมาะกับ service คุยกันเองหลังบ้าน
WebSocket เปิดสายค้างไว้ ทั้งสองฝั่งพูดเมื่อไหร่ก็ได้ chat, ราคาหุ้นวิ่ง, ตำแหน่งคนส่งอาหาร — เรื่องที่ server ต้องเป็นฝ่ายทัก ต้องคาสายไว้ทุกคน = เปลืองทรัพยากรและ scale ยากกว่ามาก

คอร์สนี้ใช้ HTTP ด้วยเหตุผลข้อเดียวที่สำคัญกว่าข้ออื่นรวมกัน: มันเป็นตัวหนังสือที่คุณอ่านออก
เวลาระบบพัง คุณเปิดดูได้ทั้ง request และ response ด้วยตาเปล่า ทดสอบด้วย curl บรรทัดเดียวได้โดยไม่ต้องมีเครื่องมือพิเศษ — สำหรับคนกำลังเรียน นี่มีค่ามากกว่าความเร็ว

3 · REST คืออะไร — สี่หลักที่ต้องจำ

HTTP บอกแค่ว่า "ข้อความต้องมีหน้าตายังไงถึงจะส่งได้" แต่ไม่ได้บอกว่าควรตั้งชื่ออะไร
ถ้าไม่มีข้อตกลงเพิ่ม ทีมสามคนจะได้ API สามสไตล์ในโปรเจกต์เดียว:

❌ ไม่มีข้อตกลง — ต้องเปิดคู่มือทุกครั้ง

POST /getUserById
POST /user_delete
GET  /updateUserName?id=1&n=x
POST /api/DoRecipeSearch

ทุกอันเป็น POST หมด ทุกอันตั้งชื่อคนละแบบ
อยากรู้ว่าลบ user ยังไง = ต้องไปถามคนเขียน

✅ REST — เดาถูกโดยไม่ต้องถามใคร

GET    /users/1
DELETE /users/1
PATCH  /users/1
GET    /recipes?q=x

เห็น GET /users/1 ครั้งเดียว
ก็เดา GET /recipes/1 และ DELETE /recipes/1 ได้เอง

ก่อนอ่านตารางถัดไป ขอนิยามสองคำที่จะใช้ตลอดทั้ง Day 8 ก่อน:
resource = "ของ" หนึ่งอย่างที่ API เปิดให้ยุ่งด้วย — เป็นได้ทั้งกอง (/recipes = สูตรทั้งหมด) และชิ้นเดียว (/recipes/7 = สูตรที่ 7)
endpoint = หนึ่งคู่ของ method + path ที่ server รับ — GET /recipes/7 คือหนึ่ง endpoint ส่วน DELETE /recipes/7 เป็นอีก endpoint หนึ่ง ถึงจะ path เดียวกันก็ตาม

REST ยืนบนสี่หลักนี้ — สามข้อแรกเป็นวิธีทำ ข้อสุดท้ายคือผลลัพธ์ที่เราต้องการจริง ๆ:

หลักแปลเป็นภาษาคนทดสอบตัวเองว่าทำได้ไหม
resource-based URL ชี้ไปที่ ของ ไม่ใช่ การกระทำ — ตั้งชื่อด้วยคำนามเสมอ อ่าน URL ออกเสียงแล้วได้คำนามไหม? /users/1 ✅ · /getUser ❌
stateless server ไม่จำอะไรจาก request ก่อนหน้าเลย ทุก request ต้องพกของมาเองครบ ถ้ามี server 5 ตัวแล้ว request นี้สุ่มไปโดนตัวไหนก็ได้ ผลจะเหมือนกันไหม?
ใช้ method ให้ตรงความหมาย "จะทำอะไรกับของชิ้นนั้น" บอกด้วย HTTP method ไม่ใช่บอกด้วยชื่อ URL ปิดชื่อ URL ทิ้ง เหลือแค่ method — ยังบอกได้ไหมว่านี่คืออ่านหรือลบ?
uniform interface ทุก resource ในระบบใช้กติกาชุดเดียวกันหมด ไม่มีข้อยกเว้นเฉพาะตัว คนที่เพิ่งเข้าทีมวันนี้ เดา endpoint ที่ยังไม่เคยเห็นถูกไหม?

คุณค่าที่แท้จริงของ REST คือข้อสุดท้าย — สามข้อแรกมีไว้เพื่อให้ข้อสี่เกิด
REST ไม่ได้ทำให้ API เร็วขึ้นแม้แต่นิดเดียว มันทำให้ API เดาได้ — และ "เดาได้" คือสิ่งที่ทำให้ทีม 10 คน เขียน endpoint 200 ตัวแล้วยังหน้าตาเหมือนคนเดียวเขียน

4 · resource-based: URL คือคำนาม ไม่ใช่คำกริยา

กฎมีข้อเดียว: URL บอกว่า "ของชิ้นไหน" · method บอกว่า "จะทำอะไรกับมัน"
คนใหม่มักเผลอเอาคำกริยาไปยัดใน URL เพราะมันเป็นวิธีที่เราตั้งชื่อฟังก์ชันมาตลอด 32 บท (getUser, deleteUser) — แต่ HTTP มีช่องสำหรับคำกริยาแยกไว้ให้แล้ว คือ method
พอเขียนคำกริยาไว้สองที่ มันจะขัดกันเองได้ทันที: POST /recipes/7/delete อ่านฝั่ง method ว่า "สร้าง" แต่อ่านฝั่ง URL ว่า "ลบ" — แล้วเครื่องมือที่อ่าน method (เช่นตัวที่ตัดสินใจว่า retry ได้ไหม) ก็เชื่อฝั่งที่ผิด

🏦 เทียบให้เห็นภาพ — ใบฝาก-ถอนที่เคาน์เตอร์ธนาคาร

ใบฝาก-ถอนที่ธนาคารมีสองช่องแยกกันคนละบรรทัด
· ช่อง เลขที่บัญชี — บอกว่า "จะยุ่งกับบัญชีไหน"
· ช่อง รายการ — ติ๊กว่า "ฝาก" หรือ "ถอน"
ไม่มีใครเขียนเลขบัญชีว่า 1234567-ถอนเงิน เพราะช่องสำหรับคำว่า "ถอน" มีอยู่แล้วต่างหาก

URL กับ method แบ่งหน้าที่กันแบบเดียวกันเป๊ะ — /recipes/7 คือช่องเลขที่บัญชี ส่วน DELETE คือช่องรายการ
เขียน POST /recipes/7/delete ก็เท่ากับติ๊กช่องรายการว่า "ฝาก" แล้วเขียนคำว่า "ถอน" ต่อท้ายเลขบัญชี — แล้วจะให้เชื่อช่องไหน

ที่ไม่เหมือนกัน: ที่ธนาคารมีคนอ่านใบ ถ้ากรอกขัดกันเขาเงยหน้าถามคุณได้ แต่ HTTP ไม่มีใครถาม — เครื่องมือระหว่างทางอ่านแค่ช่อง method แล้วทำตามนั้นทันที
อีกอย่าง: รายการที่ธนาคารเพิ่มใหม่ได้เรื่อย ๆ ส่วน method ของ HTTP เป็นชุดที่ทั้งโลกตกลงกันไว้แล้ว · ตั้งคำใหม่เองได้ในทางเทคนิค แต่ไม่มีใครทำ เพราะ proxy · cache · library ระหว่างทางไม่รู้จักคำที่คุณคิดขึ้นเอง

อยากทำ❌ คำกริยาใน URL✅ คำนาม + method
ดูสูตรทั้งหมดGET /getAllRecipesGET /recipes
ดูสูตรที่ 7GET /recipe/detail?id=7GET /recipes/7
สร้างสูตรใหม่POST /createRecipePOST /recipes
ลบสูตรที่ 7POST /recipes/7/deleteDELETE /recipes/7
ดูสูตรของ user 3GET /getRecipesByUser?u=3GET /users/3/recipes

สังเกตคอลัมน์ขวา: มีชื่อ resource อยู่แค่สองคำทั้งตาราง (/recipes กับ /users) ส่วนคอลัมน์กลางมีชื่อไม่ซ้ำกันเลยห้าอัน
นี่คือเหตุผลที่ REST ทำให้ระบบเล็กลงในหัวคน — จำนวนชื่อที่ต้องจำ โตตามจำนวน "ของ" ไม่ใช่ตามจำนวน "สิ่งที่ทำได้"

ทำไมถึงใช้พหูพจน์ /users ไม่ใช่ /user — เพราะ /users แปลว่า "ตู้เก็บ user ทั้งกอง" ซึ่งอ่านแล้วเข้ากันได้ทั้งสองแบบ: GET /users = ขอทั้งกอง · GET /users/1 = ขอตัวที่ 1 จากกอง
จะเลือกเอกพจน์ทั้งระบบก็ไม่ผิดหลัก REST — ที่ผิดคือปนกัน เช่นมี /user กับ /recipes อยู่ในระบบเดียวกัน เพราะแบบนั้นคนใช้เดาไม่ถูกอีกต่อไป ซึ่งพังตรงหลักข้อสี่พอดี

🗣 ศัพท์ที่เพิ่งเรียนไป

คำพวกนี้คือชื่อที่วงการใช้เรียกสิ่งที่คุณเพิ่งเข้าใจไป — ใช้คำให้ตรง แล้วจะได้คำตอบที่ตรง ทั้งจาก dev คนอื่นและจาก AI

resource
"ของ" ที่ API เปิดให้ยุ่งด้วย · เวลาคุยงานให้ถามว่า "resource หลักของระบบนี้มีอะไรบ้าง" แทนที่จะถามว่า "มีหน้าอะไรบ้าง" — อีกฝ่ายจะรู้ทันทีว่าคุณกำลังถามถึงโครงของ API ไม่ใช่หน้าจอ
collection / item
"กอง" กับ "ชิ้น" · /recipes คือ collection · /recipes/7 คือ item หนึ่งชิ้นในกองนั้น · ประโยคที่ใช้ได้จริง: "endpoint นี้ยิงใส่ collection หรือใส่ item ตัวเดียว"
endpoint
หนึ่งคู่ของ method + path ที่ server รับ · เวลาแจ้งบั๊กให้บอกทั้งคู่ — "endpoint DELETE /users/{id} พัง" สื่อกว่า "หน้า user พัง" คนละโลก · หลายคนพูดสั้น ๆ ว่า "endpoint /users" หมายถึง path อย่างเดียวก็มี — ไม่ต้องไปแก้เขา แค่เวลาคุณพูดเองให้บอกทั้ง method + path จะไม่มีใครเดาผิด
CRUD
ย่อจาก Create · Read · Update · Delete = สี่อย่างพื้นฐานที่ทำกับข้อมูลได้ · ใน REST มันตรงกับ POST · GET · PUT/PATCH · DELETE ตามลำดับ · ประโยคที่จะได้ยินบ่อยคือ "ตัวนี้เป็น CRUD ธรรมดา" แปลว่าไม่มีตรรกะพิเศษอะไรซ่อนอยู่

5 · URI · path · path parameter — สามคำที่คนใช้ปนกัน

ก่อนคุยเรื่อง method ต้องแยกศัพท์สามคำนี้ให้ออกก่อน เพราะทั้ง Day 8 จะพูดถึงมันตลอด
ผมเปิด server แล้วดักอ่านไบต์ดิบที่ curl ส่งออกไปจริง ๆ — หน้าตาแบบนี้:

PATCH /users/42?fields=name HTTP/1.1
Host: 127.0.0.1:8793
User-Agent: curl/8.13.0
Accept: */*
Authorization: Bearer abc123
Content-Type: application/json
Content-Length: 21

{"name":"Somchai Jr"}

ทั้งหมดนี้คือ "หนึ่ง request" — ตัวหนังสือล้วน ๆ ไม่มีเวทมนตร์อะไรเลย แบ่งเป็นสามส่วนเรียงจากบนลงล่างเสมอ:

ทีนี้ซูมเข้าไปดูเฉพาะ "ที่อยู่" ในบรรทัดแรก — ผมเขียนแบบเต็มพร้อมชื่อ server ให้เห็นครบทุกชิ้น:

  https://api.wongnok.com/users/42/orders?status=paid&page=2
  └──────────┬───────────┘└───────┬──────┘└──────────┬──────────┘
        ที่อยู่ของ server          path              query string
     (ไม่ได้อยู่ในบรรทัดแรก        = "ของชิ้นไหน"       = "กรอง/เรียงยังไง"
      แต่อยู่ใน header Host)          │
                                     └─▶ /users/42/orders
                                                  ▲
                                          path parameter — ช่องว่างใน path
                                          ที่เปลี่ยนค่าได้ · เขียนเป็น /users/{id}/orders

  ทั้งบรรทัดรวมกัน = URI (ที่อยู่เต็มของ resource ชิ้นนั้น)

ฝั่ง server มองเห็นเป็นคนละชิ้นแยกกันจริง ๆ ไม่ใช่แค่การเรียกให้ต่างกัน — ผมยิง GET /users/42/orders?status=paid&page=2 ใส่ server อีกตัวที่พิมพ์ทุกอย่างที่มันเห็นออกมา (คนละตัวกับตัวที่พิมพ์ไบต์ดิบข้างบน จึงคนละพอร์ต):

Method      = "GET"
Host        = "127.0.0.1:8792"
URL.Path    = "/users/42/orders"
PathValue   = "42"
RawQuery    = "status=paid&page=2"
Query status= "paid"
RequestURI  = "/users/42/orders?status=paid&page=2"
header Accept           = "application/json"
header User-Agent       = "curl/8.13.0"

ชื่อทางซ้ายไม่ใช่ศัพท์ REST — เป็นชื่อช่องที่ Go เตรียมไว้ให้อ่าน ในบทถัด ๆ ไปของ Day 8 เราจะเรียกมันเองกับมือ ตอนนี้แค่รู้ว่ามันแยกกันจริงก็พอ
ที่ต้องสังเกตคือ PathValue = "42" — Go ได้เลข 42 มาเพราะ server ลงทะเบียนไว้ว่า path หน้าตาแบบ /users/{id}/orders แล้ว {id} คือช่องว่างที่รอรับค่า
ส่วน RawQuery คือ query ทั้งเส้นที่ยังไม่ได้แยก และ Query status คือค่าที่แยกออกมาแล้วเฉพาะตัว status

เส้นแบ่งที่ใช้ตัดสินใจได้จริง — ถ้าเอาส่วนนั้นออกแล้วไม่รู้ว่าพูดถึงของชิ้นไหน มันคือ path parameter (/users/orders เพียว ๆ ไม่มีความหมาย)
ถ้าเอาออกแล้วยังรู้ว่าพูดถึงของกองไหน แค่ได้มาเยอะขึ้น มันคือ query parameter (/users/42/orders ยังแปลว่า "order ของ user 42 ทั้งหมด")

📦 เทียบให้เห็นภาพ — จ่าหน้าซองพัสดุ กับโน้ตที่แปะข้างกล่อง

บนกล่องพัสดุมีข้อความสองแบบที่ทำหน้าที่คนละอย่าง
· ที่อยู่ — "หมู่บ้าน users บ้านเลขที่ 42 ตู้รับของ orders" นี่คือ path
· เลข 42 ในที่อยู่คือช่องที่เปลี่ยนได้ทุกใบ ลบทิ้งเมื่อไหร่คนส่งของไม่รู้จะไปบ้านไหน — นั่นคือ path parameter
· โน้ตแปะข้างกล่อง — "เอาเฉพาะกล่องที่จ่ายเงินแล้ว หน้า 2" คือ query string · ลอกโน้ตออกของก็ยังไปถึงบ้านหลังเดิม แค่ได้มาทั้งกองโดยไม่ได้คัด

ที่ไม่เหมือนกัน: จ่าหน้าซองพิมพ์ผิดนิดหน่อย บุรุษไปรษณีย์ยังเดาออกและส่งถึงบ้าน แต่ path ผิดแค่ตัวอักษรเดียว server ตอบ 404 ทันที ไม่มีใครเดาให้
และโน้ตข้างกล่องเป็นแค่คำขอ ส่วน query parameter ที่ server ไม่รู้จัก มันจะเมินทิ้งเงียบ ๆ ไม่มีคำเตือนอะไรกลับมาเลย

🗣 ศัพท์ที่เพิ่งเรียนไป
URI (กับคำว่า URL)
ที่อยู่เต็มของ resource ชิ้นนั้น · ในงานประจำวันคนพูดว่า "URL" กันเป็นส่วนใหญ่และไม่ผิด · ที่ต้องรู้คือถ้าใครพูดว่า "URI" เขาหมายถึงที่อยู่เต็มแบบในแผนภาพข้างบน (ชื่อ server + path + query) · ส่วนที่โผล่ในบรรทัดแรกของ request จริง ๆ มีแค่ path กับ query เพราะชื่อ server ถูกแยกไปอยู่ใน header Host
path parameter (path param)
ช่องว่างใน path ที่รับค่า เขียนเป็น /users/{id} · ประโยคที่ใช้ได้จริง: "endpoint นี้รับ id เป็น path param หรือ query param" — ถามแบบนี้อีกฝ่ายตอบได้ทันทีโดยไม่ต้องเปิดโค้ดดู
query string / query parameter
ส่วนที่อยู่หลัง ? ใช้ กรอง · เรียง · แบ่งหน้า · พูดว่า "ขอ query param สำหรับแบ่งหน้าด้วย" ชัดกว่าพูดว่า "ขอให้มันโหลดทีละหน้าได้"
header
บรรทัด ชื่อ: ค่า ที่เป็นข้อมูลประกอบของ request · เวลาไล่บั๊ก ประโยคที่ได้ยินบ่อยที่สุดคือ "ลองเช็ก header Content-Type ที่ส่งไปหรือยัง"
body / payload
ก้อนข้อมูลจริงที่ส่งไปกับ request (หรือส่งกลับมาใน response) · สองคำนี้ใช้แทนกันได้ในบทสนทนาทั่วไป — "payload ที่ส่งไปหน้าตาเป็นยังไง" ก็คือการขอดู body นั่นเอง

6 · หัวใจของบท — "stateless" แปลว่าอะไรจริง ๆ

คำนี้ถูกแปลผิดบ่อยที่สุดในบรรดาศัพท์ REST ทั้งหมด
stateless ไม่ได้แปลว่า "ห้ามเก็บข้อมูล" — เก็บลง database ได้เต็มที่ นั่นคืองานของมัน
มันแปลว่า: server ไม่จำอะไรเกี่ยวกับ ตัวคุณ ระหว่าง request สองอันติดกัน
request แต่ละอันมาถึงในสภาพคนแปลกหน้า เสมอ ต่อให้เพิ่งคุยกันไปเมื่อ 0.2 วินาทีที่แล้ว

🪪 เทียบให้เห็นภาพ — รปภ. หน้าตึกที่สลับคนใหม่ทุกครั้ง

ตึกออฟฟิศแห่งหนึ่งมี รปภ. หลายสิบคนสลับกันยืน และคุณไม่มีทางรู้ว่ารอบหน้าจะเจอคนไหน
ถึงจะเพิ่งแลกบัตรกับ รปภ. คนที่ 1 ไปเมื่อสองนาทีก่อน พอเดินกลับเข้ามาใหม่แล้วเจอคนที่ 7 เขาก็ยังขอดูบัตรอยู่ดี — ไม่ใช่เพราะเขากวน แต่เพราะเขาไม่ได้ยืนอยู่ตรงนั้นตอนคุณแลกบัตร
ทางออกที่ใช้ได้จริงมีอย่างเดียว: พกบัตรติดตัว แล้วยื่นให้ดูใหม่ทุกครั้ง

HTTP เป็นแบบนั้นทั้งระบบ — บัตรคือ token · การยื่นบัตรคือการแนบ header Authorization ไปกับทุก request

ที่ไม่เหมือนกัน: รปภ. คนเดิมจริง ๆ อาจจำหน้าคุณได้ แต่ server ที่ทำตามหลัก stateless ตั้งใจไม่จำ แม้จะเป็นตัวเดิมก็ตาม
และคำว่า "ไม่จำ" หมายถึงไม่จำว่าคุณเป็นใครเท่านั้น — ทะเบียนผู้เช่าที่เก็บไว้ในตึก (database) ยังอยู่ครบและเปิดดูได้ทุกเมื่อ

ผมเปิด server ที่นับ request ให้ดูด้วย แล้วยิงสามครั้งติดกันจาก terminal เดียวกัน
ครั้งที่สองผมแนบ token ไปด้วย — token คือข้อความสั้น ๆ ที่ server ออกให้ตอน login แล้ว client เก็บไว้ใช้เป็นบัตรประจำตัวในครั้งถัด ๆ ไป ส่งไปในบรรทัด header ชื่อ Authorization
ตัวเลขที่ตอบกลับมา (401, 200) คือ status code — ตัวเลขสามหลักที่สรุปว่าผลเป็นยังไง ความหมายของแต่ละตัวอยู่ใน บทเรียน 34 ตอนนี้อ่านแค่ว่า 200 = ผ่าน · 401 = ไม่รู้ว่าคุณเป็นใคร ก็พอ

$ curl -i http://127.0.0.1:8791/me                              # ครั้งที่ 1 ไม่ส่งอะไรมา
HTTP/1.1 401 Unauthorized
Content-Type: application/json; charset=utf-8
Content-Length: 99

{"error":"missing Authorization header","request_no":1,"server_knows":"nothing about who you are"}

$ curl -i http://127.0.0.1:8791/me -H 'Authorization: Bearer abc123'   # ครั้งที่ 2 ส่ง token
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 63

{"request_no":2,"token_seen":"Bearer abc123","user":"Somchai"}

$ curl -i http://127.0.0.1:8791/me                              # ครั้งที่ 3 ไม่ส่ง token อีก
HTTP/1.1 401 Unauthorized
...
{"error":"missing Authorization header","request_no":3,"server_knows":"nothing about who you are"}

ดู request_no ให้ดี: 2 แล้ว 3 — เป็น server ตัวเดียวกัน ยังไม่ได้ restart อะไรเลย
ครั้งที่ 2 มันเพิ่งรู้จักคุณเมื่อเสี้ยววินาทีก่อน ครั้งที่ 3 มันตอบ 401 หน้าตาย
นี่ไม่ใช่บั๊ก — นี่คือธรรมชาติของ HTTP: สิ่งเดียวที่ server รู้ คือสิ่งที่อยู่ใน request ก้อนที่กำลังอ่านอยู่ตอนนี้ ก้อนก่อนหน้าไม่มีอยู่ในโลกของมันแล้ว

นี่คือคำตอบว่าทำไมต้องแนบ token ไปทุก request — ไม่ใช่เพราะ framework บังคับ ไม่ใช่เพราะกลัวไม่ปลอดภัย แต่เพราะไม่มีที่ให้ server จำ
login ครั้งเดียวได้ token มา แล้ว token นั้นต้องเดินทางไปกับ ทุก request ต่อจากนั้น ในฐานะบัตรประจำตัวที่พกติดตัว ไม่ใช่ทะเบียนที่ฝากไว้ที่ server

"แล้ว session ที่เคยได้ยินล่ะ?" — เก็บ session ไว้ใน RAM ของ server ก็ทำได้ และมันจะทำงาน ตอนที่คุณรัน server ตัวเดียวบนเครื่องตัวเอง
แต่จำเรื่องจาก บทเรียน 32 ได้ไหม — ตอน deploy จริงเราไม่ได้รันโปรแกรมตัวเดียว เรารันสำเนาของโปรแกรมเดียวกันหลายตัว (สำเนาแต่ละตัวเรียกว่า instance) แล้วมีตัวกระจาย request สุ่มไปตัวไหนก็ได้
สำคัญตรงนี้: instance แต่ละตัวคือคนละ process มี RAM ของใครของมัน — ตัวที่ 1 จำอะไรไว้ ตัวที่ 7 มองไม่เห็นเลย

⚠️ อาการที่จะเจอถ้าเก็บ session ไว้ใน RAM ของ server

ลูกค้า login ผ่าน instance ตัวที่ 1 → ตัวที่ 1 จำได้ → กดปุ่มถัดไปโดนสุ่มไป instance ตัวที่ 7 → ตัวที่ 7 ไม่เคยเห็นหน้าคุณ → เด้งออกจากระบบทั้งที่เพิ่ง login เสร็จ
และมันจะพังแบบสุ่ม ๆ — บางคลิกผ่าน บางคลิกเด้ง ไม่มี error ในล็อก ไล่จับยากที่สุด
ที่โหดกว่านั้นคือบนเครื่อง dev ของคุณมันจะไม่พังเลยสักครั้ง เพราะที่นั่นมี instance เดียว
ทางออกคืออย่าให้ server ต้องจำ: state ที่ต้องข้าม request ให้ไปอยู่ที่อื่น — ใน token ที่ client พกมาเอง หรือในของกลางอย่าง Redis / database

🗣 ศัพท์ที่เพิ่งเรียนไป
stateless
server ไม่จำอะไรเกี่ยวกับตัวคุณระหว่าง request สองอัน · ประโยคที่ใช้ตัดสินใจได้จริงเวลาคุยกับทีม: "ถ้าเก็บอันนี้ไว้ใน memory ของ server มันจะไม่ stateless แล้วนะ พอ scale เป็นหลายตัวจะพัง"
token / Bearer token
ข้อความสั้น ๆ ที่ใช้เป็นบัตรประจำตัว แนบมาในบรรทัด Authorization: Bearer … · คำว่า Bearer แปลว่า "ใครถือก็ใช้ได้" ซึ่งเป็นเหตุผลว่าทำไมห้ามให้มันหลุด · ถามว่า "auth ของระบบนี้ใช้ token แบบไหน" จะได้คำตอบตรงกว่าถามว่า "login ยังไง"
session
ความจำเรื่อง "คุณคือใคร" ที่ฝากไว้ฝั่ง server · เก็บไว้ในของกลาง เช่น Redis ทำให้ instance ไหนก็รับงานต่อได้ — แก้ปัญหาในกล่องข้างบนได้จริง · แต่พูดให้ตรงคือระบบยัง"มี session อยู่" ไม่ได้แปลว่ากลายเป็น stateless · stateless แบบเคร่งคือให้ client พกทุกอย่างมาเองใน token
instance
สำเนาของโปรแกรมเดียวกันที่กำลังรันอยู่หนึ่งตัว · "ตอนนี้รันกี่ instance" คือคำถามแรกที่ควรถามเวลาเจอบั๊กที่พังแบบสุ่ม ๆ
sticky session
การตั้งค่าให้ผู้ใช้คนเดิมถูกส่งไป instance ตัวเดิมทุกครั้ง · เป็นพลาสเตอร์ปิดแผลของปัญหาในกล่องข้างบน — รู้จักชื่อไว้ เพื่อจะได้รู้ว่าถ้ามีคนเสนอวิธีนี้ ทางเลือกที่ดีกว่าคือทำให้ระบบ stateless ตั้งแต่แรก

7 · method 5 ตัว — และคู่ที่คนใหม่พลาดบ่อยที่สุด

URL บอกว่า "ของชิ้นไหน" แล้ว · ที่เหลือคือ "จะทำอะไรกับมัน" ซึ่งบอกด้วย method:

methodแปลว่าตัวอย่างบน /recipesมี body ไหม
GETขอดู — ห้ามเปลี่ยนอะไรGET /recipes · GET /recipes/7ไม่มี
POSTสร้างของใหม่ในกอง (id ให้ server ตั้งให้)POST /recipesมี — ข้อมูลของใหม่
PUTแทนที่ทั้งก้อน ด้วยของที่ส่งไปPUT /recipes/7มี — ต้องครบทุก field
PATCHแก้เฉพาะ field ที่ส่งไป ที่เหลือไม่แตะPATCH /recipes/7มี — เฉพาะที่จะแก้
DELETEลบของชิ้นนั้นDELETE /recipes/7ปกติไม่มี

สังเกตว่า POST ยิงใส่กอง (/recipes) ส่วน PUT / PATCH / DELETE ยิงใส่ชิ้น (/recipes/7) — เพราะจะสร้างของใหม่ เรายังไม่รู้ id ของมัน จึงชี้เจาะจงไม่ได้

ทีนี้มาถึงจุดที่คนใหม่พลาดกันแทบทุกคน — และมันไม่ใช่ความผิดพลาดที่คอมไพเลอร์หรือ test จะจับให้:

ทายก่อนรัน — user คนที่ 1 ตอนนี้มีข้อมูลครบสี่ช่อง แล้วผมยิง PUT ด้วย body ที่มีแค่ name

$ curl http://127.0.0.1:8791/users/1
{"id":1,"name":"Somchai","email":"somchai@example.com","role":"member"}

$ curl -X PUT http://127.0.0.1:8791/users/1 \
       -H 'Content-Type: application/json' \
       -d '{"name":"Somchai Jr"}'

บรรทัดสุดท้ายจะได้ status อะไร และหลังจากนั้น email กับ role ยังอยู่ไหม

$ curl -X PUT .../users/1 -d '{"name":"Somchai Jr"}'
{"id":1,"name":"Somchai Jr","email":"","role":""}

$ curl .../users/1                     # อ่านซ้ำเพื่อยืนยันว่าไม่ใช่แค่ response หลอกตา
{"id":1,"name":"Somchai Jr","email":"","role":""}

200 OK · ไม่มี error สักตัว · แต่อีเมลกับ role หายเกลี้ยง
เพราะ PUT แปลว่า "เอาก้อนนี้ไปวางแทนก้อนเดิม" — field ที่คุณไม่ได้ส่ง แปลว่าคุณตั้งใจให้มันว่าง ไม่ใช่ "ไม่ต้องแตะ"
ฝั่ง Go มันชัดมาก — handler คือฟังก์ชันที่ server เรียกเมื่อมี request เข้ามาตรง endpoint นั้น (จะได้เขียนเองในบทถัด ๆ ไปของ Day 8): handler ตัวนี้ decode JSON ลง User ที่เพิ่งประกาศใหม่ field ที่ไม่มีใน JSON เลยคง zero value ไว้ตามที่เรียนมาใน บทเรียน 2 — string ที่ไม่มีใครเซ็ตคือ "" แล้ว "" ก็ถูกเขียนทับลง store ตรง ๆ

ยิงแบบเดียวกันเป๊ะ ๆ แต่เปลี่ยนเป็น PATCH (หลังกู้ข้อมูลกลับมาแล้ว):

$ curl -X PATCH .../users/1 -d '{"name":"Somchai Jr"}'
{"id":1,"name":"Somchai Jr","email":"somchai@example.com","role":"member"}

ต่างกันที่ฝั่ง server ทำอะไรก่อน decode: PATCH อ่านของเดิมขึ้นมาก่อน แล้วให้ JSON ทับลงไปเฉพาะช่องที่ส่งมา — ช่องที่ไม่มีใน JSON จึงเก็บค่าเดิมไว้
สรุปที่ต้องจำไปทั้งชีวิต: ถ้า client ส่ง field มาไม่ครบแล้วคุณใช้ PUT — คุณกำลังลบข้อมูลลูกค้าโดยตอบ 200 กลับไปว่าสำเร็จ

PUT /users/1 = แทนที่ทั้งก้อน

ก่อน: {name:"Somchai", email:"s@ex.com", role:"member"}
ส่ง:  {name:"Somchai Jr"}
หลัง: {name:"Somchai Jr", email:"", role:""}

ใช้ได้ต่อเมื่อ client มีข้อมูลครบทุก fieldอยู่ในมือ เช่นฟอร์มแก้ไขที่โหลดของเดิมมาแสดงครบแล้ว

PATCH /users/1 = แก้เฉพาะที่ส่ง

ก่อน: {name:"Somchai", email:"s@ex.com", role:"member"}
ส่ง:  {name:"Somchai Jr"}
หลัง: {name:"Somchai Jr", email:"s@ex.com", role:"member"}

ใช้กับหน้าจอที่แก้ทีละช่อง เช่นปุ่ม "เปลี่ยนอีเมล" — ซึ่งเป็นกรณีที่พบบ่อยกว่ามากในงานจริง

อย่าอ่านกล่องซ้ายว่า "PUT คือของผิด" — PUT ไม่ได้ผิด และเราจะใช้มันจริงในบทถัด ๆ ไป
ที่ผิดคือเลือกไม่ตรงกับสิ่งที่ client มีอยู่ในมือ ณ ตอนกดปุ่ม
คำถามเดียวที่ใช้ตัดสิน: ตอนนี้ client ถือข้อมูลครบทั้งก้อนอยู่หรือเปล่า — ครบ → PUT ได้ · ไม่ครบ → ต้อง PATCH

🧊 เทียบให้เห็นภาพ — กระดานรายการของที่ต้องซื้อ หน้าตู้เย็น

บนกระดานหน้าตู้เย็นเขียนไว้สามบรรทัด: นม · ไข่ · ข้าวสาร
· PATCH = เดินไปลบเฉพาะบรรทัด "นม" แล้วเขียน "นมจืด" แทน อีกสองบรรทัดอยู่ครบเหมือนเดิม
· PUT = ลบกระดานทั้งแผ่น แล้วเขียนใหม่จากที่ถืออยู่ในมือ — ถ้าในมือมีแค่ "นมจืด" กระดานก็จะเหลือบรรทัดเดียว ไข่กับข้าวสารหายไปทั้งคู่

นี่คือเหตุผลที่ PUT ในตัวอย่างข้างบนทำให้ email กับ role กลายเป็นค่าว่าง — คุณไม่ได้สั่งให้ลบ แต่คุณส่ง "กระดานฉบับใหม่" ที่ไม่มีสองบรรทัดนั้นไปวางแทน

ที่ไม่เหมือนกัน: กระดานจริงลบแล้วยังเห็นรอยจาง ๆ และเดินไปถามคนในบ้านได้ว่าเมื่อกี้เขียนอะไรไว้
แต่ฝั่ง server ของเดิมหายจริง ไม่มีรอยให้ดู และมันยังตอบกลับมาว่า 200 OK เหมือนทุกอย่างเรียบร้อยดี
และกระดานจริงลบหมดทั้งแผ่นรวมหัวกระดาษ แต่ฝั่ง server id ไม่หาย เพราะ id มาจาก path (/users/1) ไม่ได้มาจาก body — สิ่งที่ถูกเขียนทับคือเฉพาะ field ที่อยู่ใน bodyเท่านั้น (ดูเฉลยข้างบน: email กับ role ว่าง แต่ "id":1 ยังอยู่)

🗣 ศัพท์ที่เพิ่งเรียนไป
full replacement (แทนที่ทั้งก้อน)
สิ่งที่ PUT สัญญาไว้ · ประโยคที่เคลียร์ปัญหาในทีมได้เลยคือ "endpoint นี้เป็น full replacement หรือ partial update" — ถามสั้น ๆ แค่นี้ก่อนต่อ API กัน ช่วยคุณจากบั๊กข้อมูลหายได้จริง
partial update (แก้บางส่วน)
สิ่งที่ PATCH ทำ · เวลาเขียนสเปกให้ระบุไปตรง ๆ ว่า "ให้ทำเป็น partial update" จะไม่มีใครตีความผิดทาง
zero value
ค่าเริ่มต้นที่ Go ใส่ให้ field ที่ไม่มีใครเซ็ต (string ได้ "") · เวลาเจออีเมลกลายเป็นช่องว่าง ประโยคที่ตรงจุดคือ "น่าจะเป็น zero value ทับลงไปตอน decode"
silent data loss
ข้อมูลหายโดยไม่มี error ฟ้องสักบรรทัด · นี่คือชื่อของอาการที่คุณเพิ่งเห็นกับตา — พูดคำนี้ตอนรีวิวโค้ดเมื่อไหร่ ทุกคนจะรู้ทันทีว่าเรื่องนี้ต้องแก้ก่อนเรื่องอื่น

8 · ยิงซ้ำแล้วโลกเปลี่ยนไหม — idempotent

เน็ตหลุดระหว่างรอคำตอบเป็นเรื่องปกติมาก แอปทุกตัวจึงยิงซ้ำอัตโนมัติ
คำถามที่ต้องตอบให้ได้ตั้งแต่ตอนออกแบบคือ: ยิงซ้ำแล้วเกิดอะไรขึ้น
คำว่า idempotent แปลว่า "ยิง 1 ครั้งกับยิง 10 ครั้ง สภาพสุดท้ายของข้อมูลเหมือนกัน"

ทายก่อนรัน — server เปล่า ๆ ยังไม่มี user เลย ผมยิง POST ด้วย body เดียวกันเป๊ะ ๆ สองครั้ง (จำลองเน็ตหลุดแล้ว retry)

$ curl -i -X POST http://127.0.0.1:8794/users -d '{"name":"Somchai"}'
$ curl -i -X POST http://127.0.0.1:8794/users -d '{"name":"Somchai"}'
$ curl http://127.0.0.1:8794/users

บรรทัดที่ 3 จะได้ user กี่คน?

# ครั้งที่ 1
HTTP/1.1 201 Created
Location: /users/1
{"id":1,"name":"Somchai"}

# ครั้งที่ 2 — body เดิมทุกตัวอักษร
HTTP/1.1 201 Created
Location: /users/2
{"id":2,"name":"Somchai"}

$ curl .../users
[{"id":1,"name":"Somchai"},{"id":2,"name":"Somchai"}]

ได้สองคน — เพราะ POST แปลว่า "เพิ่มของใหม่เข้ากอง" ยิงสองครั้งก็คือสั่งเพิ่มสองครั้ง ตรงตามที่มันสัญญาไว้ทุกประการ
(Location: ที่โผล่มาคือ header ที่ server ใช้บอกว่า "ของที่คุณเพิ่งสร้าง ไปดูได้ที่ path นี้" — ครั้งที่สองมันชี้ไปคนละที่ นั่นคือหลักฐานว่าได้ของใหม่มาอีกชิ้น ไม่ใช่ชิ้นเดิม)
POST จึงไม่ idempotent และนี่คือเหตุผลที่ปุ่ม "จ่ายเงิน" กดรัวสองทีแล้วเงินออกสองรอบได้จริง

เทียบกับ PUT: ผมยิง PUT /users/1 ด้วย body ที่มี field ครบทั้งก้อน (name + email + role) สองครั้งติดกัน แล้วอ่านกลับมาได้ {"id":1,"name":"Somchai","email":"somchai@example.com","role":"admin"} เท่ากันทั้งสองครั้ง — ไม่มี user เพิ่มขึ้นมา
เพราะ PUT แปลว่า "ทำให้ก้อนนี้เป็นแบบนี้" ไม่ใช่ "เพิ่มอีกก้อน" สั่งซ้ำอีกสิบครั้งมันก็เป็นแบบเดิมอยู่ดี

DELETE ก็ idempotent แต่มีจุดที่ทำให้คนสับสน — status ไม่เท่ากันทั้งสองครั้ง:

$ curl -i -X DELETE http://127.0.0.1:8794/users/1     # ครั้งแรก
HTTP/1.1 204 No Content

$ curl -i -X DELETE http://127.0.0.1:8794/users/1     # ยิงซ้ำ
HTTP/1.1 404 Not Found
Content-Type: application/json; charset=utf-8
Content-Length: 27

{"error":"user not found"}

ครั้งแรกไม่มี body ตามมาเลยเพราะ 204 แปลว่า "สำเร็จ แต่ไม่มีอะไรจะส่งกลับให้"
ตรงนี้ต้องแยกให้ชัด: idempotent พูดถึง "สภาพของข้อมูล" ไม่ใช่ "status ที่ตอบกลับ"
หลังยิงครั้งแรก user 1 หายไป · หลังยิงครั้งที่สอง user 1 ก็ยังหายไปอยู่ดี — สภาพสุดท้ายเหมือนกัน จึงถือว่า idempotent เต็มตัว ส่วน 404 เป็นแค่การรายงานว่า "ไม่มีอะไรให้ลบแล้วนะ"

methodเปลี่ยนข้อมูลไหมยิงซ้ำแล้วสภาพสุดท้ายเหมือนเดิมไหมretry อัตโนมัติได้ไหม
GETไม่เหมือนได้สบาย
PUTเปลี่ยนเหมือนได้
DELETEเปลี่ยนเหมือน (status ต่างได้)ได้
PATCHเปลี่ยนแล้วแต่ที่เขียน — {"role":"admin"} เหมือน แต่ "บวกคะแนนอีก 1" ไม่เหมือนต้องดูเป็นราย endpoint
POSTเปลี่ยนไม่เหมือน — ได้ของใหม่เพิ่มทุกครั้งไม่ได้ ถ้าไม่ออกแบบกันซ้ำเพิ่ม
🗣 ศัพท์ที่เพิ่งเรียนไป
idempotent
ยิงซ้ำกี่ครั้ง สภาพสุดท้ายของข้อมูลก็เท่าเดิม · ประโยคที่เอาไปใช้ได้ทันที: "endpoint นี้ idempotent ไหม ถ้าเน็ตหลุดแล้ว retry จะซ้ำหรือเปล่า" — คำถามนี้แหละคือสิ่งที่ dev รุ่นพี่จะถามคุณตอนรีวิวงาน
safe method
method ที่สัญญาว่าไม่เปลี่ยนข้อมูลเลย — GET คือตัวหลัก · ที่ต้องรู้คือถ้าเขียน GET ที่แอบแก้ข้อมูล ของที่พังคือทุกอย่างที่เก็บคำตอบเก่าไว้ใช้ซ้ำ (browser, CDN, cache) เพราะมันเชื่อคำสัญญาข้อนี้ · และที่หนักกว่านั้น: browser prefetch · crawler · ตัว retry อัตโนมัติ จะยิง GET นั้นเองโดยไม่มีใครกด — ข้อมูลจึงเปลี่ยนได้ทั้งที่ไม่มีผู้ใช้อยู่ตรงนั้นเลย
retry
การยิงซ้ำอัตโนมัติเมื่อไม่ได้คำตอบ · "ตัวนี้ retry ได้ไหม" เป็นคำถามที่ตอบได้ก็ต่อเมื่อรู้ว่ามัน idempotent หรือเปล่า สองคำนี้จึงมาคู่กันเสมอ
idempotency key
รหัสประจำคำสั่งหนึ่งครั้งที่ client ส่งมาด้วย เพื่อให้ server รู้ว่า "คำสั่งนี้เคยทำไปแล้ว" · เป็นวิธีมาตรฐานที่ระบบจ่ายเงินใช้กันกับ POST · ตอนนี้แค่รู้จักชื่อไว้ก็พอ ยังไม่ต้องทำเป็น

ทั้งบทรวบเป็นสองประโยค
1. URL คือคำนาม · method คือคำกริยา · ห้ามเขียนคำกริยาซ้ำลงไปใน URL อีก
2. server ไม่จำอะไรระหว่าง request — ทุกอย่างที่มันต้องใช้ตัดสินใจ ต้องเดินทางมาพร้อม request ก้อนนั้น ไม่ว่าจะเป็น token, id ใน path หรือ filter ใน query

ทดสอบความเข้าใจ

1. หลัก "stateless" ของ REST แปลว่าอะไร?

stateless พูดถึงความจำเกี่ยวกับตัวคุณระหว่าง request ไม่ได้ห้ามเก็บข้อมูลลง database (เก็บได้เต็มที่ นั่นคืองานของมัน)
ผมยิงสามครั้งจาก terminal เดียวกันติด ๆ กัน: request_no 2 แนบ Authorization: Bearer abc123 ได้ 200 พร้อม "user":"Somchai" · request_no 3 ไม่แนบ ได้ 401 พร้อม "server_knows":"nothing about who you are" — server ตัวเดิม ไม่ได้ restart
ตัวเลือกที่ 1 ผิดเพราะสับสนระหว่าง "ข้อมูลของระบบ" กับ "สถานะของ session" · ตัวเลือกที่ 3 ผิดกลับด้าน — stateless ทำให้ client ต้อง ถือ token ไว้เองต่างหาก เพราะไม่มีที่ให้ server ฝาก · ตัวเลือกที่ 4 ไม่เกี่ยวกับ stateless เลย (status code แต่ละแบบมีความหมายของตัวเอง — บทเรียน 34)

2. อยากแก้แค่ email ของ user 1 โดยไม่ให้ field อื่นเปลี่ยน ควรทำยังไง?

PATCH = แก้เฉพาะที่ส่งมา · PUT = แทนที่ทั้งก้อน
ผมยิงจริงทั้งสองแบบด้วย body {"name":"Somchai Jr"} เท่ากันเป๊ะ: PUT ได้ {"id":1,"name":"Somchai Jr","email":"","role":""} (200 OK ไม่มี error แต่ข้อมูลหาย) ส่วน PATCH ได้ {"id":1,"name":"Somchai Jr","email":"somchai@example.com","role":"member"}
ตัวเลือกที่ 1 จึงเป็นกับดักตัวจริง — มันทำงาน "สำเร็จ" แล้วลบอีเมลลูกค้าทิ้งเงียบ ๆ · ตัวเลือกที่ 2 เอาคำกริยา (update-email) ยัดกลับเข้า URL ทั้งที่ method สื่อได้อยู่แล้ว · ตัวเลือกที่ 3 ผิดหนักที่สุด เพราะ GET สัญญาว่าจะไม่เปลี่ยนอะไร — ทำ GET ที่แก้ข้อมูลเมื่อไหร่ ตัวที่พังคือทุกอย่างที่ cache หน้าเว็บ

3. ใน GET /users/42/orders?status=paid&page=2 — ส่วนไหนคือ path parameter?

server เห็นเป็นคนละชิ้นจริง ๆ — ผมยิงเข้าไปแล้วให้มันพิมพ์ทุกอย่างที่เห็นออกมา: URL.Path = "/users/42/orders" · PathValue = "42" · RawQuery = "status=paid&page=2"
เส้นแบ่งคือ เอาออกแล้วยังรู้ไหมว่าพูดถึงของชิ้นไหน: ตัด 42 ทิ้งเหลือ /users/orders ซึ่งไม่มีความหมาย → เป็น path parameter
ตัวเลือกที่ 2 กับ 4 คือ query parameter — ตัดทิ้งแล้ว /users/42/orders ยังแปลได้ว่า "order ทั้งหมดของ user 42" แค่ไม่ได้กรอง · ตัวเลือกที่ 3 ผิดเพราะ orders เป็นชื่อ resource ที่เขียนตายตัวไว้ใน pattern ไม่ใช่ช่องว่างที่รับค่า

4. เน็ตหลุดตอนกดสร้างสูตร แอปเลย retry — ยิง POST /recipes ด้วย body เดิมเป๊ะ ๆ สองครั้ง ผลเป็นยังไง?

ผมยิงจริงสองครั้งด้วย {"name":"Somchai"} เท่ากันทุกตัวอักษร: ครั้งแรก 201 Created + Location: /users/1 · ครั้งที่สอง 201 Created + Location: /users/2 แล้ว GET กองได้ [{"id":1,...},{"id":2,...}] — สองอัน
POST แปลว่า "เพิ่มของใหม่เข้ากอง" สั่งสองครั้งก็เพิ่มสองครั้ง ตรงตามสัญญาของมัน
ตัวเลือกที่ 1 กับ 2 ผิดเพราะ HTTP ไม่มีกลไกกันซ้ำติดมาให้เอง — ถ้าอยากได้แบบนั้นต้องออกแบบเพิ่มเอง (เช่นให้ client ส่ง key ประจำคำสั่งมาด้วย แล้ว server จำว่าเคยเห็น key นี้แล้ว) · ตัวเลือกที่ 4 ผิดชัด และเป็นความเข้าใจผิดที่ทำให้ปุ่มจ่ายเงินตัดเงินสองรอบ

🛠️ ภารกิจ — ออกแบบ API ของ Wongnok บนกระดาษ

บทนี้ยังไม่เขียนโค้ด แต่การออกแบบผิดตอนนี้ แพงกว่าเขียนโค้ดผิดสิบเท่า — เพราะพอมี client ใช้แล้ว URL จะแก้ไม่ได้อีก
โจทย์: เว็บรวมสูตรอาหาร · คนทั่วไปค้นสูตรได้ · สมาชิกสร้าง/แก้/ลบสูตรของตัวเอง และให้คะแนนสูตรคนอื่นได้

  1. เขียนตาราง 3 คอลัมน์ (method · path · ทำอะไร) ให้ครบทุกอย่างที่โจทย์ต้องการ — อย่างน้อยต้องมี: ดูสูตรทั้งหมด · ดูสูตรเดียว · สร้าง · แก้ · ลบ
    กติกา: ห้ามมีคำกริยาใน path แม้แต่ตัวเดียว
  2. ค้นหา: "หาสูตรจากชื่อเมนู" กับ "หาสูตรที่ทำเสร็จใน 30 นาที และง่าย" ต้องเป็น path ใหม่ หรือเป็น query บน path เดิม? ตอบพร้อมเหตุผล
    (ใช้เส้นแบ่งจากหัวข้อ 5: ตัดส่วนนั้นทิ้งแล้วยังรู้ไหมว่าพูดถึงของกองไหน)
  3. ความสัมพันธ์: "ดูสูตรทั้งหมดของ user 3" ควรเป็น URL อะไร — แล้วเทียบกับ "ให้คะแนนสูตรที่ 7" ว่าใครควรเป็น resource หลัก
  4. เลือก method ให้ถูก: หน้าจอ "แก้ไขสูตร" ของคุณโหลดข้อมูลเดิมมาแสดงครบทุกช่อง ก่อนให้ผู้ใช้แก้ — ควรใช้ PUT หรือ PATCH?
    แล้วปุ่ม "เปลี่ยนรูปหน้าปกสูตร" ปุ่มเดียวล่ะ? ตอบให้ต่างกันและบอกเหตุผล
  5. ทดสอบหลัก stateless: ไล่ทีละ endpoint แล้วเขียนว่า ข้อมูลอะไรบ้างต้องเดินทางมากับ request นั้น เพื่อให้ server ตัดสินใจได้ครบ
    โดยเฉพาะ DELETE สูตรที่ 7 — server จะรู้ได้ยังไงว่าคนยิงคือเจ้าของสูตร ถ้ามันจำ login ครั้งก่อนไม่ได้เลย
  6. หา idempotency ที่จะทำให้เจ็บ: ไล่ทุกแถวในตารางแล้วติดดาวไว้ที่แถวที่ ยิงซ้ำแล้วผลไม่เหมือนเดิม — มีกี่แถว? แถวไหนที่ผู้ใช้จริงจะกดรัวได้ง่ายที่สุด?
  7. เขียนตารางเคสเทส — สำหรับ endpoint แก้สูตร ให้เขียนเป็นแถว (ชื่อเคส · request ที่ส่ง · สภาพข้อมูลที่คาดหวังหลังจบ) อย่างน้อย 4 แถว ต้องมีเคส "ส่ง field มาไม่ครบ" อยู่ด้วย
    ตารางนี้คือ input ของ table-driven test (บทเรียน 16) ตรง ๆ — พอเขียน handler เป็นในบทถัด ๆ ไปของ Day 8 คุณจะเอาตารางนี้ไปแปะเป็นเทสได้ทันที โดยไม่ต้องคิดเคสใหม่

ทำที่ไหน: กระดาษ สมุด หรือไฟล์ .md เปล่า ๆ ก็ได้ ยังไม่ต้องเปิดโปรเจกต์ Go
ตรวจงานตัวเองก่อนกดเฉลย — ตารางของคุณผ่านครบสี่ข้อนี้ไหม

ข้อ 2 — เป็น query บน /recipes เดิม ไม่ใช่ path ใหม่: GET /recipes?q=ต้มยำ และ GET /recipes?max_minutes=30&difficulty=easy
เหตุผลตามเส้นแบ่งในหัวข้อ 5: ตัด ?q=... ทิ้งแล้วเหลือ /recipes ซึ่งยังมีความหมายครบ (สูตรทั้งหมด) — แปลว่ามันคือตัวกรอง ไม่ใช่ตัวชี้
ที่สำคัญกว่านั้น: ถ้าทำเป็น path เช่น /recipes/search/ต้มยำ พอวันหนึ่ง อยากค้นด้วยสองเงื่อนไขพร้อมกัน คุณจะต้องเพิ่ม path ใหม่อีกอัน แล้วอีกอัน ไม่จบ — ส่วน query เพิ่มกี่ตัวก็ได้บน path เดิม

ข้อ 3 — GET /users/3/recipes อ่านออกเสียงได้ว่า "สูตรทั้งหลาย ของ user 3" — path ไล่จากของใหญ่ไปของเล็กเสมอ
ส่วนให้คะแนน: POST /recipes/7/ratings — คราวนี้ สูตรเป็นเจ้าของกอง เพราะสิ่งที่เรากำลังเพิ่มคือคะแนนของสูตรนั้น
หลักคือ อะไรคือ "กอง" ที่ของชิ้นใหม่จะไปอยู่ ตัวนั้นขึ้นก่อนใน path

ข้อ 4 — หน้าจอแก้ไขเต็มหน้าใช้ PUT ได้ เพราะ client โหลดของเดิมมาครบแล้ว จะส่งกลับครบทุก field ซึ่งตรงกับความหมาย "แทนที่ทั้งก้อน" พอดี
ปุ่มเปลี่ยนรูปหน้าปกใช้ PATCH /recipes/7 เพราะหน้าจอนั้น ไม่มีข้อมูลช่องอื่นอยู่ในมือเลย ถ้าใช้ PUT คุณจะซ้ำรอยหัวข้อ 7 เป๊ะ ๆ: ได้ 200 OK กลับมา แล้วส่วนผสมกับวิธีทำของสูตรนั้นกลายเป็นค่าว่างทั้งหมด
คำถามที่ใช้ตัดสินได้ทุกครั้ง: ตอนกดปุ่มนี้ client มีข้อมูลครบทั้งก้อนอยู่ในมือหรือเปล่า? มีครบ → PUT ได้ · ไม่ครบ → ต้อง PATCH

💬 ข้อ 5 คือข้อที่ผมอยากเห็นคำตอบที่สุด — เพราะคำถาม "server จะรู้ได้ยังไงว่าคุณเป็นเจ้าของสูตร ถ้ามันจำอะไรไม่ได้เลย" คือประตูเข้าสู่เรื่อง auth ทั้งหมดของ Day 8
ถ้าตารางที่คุณออกแบบมามีจุดที่ไม่แน่ใจว่า "แบบนี้ REST หรือเปล่า" ส่งตารางมาได้เลย — ผมอยากเห็นเหตุผลที่คุณใช้ตัดสินใจ มากกว่าคำตอบที่ถูกอยู่แล้ว เพราะหลัก REST มีข้อเดียวที่ตายตัวจริง ๆ คือ ทำอย่างไรก็ได้ ขอให้คนอ่านเดาข้อถัดไปถูก

อ้างอิง