Request/Response over WebSocket
นักพัฒนาที่มาจาก HTTP แบกข้อสมมติที่ฝังลึกมาด้วย: ฉันเรียกเอง response นี้ก็เลยเป็นของฉัน ด้วย fetch คำตอบที่คุณ await คือคำตอบของคำถามที่คุณถามอย่างไม่กำกวม WebSocket ถอดการรับประกันนั้นออกไปเงียบ ๆ — และการได้การรับประกันนั้นคืนมาคือหนึ่งในแพตเทิร์นที่มีประโยชน์ที่สุดในการออกแบบ message
socket เน้น message ไม่ใช่ request/reply
หัวข้อที่มีชื่อว่า “socket เน้น message ไม่ใช่ request/reply”WebSocket ไม่มีแนวคิดของ request ที่จับคู่กับ response มีเพียงสตรีมของ message ที่ไหลไปแต่ละทิศทาง อย่างเป็นอิสระ เมื่อ onmessage ทำงาน message นั้นอาจเป็น:
- reply ของสิ่งที่คุณเพิ่งถาม,
- reply ของสิ่งที่คุณถามไปสาม message ก่อนหน้าซึ่งใช้เวลานานกว่า,
- หรือ server push ที่ไม่ได้ร้องขอเลย (chat line ใหม่, price tick)
ถ้าคุณยิง request “fetch user” ออกไปสามครั้งและผลลัพธ์กลับมาสามอัน ไม่มีอะไรใน transport บอกคุณว่าผลลัพธ์ไหนตอบ request ไหน reply อาจมาถึงผิดลำดับ สลับกับ push คุณพึ่งพาลำดับไม่ได้ และคุณ await send ไม่ได้แน่นอน
correlation id ผูก reply เข้ากับ request ของตัวเอง
หัวข้อที่มีชื่อว่า “correlation id ผูก reply เข้ากับ request ของตัวเอง”วิธีแก้คือ field เดียวที่คุณรู้วิธีเพิ่มอยู่แล้ว: id ที่ไม่ซ้ำกันบนแต่ละ request ซึ่ง server สะท้อนกลับ (echoes) บน reply ที่จับคู่กัน client เก็บตารางเล็ก ๆ ของ id → “ใครกำลังรออันนี้” และเมื่อ reply มาถึง client ก็ค้นหา id, resolve ผู้รอนั้น แล้วลบ entry ออก reply ของ id ที่ไม่รู้จัก (หรือ message ที่ไม่มี id เลย) ถูกถือว่าเป็น server push
sequenceDiagram
participant C as Client
participant S as Server
C->>S: { type: 'getUser', id: 'r1', name: 'alice' }
C->>S: { type: 'getUser', id: 'r2', name: 'bob' }
Note over S: replies may come back in any order
S-->>C: { type: 'reply', id: 'r2', age: 41 }
C->>C: look up r2, resolve bob's waiter
S-->>C: { type: 'reply', id: 'r1', age: 30 }
C->>C: look up r1, resolve alice's waiter นี่คือวิธีที่ request/response รู้สึก เหมือน HTTP อีกครั้งพอดี — แต่สร้างด้วยมือ บนสตรีม ด้วย correlation field เดียว การห่อไว้ใน promise ทำให้จุดที่เรียกอ่านเหมือน await ธรรมดา
ชั้น request/reply เล็ก ๆ แบบสด ๆ
หัวข้อที่มีชื่อว่า “ชั้น request/reply เล็ก ๆ แบบสด ๆ”เดโมด้านล่างทำ request() บน echo socket ภายในหน้า แต่ละครั้งที่เรียกจะสร้าง id ใหม่ เก็บ resolver ไว้ใน map pending แล้วส่ง request เมื่อ reply กลับมา id ที่สะท้อนกลับของตัวเองจะหา resolver ที่ถูกต้องเจอ request สองอันถูกส่งออกไป echo หน่วงเวลาต่างกันเพื่อให้ reply มาถึงผิดลำดับ — และ correlation ยังคงจับคู่แต่ละอันได้อย่างถูกต้อง เดโมนี้ใช้ WebSocket API จริง
ดูลำดับให้ดี: reply ของ bob กลับมาก่อนเพราะ server ตอบเขาเร็วกว่า แต่ promise ของ alice ยังคง resolve เป็น alice และของ bob เป็น bob id — ไม่ใช่ลำดับการมาถึง — คือสิ่งที่ทำให้การจับคู่ถูกต้อง map pending เล็ก ๆ นั้นคือกลเม็ดทั้งหมดเบื้องหลังทุก library “RPC over WebSocket” ที่คุณจะได้ใช้
ส่วนขัดเกลาสองสามอย่างที่คุณจะเพิ่มใน production: timeout ที่ reject entry ที่รออยู่หากไม่มี reply มาถึง และการเคลียร์ map pending หาก socket ปิด เพื่อไม่ให้ promise ค้างอยู่ตลอดกาล
| Pattern | ใช้เมื่อ | ข้อเสีย |
|---|---|---|
| Correlation ID + pending map | ต้องการ request/response บน WS | ต้อง implement เอง |
| HTTP สำหรับ request/response | query ครั้งเดียว, cacheable | latency สูงกว่า WS |
| Fire-and-forget (ไม่มี reply) | log, metric push | ไม่รู้ว่า server ได้รับหรือไม่ |
ข้อผิดพลาดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ข้อผิดพลาดที่พบบ่อย”ไม่มี Timeout สำหรับ Pending Request อาการ:
- ส่ง request แล้ว server ไม่ตอบ — promise ค้างตลอดกาล
- memory leak จาก
pendingmap ที่โตขึ้นเรื่อย ๆ - ตั้ง timeout ต่อ request:
setTimeout(() => reject('timeout'), 5000)แล้วลบออกจาก map
ไม่ Clear Pending Map เมื่อ Connection Close อาการ:
- WS close → reconnect แต่ promise จาก session เก่ายังค้างอยู่
- memory leak และ stale handler ทำงานเมื่อ reconnect
- ใน
onclose: reject + clear pending map ทั้งหมด
💡 ตัวอย่างจากของจริง
Pusher Channels:
- ส่ง subscription request ด้วย
channel_id- server ตอบ
pusher_internal:subscription_succeededพร้อมchannelที่ matchPhoenix LiveView (Elixir):
- ใช้ ref field เป็น correlation ID สำหรับทุก push/reply
- client track ref เพื่อ match response กับ request ที่ส่งไป