Computer >> บทช่วยสอนคอมพิวเตอร์ >  >> การเขียนโปรแกรม >> Ruby

สร้าง Ruby WebSocket Server ตั้งแต่ศูนย์: คู่มือขั้นตอน

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 เราต้องสร้าง :-)

สร้าง Ruby WebSocket Server ตั้งแต่ศูนย์: คู่มือขั้นตอน

เริ่ม Server

Web socket เริ่มชีวิตเป็น normal HTTP requests มี lifecycle แปลกๆ:

  1. Browser ส่ง normal HTTP request พร้อม special headers บอก "please make me a websocket"
  2. Server ตอบ HTTP response เฉพาะ แต่ ไม่ CLOSE THE CONNECTION
  3. 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!

สร้าง Ruby WebSocket Server ตั้งแต่ศูนย์: คู่มือขั้นตอน

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 มาถูกต้อง

สร้าง Ruby WebSocket Server ตั้งแต่ศูนย์: คู่มือขั้นตอน

Extra Credit

จบแล้ว! หวังว่าเรียนรู้ WebSockets บ้าง Server ขาดหลายอย่าง หากอยาก exercise ต่อ ดู:

  • Support multi-frame payloads
  • Binary payload support
  • Ping / Pong support
  • Long payload support
  • Closing handshake