Websockets ได้รับความสนใจมากขึ้น เราได้ยินว่าเป็น "อนาคต" เราได้ยินว่าใช้งานง่ายขึ้นเพราะ ActionCable ใน Rails 5 แต่ websockets คืออะไรจริงๆ ทำงานยังไง?
บทความนี้จะตอบคำถามโดยสร้าง WebSocket server เรียบง่ายจากศูนย์ใน Ruby ตอนจบ จะได้ bidirectional communication ระหว่าง browser และ server
โค้ดในบทความนี้เป็น learning exercise หากต้องการ implement websockets ใน production app จริง ดู websocket-ruby gem ที่ยอดเยี่ยม อาจดู WebSocket Spec ด้วย
ถ้ายังไม่เคยได้ยิน websockets
Web sockets ประดิษฐ์เพื่อแก้ปัญหา inherent ใน normal HTTP connections เวลาร้องขอ webpage ด้วย normal HTTP connection server ส่ง content แล้ว close connection อยาก request หน้าอื่น ต้องทำ connection ใหม่ ปกติ fine แต่ไม่ใช่ best approach สำหบาง use cases:
- บาง applications เช่น chat frontend ต้อง update ทันทีเมื่อมี message ใหม่ ถ้ามีแค่ normal HTTP requests ต้อง continuously poll server ดูว่ามี content ใหม่ไหม
- ถ้า front-end ต้องทำ small requests หลายๆ ครั้ง去 server overhead สร้าง connections ใหม่ทุก request กลายเป็น performance issue น้อยลงใน HTTP2
กับ Web sockets ทำ connection หนึ่งไป server แล้ว hold open ใช้ bidirectional communication
Client side
Web sockets ปกติใช้ communication ระหว่าง browser และ web server Browser side implemented ใน JavaScript ตัวอย่างเขียน JavaScript เรียบง่ายเปิด Web socket ไป local server ส่ง message
<!doctype html>
<html lang="en">
<head>
<title>Websocket Client</title>
</head>
<body>
<script>
var exampleSocket = new WebSocket("ws://localhost:2345");
exampleSocket.onopen = function (event) {
exampleSocket.send("Can you hear me?");
};
exampleSocket.onmessage = function (event) {
console.log(event.data);
}
</script>
</body>
</html>
เริ่ม static server เล็กๆ และเปิดไฟล์ใน browser ได้ error สมเหตุสมผล เพราะยังไม่มี server เราต้องสร้าง :-)

เริ่ม Server
Web socket เริ่มชีวิตเป็น normal HTTP requests มี lifecycle แปลกๆ:
- Browser ส่ง normal HTTP request พร้อม special headers บอก "please make me a websocket"
- Server ตอบ HTTP response เฉพาะ แต่ ไม่ CLOSE THE CONNECTION
- Browser และ server ใช้ special websocket protocol แลกเปลี่ยน frames of data ผ่าน open connection
ขั้นแรกสร้าง Web server โค้ดด้านล่าง simplest web server ไม่ serve อะไร แค่รอ request แล้วพิมพ์ STDERR
require 'socket'
server = TCPServer.new('localhost', 2345)
loop do
# รอ connection
socket = server.accept
STDERR.puts "Incoming Request"
# อ่าน HTTP request รู้ว่าเสร็จตอนเจอ line แค่ \r\n
http_request = ""
while (line = socket.gets) && (line != "\r\n")
http_request += line
end
STDERR.puts http_request
socket.close
end
รัน server refresh websocket testing page ได้:
$ ruby server1.rb
Incoming Request
GET / HTTP/1.1
Host: localhost:2345
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: cG8zEwcrcLnEftn2qohdKQ==
สังเกต HTTP request มี headers เกี่ยว websocket นี่คือขั้นแรกใน websocket handshake
The Handshake
Web socket requests เริ่มด้วย handshake เพื่อให้ client และ server เข้าใจว่า websockets จะเกิดขึ้น และตกลง protocol version ทำงานแบบนี้:
Client ส่ง HTTP request แบบนี้
GET / HTTP/1.1
Host: localhost:2345
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: E4i4gDQc1XTIQcQxvf+ODA==
Sec-WebSocket-Version: 13
ส่วนสำคัญที่สุดคือ Sec-WebSocket-Key Client คาดหวัง server คืน modified version ของค่านี้เป็น proof against XSS attacks และ caching proxies
Server ตอบ
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: d9WHst60HtB4IvjOVevrexl0oLA=
Server response เป็น boilerplate ยกเว้น header Sec-WebSocket-Accept header นี้ generate แบบนี้:
# เอาค่าจาก client append magic string
# Generate SHA1 hash แล้ว base64 encode
Digest::SHA1.base64digest([sec_websocket_accept, "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"].join)
ตาไม่หลอก คุณมี magic constant
Implement Handshake
Update server ให้ handshake เสร็จ ก่อน ดึง security token จาก request headers:
# Grab security key จาก headers
# หาไม่ได้ close connection
if matches = http_request.match(/^Sec-WebSocket-Key: (\S+)/)
websocket_key = matches[1]
STDERR.puts "Websocket handshake detected with key: #{ websocket_key }"
else
STDERR.puts "Aborting non-websocket connection"
socket.close
next
end
ตอนนี้ใช้ security key generate valid response:
response_key = Digest::SHA1.base64digest([websocket_key, "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"].join)
STDERR.puts "Responding to handshake with key: #{ response_key }"
socket.write <<-eos
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: #{ response_key }
eos
STDERR.puts "Handshake completed."
Refresh websocket test page ไม่มี connection error แล้ว Connection established!

Server output แสดง security keys และ response key:
$ ruby server2.rb
Incoming Request
Websocket handshake detected with key: Fh06+WnoTQQiVnX5saeYMg==
Responding to handshake with key: nJg1c2upAHixOmXz7kV2bJ2g/YQ=
Handshake completed.
WebSocket Frame Protocol
ตอน handshake เสร็จ HTTP ไม่ใช้แล้ว แลกเปลี่ยน data ผ่าน WebSocket protocol
Frames = basic unit ของ WebSocket protocol
WebSocket protocol เป็น frame-based แต่หมายถึงอะไร?
เวลาขอ browser ส่ง data ผ่าน WebSocket หรือขอ server respond data แบ่งเป็น chunks แต่ละ chunk wrapped metadata ทำ frame
Frame structure คล้ายนี้ ตัวเลขบนสุดคือ bits บาง fields เช่น extended payload length อาจไม่มีเสมอ:
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------- - - - - - - - - - - - - - - - +
: Payload Data continued ... :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Payload Data continued ... |
+---------------------------------------------------------------+
สิ่งแรกเด่น: binary protocol ต้อง bit manipulation แต่ไม่ยาก โครงสร้างด้านบน bits บนสุด และบาง fields ไม่มีเสมอ เช่น extended payload length จะมีถ้า payload < 127 bytes
Receiving Data
Handshake เสร็จ เริ่ม parse binary frame 简单起见 看 incoming frame one byte at a time แล้วรวมกันให้เห็นใน action
Byte 1: FIN and Opcode
จากตาราง byte แรก (8 bits แรก) มี data:
- FIN: 1 bit false = message แยกหลาย frames
- opcode: 4 bits บอก payload เป็น text, binary หรือแค่ "ping" keep connection alive
- RSV: 3 bits unused ใน WebSockets spec ปัจจุบัน
ได้ byte แรก用 IO#getbyte แล้ว extract data ด้วย bitmasking ไม่รู้จัก bitwise operators อ่านบทความ Bitwise hacks in Ruby ของผม
first_byte = socket.getbyte
fin = first_byte & 0b10000000
opcode = first_byte & 0b00001111
# Server รองรับแค่ single-frame, text messages
# Raise exception ถ้า client ส่งอย่างอื่น
raise "We don't support continuations" unless fin
raise "We only support opcode 1" unless opcode == 1
Byte 2: MASK and payload length
Byte 2 มี info เพิ่มเกี่ยว payload:
- MASK: 1 bit Boolean flag payload masked หรือไม่ true = payload ต้อง "unmask" ก่อนใช้ spec ระบุว่าควร true เสมอสำหรับ frames จาก client
- payload length: 7 bits payload < 126 bytes length เก็บที่นี่ ถ้า > 126 bytes จะมี bytes เพิ่มมาบอก length
จัดการ byte 2:
second_byte = socket.getbyte
is_masked = second_byte & 0b10000000
payload_size = second_byte & 0b01111111
raise "All frames sent to a server should be masked according to the websocket spec" unless is_masked
raise "We only support payloads < 126 bytes in length" unless payload_size < 126
STDERR.puts "Payload size: #{ payload_size } bytes"
Bytes 3-7: The masking key
คาดหวัง payloads ทุก incoming frames masked ถ้าจะ unmask ต้อง XOR กับ masking key
Masking key = 4 bytes ถัดไป ไม่ต้อง process แค่ read bytes ใส่ array
mask = 4.times.map { socket.getbyte }
STDERR.puts "Got mask: #{ mask.inspect }"
บอกมาถ้ารู้วิธีอ่าน 4 bytes ใส่ array สวยกว่า
times.mapค่อนข้างแปลก แต่ concise ที่สุดที่คิดได้ ผม @StarrHorne บน twitter
Bytes 8 ขึ้นไป: The payload
เสร็จ metadata ดึง actual payload
data = payload_size.times.map { socket.getbyte }
STDERR.puts "Got masked data: #{ data.inspect }"
จำว่า payload masked พิมพ์จะเป็น garbage Unmask: XOR แต่ละ byte กับ corresponding byte ของ mask เนื่องจาก mask 4 bytes เรา repeat ให้ยาวเท่า payload:
unmasked_data = data.each_with_index.map { |byte, i| byte ^ mask[i % 4] }
STDERR.puts "Unmasked the data: #{ unmasked_data.inspect }"
ตอนนี้มี array of bytes แปลงเป็น unicode string ทุก text ใน Websockets เป็น unicode
STDERR.puts "Converted to a string: #{ unmasked_data.pack('C*').force_encoding('utf-8').inspect }"
Putting it all together
รวมโค้ดทั้งหมด ได้ script แบบนี้:
require 'socket'
require 'digest/sha1'
server = TCPServer.new('localhost', 2345)
loop do
socket = server.accept
STDERR.puts "Incoming Request"
http_request = ""
while (line = socket.gets) && (line != "\r\n")
http_request += line
end
if matches = http_request.match(/^Sec-WebSocket-Key: (\S+)/)
websocket_key = matches[1]
STDERR.puts "Websocket handshake detected with key: #{ websocket_key }"
else
STDERR.puts "Aborting non-websocket connection"
socket.close
next
end
response_key = Digest::SHA1.base64digest([websocket_key, "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"].join)
STDERR.puts "Responding to handshake with key: #{ response_key }"
socket.write <<-eos
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: #{ response_key }
eos
STDERR.puts "Handshake completed. Starting to parse the websocket frame."
first_byte = socket.getbyte
fin = first_byte & 0b10000000
opcode = first_byte & 0b00001111
raise "We don't support continuations" unless fin
raise "We only support opcode 1" unless opcode == 1
second_byte = socket.getbyte
is_masked = second_byte & 0b10000000
payload_size = second_byte & 0b01111111
raise "All incoming frames should be masked according to the websocket spec" unless is_masked
raise "We only support payloads < 126 bytes in length" unless payload_size < 126
STDERR.puts "Payload size: #{ payload_size } bytes"
mask = 4.times.map { socket.getbyte }
STDERR.puts "Got mask: #{ mask.inspect }"
data = payload_size.times.map { socket.getbyte }
STDERR.puts "Got masked data: #{ data.inspect }"
unmasked_data = data.each_with_index.map { |byte, i| byte ^ mask[i % 4] }
STDERR.puts "Unmasked the data: #{ unmasked_data.inspect }"
STDERR.puts "Converted to a string: #{ unmasked_data.pack('C*').force_encoding('utf-8').inspect }"
socket.close
end
Refresh WebSocket tester webpage ส่ง request ไป server ได้ output:
$ ruby websocket_server.rb
Incoming Request
Websocket handshake detected with key: E4i4gDQc1XTIQcQxvf+ODA==
Responding to handshake with key: d9WHst60HtB4IvjOVevrexl0oLA=
Handshake completed. Starting to parse the websocket frame.
Payload size: 16 bytes
Got mask: [80, 191, 161, 254]
Got masked data: [19, 222, 207, 222, 41, 208, 212, 222, 56, 218, 192, 140, 112, 210, 196, 193]
Unmasked the data: [67, 97, 110, 32, 121, 111, 117, 32, 104, 101, 97, 114, 32, 109, 101, 63]
Converted to a string: "Can you hear me?"
ส่งข้อมูลกลับไป Client
ส่ง test message จาก client ไป toy WebSocket server สำเร็จ ตอนส่งกลับจาก server ไป client น่าสนใจ
น้อยลง เพราะไม่ต้องจัดการ masking Frames จาก server ไป client unmasked เสมอ
เหมือน consume frame byte by byte construct frame byte by byte
Byte 1: FIN and opcode
Payload fit one frame เป็น text FIN = 1 opcode = 1 รวมด้วย bit format เดิมได้ number:
output = [0b10000001]
Byte 2: MASKED and payload length
Frame จาก server ไป client MASKED = 0 ignore ได้ payload length = string length
output = [0b10000001, response.size]
Bytes 3 ขึ้นไป: The payload
Payload ไม่ masked แค่ string
response = "Loud and clear!"
STDERR.puts "Sending response: #{ response.inspect }"
output = [0b10000001, response.size, response]
Bombs away!
ตอนนี้มี array data ที่จะส่ง แปลงเป็น string of bytes ส่งผ่าน wire ใช้ Array#pack super-versatile
socket.write output.pack("CCA#{ response.size }")
string แปลก "CCA#{ response.size }" บอก Array#pack ว่า array มี two 8-bit unsigned ints ตามด้วย character string ขนาดที่ระบุ
เปิด network inspector ใน chrome เห็น message มาถูกต้อง

Extra Credit
จบแล้ว! หวังว่าเรียนรู้ WebSockets บ้าง Server ขาดหลายอย่าง หากอยาก exercise ต่อ ดู:
- Support multi-frame payloads
- Binary payload support
- Ping / Pong support
- Long payload support
- Closing handshake