ข้ามไปยังเนื้อหา

Request/Response over WebSocket

นักพัฒนาที่มาจาก HTTP แบกข้อสมมติที่ฝังลึกมาด้วย: ฉันเรียกเอง response นี้ก็เลยเป็นของฉัน ด้วย fetch คำตอบที่คุณ await คือคำตอบของคำถามที่คุณถามอย่างไม่กำกวม WebSocket ถอดการรับประกันนั้นออกไปเงียบ ๆ — และการได้การรับประกันนั้นคืนมาคือหนึ่งในแพตเทิร์นที่มีประโยชน์ที่สุดในการออกแบบ message

WebSocket ไม่มีแนวคิดของ request ที่จับคู่กับ response มีเพียงสตรีมของ message ที่ไหลไปแต่ละทิศทาง อย่างเป็นอิสระ เมื่อ onmessage ทำงาน message นั้นอาจเป็น:

  • reply ของสิ่งที่คุณเพิ่งถาม,
  • reply ของสิ่งที่คุณถามไปสาม message ก่อนหน้าซึ่งใช้เวลานานกว่า,
  • หรือ server push ที่ไม่ได้ร้องขอเลย (chat line ใหม่, price tick)

ถ้าคุณยิง request “fetch user” ออกไปสามครั้งและผลลัพธ์กลับมาสามอัน ไม่มีอะไรใน transport บอกคุณว่าผลลัพธ์ไหนตอบ request ไหน reply อาจมาถึงผิดลำดับ สลับกับ push คุณพึ่งพาลำดับไม่ได้ และคุณ await send ไม่ได้แน่นอน

วิธีแก้คือ 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
การจับคู่ reply เข้ากับ request ด้วย id ที่สะท้อนกลับ

นี่คือวิธีที่ request/response รู้สึก เหมือน HTTP อีกครั้งพอดี — แต่สร้างด้วยมือ บนสตรีม ด้วย correlation field เดียว การห่อไว้ใน promise ทำให้จุดที่เรียกอ่านเหมือน await ธรรมดา

เดโมด้านล่างทำ request() บน echo socket ภายในหน้า แต่ละครั้งที่เรียกจะสร้าง id ใหม่ เก็บ resolver ไว้ใน map pending แล้วส่ง request เมื่อ reply กลับมา id ที่สะท้อนกลับของตัวเองจะหา resolver ที่ถูกต้องเจอ request สองอันถูกส่งออกไป echo หน่วงเวลาต่างกันเพื่อให้ reply มาถึงผิดลำดับ — และ correlation ยังคงจับคู่แต่ละอันได้อย่างถูกต้อง เดโมนี้ใช้ WebSocket API จริง

JavaScript

ดูลำดับให้ดี: 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/responsequery ครั้งเดียว, cacheablelatency สูงกว่า WS
Fire-and-forget (ไม่มี reply)log, metric pushไม่รู้ว่า server ได้รับหรือไม่

ไม่มี Timeout สำหรับ Pending Request อาการ:

  • ส่ง request แล้ว server ไม่ตอบ — promise ค้างตลอดกาล
  • memory leak จาก pending map ที่โตขึ้นเรื่อย ๆ
  • ตั้ง 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 ที่ match

Phoenix LiveView (Elixir):

  • ใช้ ref field เป็น correlation ID สำหรับทุก push/reply
  • client track ref เพื่อ match response กับ request ที่ส่งไป
ทำไมคุณจึงพึ่งพาลำดับการมาถึงเพื่อจับคู่ WebSocket reply กับ request ไม่ได้?
แพตเทิร์น correlation-id จับคู่ reply กับ request ของตัวเองอย่างไร?
ในเดโม ทำไม alice จึงยัง resolve เป็น alice แม้ว่า bob จะ reply ก่อน?