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

RPC over WebSocket

Pub/sub เกี่ยวกับ การ broadcast ความสนใจ แต่บางครั้ง client ต้องการสิ่งตรงข้าม: คำตอบที่แม่นยำของคำถามเฉพาะข้อหนึ่ง — “ประวัติของห้องนี้เป็นอย่างไร?”, “username นี้ถูกใช้ไปแล้วหรือยัง?” บน HTTP นั่นก็แค่ request คู่กับ response แต่บน WebSocket ไม่มีอะไรแบบนั้นมาให้ในตัว: คุณ send message ออกไป แล้ว onmessage ก็ทำงานแยกต่างหากสำหรับ ทุก message ที่เข้ามา ไม่มีอะไรผูก reply กลับไปยังคำถามต้นทาง RPC over WebSocket คือ pattern เล็ก ๆ ที่สร้าง request/response ขึ้นมาใหม่บน stream message ทางเดียว

WebSocket คือ stream ของ frame ที่เป็นอิสระต่อกันในแต่ละทิศทาง เมื่อคุณเรียก ws.send แล้วต่อมา message มาถึง คุณไม่มีวิธีในระดับภาษาที่จะรู้ว่า message นั้น ตอบ การ send นี้ ถ้าคุณยิงสาม request แล้วสาม reply กลับมา — ซึ่งอาจสลับลำดับกัน — ทั้งหมดจะลงมาที่ handler onmessage เดียวกันเป็นกองที่แยกแยะไม่ออก คุณต้องเย็บแต่ละ reply เข้ากับ request ต้นทางเอง

เคล็ดลับคือติด correlation id ที่ไม่ซ้ำกันให้ทุก request และกำหนดให้ server สะท้อน id เดียวกันนั้นกลับมาบน reply ฝั่ง client เก็บตารางของ request ที่ กำลังรอ โดยใช้ id เป็น key เมื่อ message มาถึง ก็อ่าน id ออกมา ค้นหา promise ที่กำลังรออยู่ แล้ว resolve ทิ้ง ขั้นตอนเป็นแบบนี้:

sequenceDiagram
  participant App as App code
  participant RPC as RPC client
  participant WS as WebSocket
  participant Srv as Server
  App->>RPC: call('getUser', {id: 7})
  RPC->>RPC: id = 'r1'; store pending['r1'] = resolve
  RPC->>WS: send {id:'r1', method:'getUser', params:{id:7}}
  WS->>Srv: {id:'r1', ...}
  Srv-->>WS: {id:'r1', result:{name:'Sam'}}
  WS-->>RPC: onmessage {id:'r1', result:...}
  RPC->>RPC: lookup pending['r1'] -> resolve(result)
  RPC-->>App: promise resolves with {name:'Sam'}
correlation id ผูกแต่ละ reply กลับไปยัง request ต้นทาง

เพราะแต่ละ request พก id ของตัวเอง reply จึงมาถึงในลำดับใดก็ได้ — id ไม่ใช่ลำดับการมาถึง ที่ตัดสินว่า promise ใด resolve นั่นคือสิ่งที่ทำให้การเชื่อมต่อเดียวปลอดภัยที่จะ multiplex ข้ามการเรียกพร้อมกันหลายครั้ง

RPC client บน WebSocket ที่ใช้งานได้จริงโดยพื้นฐานคือสามสิ่ง:

  • pending map. Map<id, { resolve, reject, timer }> — หนึ่งรายการต่อหนึ่งการเรียกที่กำลังบินอยู่ ถูกลบออกเมื่อ reply ลงมาหรือเมื่อการเรียก timeout
  • id generator. แหล่งกำเนิด string ที่ไม่ซ้ำกันอันใดก็ได้ — counter, crypto.randomUUID() ขอแค่ไม่ซ้ำกันในบรรดาการเรียกที่ กำลังรออยู่ในขณะนั้น
  • timeout. จุดอ่อนร้ายแรงของ RPC แบบไร้เดียงสาคือ reply ที่ไม่มีวันมา (server crash, message หลุด) ถ้าไม่มี timeout promise จะค้างตลอดกาลและ pending map จะรั่ว ทุกการเรียกจะได้ timer ที่ reject และเคลียร์ตัวเองหากไม่มี reply มาถึงในเวลาที่กำหนด

demo ต่อสาย mock สไตล์ WebSocket จริงเข้ากับ RPC client ที่สมบูรณ์ เราเรียกสำเร็จสองครั้ง (add และ echo) ซึ่ง reply มาถึง สลับลำดับกัน และเรียกหนึ่งครั้งไปยัง method ที่ server เพิกเฉย — เพื่อพิสูจน์ว่า timeout ทำงานและ reject แทนที่จะค้าง ลองรันแล้วดูว่า id จับคู่ reply เข้ากับการเรียกได้ไม่ว่าลำดับจะเป็นอย่างไร และ reply ที่หายไปจะ reject ตามกำหนด

JavaScript

สังเกตลำดับใน log: add และ echo ถูก ส่ง เป็นอันแรกและอันที่สอง แต่ echo resolve ก่อนเพราะ reply กลับมาเร็วกว่า — และนั่นไม่เป็นไร เพราะแต่ละ promise ถูก key ด้วย id ไม่ใช่ด้วยลำดับ ส่วน noReply ไม่เคยได้คำตอบ พอครบ timeout promise จึง reject ด้วย error ที่ชัดเจน แล้วรายการ pending ก็ถูกลบทิ้ง การเคลียร์ตรงนี้แหละคือความแตกต่างระหว่าง client ที่ทนทานกับ memory leak ที่ค่อย ๆ รั่ว

รักษา envelope ให้น้อยที่สุดและสมมาตร request พก id, method และ params ส่วน reply พก id เดียวกันและไม่ result ก็ error ถ้าคุณต้องการมาตรฐานแทนการประดิษฐ์เอง JSON-RPC 2.0 นิยามรูปร่างนี้ไว้พอดี — id, method, params, result/error — และเป็นค่าตั้งต้นที่สมเหตุสมผลในการลอกมาใช้ ไม่ว่าทางไหนกฎก็ตายตัว: reply ต้องสะท้อน id ของ request ไม่งั้น client จะ route กลับไม่ถูก

RPC Patternทำงานอย่างไรเหมาะกับ
Request-Responseส่ง request พร้อม id, รอ response ที่ match idOperation ที่ต้องการผลลัพธ์
Notificationส่งข้อความโดยไม่รอ responseEvent, log, metric
Server Pushserver ส่งโดยไม่มี requestReal-time update
Bidirectional Streamทั้งสองฝั่งส่งพร้อมกันAudio/video, game state

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

  • ส่ง RPC request แล้วรอ response ไม่จำกัดเวลา
  • server ไม่ตอบ (crash, bug) → client hang ตลอดไป
  • ตั้ง timeout ต่อ request: resolve error เมื่อ response ไม่มาใน N วินาที

ใช้ WebSocket RPC เมื่อ HTTP REST เพียงพอ อาการ:

  • operation ที่ทำครั้งเดียว (login, create post) ผ่าน WebSocket RPC
  • overhead จาก persistent connection โดยไม่จำเป็น
  • ใช้ HTTP สำหรับ stateless operation, WebSocket สำหรับ stateful real-time

💡 ตัวอย่างจากของจริง

tRPC over WebSocket:

  • type-safe RPC บน WebSocket — subscription สำหรับ real-time, query/mutation สำหรับ HTTP
  • Discord, Linear ใช้ pattern คล้ายกันสำหรับ real-time feature

JSON-RPC 2.0 over WebSocket:

  • protocol มาตรฐานสำหรับ RPC บน WebSocket
  • request: \{ "jsonrpc": "2.0", "method": "sendMessage", "params": \{...\}, "id": 1 \}
ทำไม RPC บน WebSocket ถึงต้องมี correlation id บนทุก request?
ใน demo ทำไม echo ถึง resolve ก่อน add ทั้งที่ add ถูกส่งก่อน?
timeout มีบทบาทอะไรใน RPC client?