การ Framing Messages
มีของขวัญละเอียดอ่อนชิ้นหนึ่งที่ WebSocket protocol มอบให้คุณ และมีกับดักละเอียดอ่อนอยู่ข้าง ๆ กันด้วย ของขวัญคือ message มาถึงครบทั้งก้อน ส่วนกับดักคือการเชื่อว่าครบทั้งก้อนแปลว่า มีความหมาย บทเรียนนี้ว่าด้วยช่องว่างระหว่างสองสิ่งนั้น
TCP ดิบ ๆ ไม่มี message — แต่ WebSocket มี
หัวข้อที่มีชื่อว่า “TCP ดิบ ๆ ไม่มี message — แต่ WebSocket มี”ถ้าคุณเคยเขียนcodeกับ TCP socket ดิบ ๆ คุณจะรู้ถึงความเจ็บปวด: TCP คือ byte stream คุณอาจเรียก send('{"a":1}') หนึ่งครั้งที่ฝั่งหนึ่ง แต่อีกฝั่งอาจได้รับ '{"a' แล้วตามด้วย ':1}' หรือได้ทั้งสองก้อนในครั้งเดียวติดกับ message ถัดไป ไม่มีขอบเขตใด ๆ คุณต้องประดิษฐ์ framing ขึ้นมาเอง — length prefixes, delimiters หรืออะไรสักอย่าง — เพื่อรู้ว่า message หนึ่งจบตรงไหนและอันถัดไปเริ่มตรงไหน
WebSocket แก้ปัญหานั้นให้คุณแล้ว protocol ทำ framing ให้สตรีมที่ระดับ wire ดังนั้น onmessage จึงทำงาน หนึ่งครั้งต่อหนึ่ง message ที่คุณส่ง โดย payload มาครบทั้งก้อน หนึ่ง send ที่ฝั่งหนึ่งกลายเป็น onmessage หนึ่งครั้งพอดีที่อีกฝั่ง
flowchart TB
subgraph tcp["Raw TCP — a byte stream"]
direction LR
t1["send A · send B"] --> t2["recv: A+par<br/>recv: t of B"]
t2 --> t3["YOU must reframe"]
end
subgraph wsf["WebSocket — discrete messages"]
direction LR
w1["send A · send B"] --> w2["onmessage: A<br/>onmessage: B"]
w2 --> w3["boundaries already correct"]
end ดังนั้นปัญหา framing ระดับล่าง — message จบตรงไหน — หายไปแล้ว วิเศษมาก แต่นั่นบอกเพียงว่า bytes ถูกจัดกลุ่มอย่างถูกต้อง ไม่ได้บอกเลยว่า bytes ที่ถูกจัดกลุ่มเหล่านั้น มีความหมายอะไร
คุณยังต้องมี application protocol
หัวข้อที่มีชื่อว่า “คุณยังต้องมี application protocol”นี่คือกับดัก เพราะ message มาถึงครบทั้งก้อน จึงมีแรงดึงดูดให้แค่ send('user joined: bob') แล้วถือว่าเสร็จ วิธีนี้ได้ผล — สำหรับ message เพียงชนิดเดียวเท่านั้น ทันทีที่คุณมีชนิดที่สอง (“user left”, “new message”, “typing…”) ฝั่งรับของคุณก็ถูกลดทอนลงเหลือเพียงการดมกลิ่น string อย่างเปราะบาง: string นี้ขึ้นต้นด้วย “user joined” ไหม? มี colon ไหม? นั่นคือ parser ที่คุณกำลังเขียนด้วยมือโดยไม่ตั้งใจ และเขียนได้แย่ด้วย
วินัยที่ขยายตัวได้นั้นเรียบง่ายและคุ้มค่าที่จะจดจำ:
- หนึ่ง JSON object ต่อหนึ่ง WebSocket message อย่าอัด message เชิงตรรกะสองอันเข้าไปใน
sendเดียว และอย่าแยก message เชิงตรรกะหนึ่งอันออกเป็นหลาย ๆ ครั้ง - ทุก message มี field
typeฝั่งรับแตกแขนงตามtypeไม่ใช่ตามรูปร่างของ string - payload อยู่ใน field ที่มีชื่อ ดังนั้นการเพิ่มข้อมูลภายหลังจึงไม่เคยทำลายการ parsing ที่มีอยู่เดิม
นี่คือ application-level framing ของคุณ: WebSocket ทำ framing ให้ bytes ส่วน field type ของคุณทำ framing ให้ ความหมาย
เดโม message ที่มีโครงสร้าง
หัวข้อที่มีชื่อว่า “เดโม message ที่มีโครงสร้าง”เดโมส่ง message ชนิด ต่าง ๆ สามชนิดผ่าน connection เดียว แต่ละอันเป็น JSON object หนึ่งอันที่มี type และฝั่งรับ dispatch ตาม type นั้น — ไม่มีการดมกลิ่น string ที่ใดเลย เดโมรันในเบราว์เซอร์ของคุณบน echo socket ภายในหน้าที่ใช้ WebSocket API จริง
สาม send กลายเป็น onmessage สามครั้ง — WebSocket รักษาขอบเขตให้สมบูรณ์แบบ และเพราะแต่ละ message ประกาศ type ของตน ฝั่งรับจึงจัดการทั้งสามชนิดด้วย if/else ที่สะอาดแทนที่จะเดาจาก text เพิ่ม message ชนิดที่สี่ในวันพรุ่งนี้ แล้วมีเพียงแขนงใหม่หนึ่งแขนงเท่านั้นที่เปลี่ยนแปลง
| Message Design | ข้อดี | ข้อเสีย |
|---|---|---|
| One JSON object per message + type field | routing ชัดเจน, extensible | verbose กว่า binary |
| String-based (no structure) | ง่าย debug | fragile, ไม่ scale เมื่อ message types เพิ่ม |
| Binary framing (Protobuf/MessagePack) | compact, เร็ว | ต้องมี schema, ไม่ human-readable |
ข้อผิดพลาดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ข้อผิดพลาดที่พบบ่อย”ยัด Message หลายอันใน send() เดียว อาการ:
ws.send('joined:bob\nchat:hello')— ยัด 2 event ในครั้งเดียว- receiver parse ผิด — บางครั้งทำงาน บางครั้งไม่
- ส่งหนึ่ง JSON object ต่อ
send()เสมอ — WebSocket เก็บ boundary ให้แล้ว
Dispatch ตาม String Shape แทน type Field อาการ:
if (data.startsWith('user joined'))— เปราะบาง แก้ยาก- message format เปลี่ยนนิดเดียว → ทุก handler พัง
- ใส่
typefield ทุก message แล้ว dispatch ตามนั้น
💡 ตัวอย่างจากของจริง
Slack RTM API:
- ทุก message มี
typefield:"type": "message","type": "user_typing","type": "presence_change"- client dispatch ตาม
type— ชัดเจน extensibleDiscord Gateway:
- ใช้
op(opcode) แทนtypeเพื่อแยก application message จาก protocol control- client แตกแขนงตาม opcode ก่อน แล้วจึงดู payload