웹 소켓은 요즘 점점 더 많은 언론을 얻고 있습니다. 우리는 그들이 "미래"라고 들었습니다. Rails 5의 ActionCable 덕분에 그 어느 때보다 사용하기 쉽다고 들었습니다. 하지만 웹 소켓이란 정확히 무엇입니까? 어떻게 작동합니까?
이 게시물에서 우리는 Ruby에서 처음부터 간단한 WebSocket 서버를 구축하여 이러한 질문에 답할 것입니다. 완료되면 브라우저와 서버 간에 양방향 통신이 이루어집니다.
<블록 인용>이 게시물의 코드는 학습 연습용입니다. 실제 프로덕션 앱에서 웹 소켓을 구현하려면 뛰어난 websocket-ruby gem을 확인하십시오. WebSocket 사양을 살펴볼 수도 있습니다.
웹 소켓에 대해 들어본 적도 없습니다.
웹 소켓은 일반 HTTP 연결에 내재된 몇 가지 문제를 해결하기 위해 발명되었습니다. 일반 HTTP 연결을 사용하여 웹 페이지를 요청하면 서버에서 콘텐츠를 보낸 다음 연결을 닫습니다. 다른 페이지를 요청하려면 다른 연결을 해야 합니다. 이것은 일반적으로 잘 작동하지만 일부 사용 사례에서는 최상의 접근 방식이 아닙니다.
- 채팅과 같은 일부 응용 프로그램의 경우 새 메시지가 들어오는 즉시 프런트 엔드를 업데이트해야 합니다. 일반적인 HTTP 요청만 있으면 서버를 지속적으로 폴링하여 메시지가 있는지 확인해야 합니다. 새로운 콘텐츠.
- 프론트 엔드 애플리케이션이 서버에 많은 작은 요청을 해야 하는 경우 각 요청에 대해 새 연결을 만드는 오버헤드가 성능 문제가 될 수 있습니다. 이것은 HTTP2에서 덜 문제입니다.
웹 소켓을 사용하면 서버에 하나의 연결을 만든 다음 열린 상태로 유지하고 양방향 통신에 사용합니다.
클라이언트 측
웹 소켓은 일반적으로 브라우저와 웹 서버 간의 통신에 사용됩니다. 브라우저 쪽은 JavaScript로 구현됩니다. 아래 예에서 저는 웹 소켓을 로컬 서버로 열고 메시지를 보내는 아주 간단한 JavaScript를 작성했습니다.
<!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>
작은 정적 서버를 시작하고 웹 브라우저에서 이 파일을 열면 오류가 발생합니다. 아직 서버가 없기 때문에 의미가 있습니다. 우리는 여전히 하나를 구축해야 합니다. :-)
서버 시작
웹 소켓은 정상적인 HTTP 요청으로 수명을 시작합니다. 이상한 수명 주기가 있습니다.
- 브라우저는 "웹 소켓을 만들어주세요"라는 특수 헤더와 함께 일반 HTTP 요청을 보냅니다.
- 서버는 특정 HTTP 응답으로 응답하지만 연결을 닫지 않습니다.
- 브라우저와 서버는 특수한 웹 소켓 프로토콜을 사용하여 열린 연결을 통해 데이터 프레임을 교환합니다.
따라서 첫 번째 단계는 웹 서버를 구축하는 것입니다. 아래 코드에서는 가장 간단한 웹 서버를 만들고 있습니다. 실제로 아무 것도 제공하지 않습니다. 단순히 요청을 기다린 다음 STDERR로 인쇄합니다.
require 'socket'
server = TCPServer.new('localhost', 2345)
loop do
# Wait for a connection
socket = server.accept
STDERR.puts "Incoming Request"
# Read the HTTP request. We know it's finished when we see a line with nothing but \r\n
http_request = ""
while (line = socket.gets) && (line != "\r\n")
http_request += line
end
STDERR.puts http_request
socket.close
end
서버를 실행하고 웹 소켓 테스트 페이지를 새로 고치면 다음과 같이 표시됩니다.
$ 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 요청에는 웹 소켓과 관련된 헤더가 많이 있습니다. 이것은 실제로 websocket handshake의 첫 번째 단계입니다.
악수
모든 웹 소켓 요청은 핸드셰이크로 시작됩니다. 이것은 클라이언트와 서버 모두 웹 소켓이 곧 발생한다는 것을 이해하고 둘 다 프로토콜 버전에 동의하는지 확인하기 위한 것입니다. 다음과 같이 작동합니다.
클라이언트는 이와 같은 HTTP 요청을 보냅니다.
GET / HTTP/1.1
Host: localhost:2345
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: E4i4gDQc1XTIQcQxvf+ODA==
Sec-WebSocket-Version: 13
이 요청의 가장 중요한 부분은 Sec-WebSocket-Key
입니다. . 클라이언트는 서버가 XSS 공격 및 캐싱 프록시에 대한 증거로 이 값의 수정된 버전을 반환할 것으로 기대합니다.
서버 응답
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: d9WHst60HtB4IvjOVevrexl0oLA=
서버 응답은 Sec-WebSocket-Accept
를 제외한 상용구입니다. 헤더. 이 헤더는 다음과 같이 생성됩니다.
# Take the value provided by the client, append a magic
# string to it. Generate the SHA1 hash, then base64 encode it.
Digest::SHA1.base64digest([sec_websocket_accept, "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"].join)
당신의 눈은 당신에게 거짓말을하지 않습니다. 관련된 마법 상수가 있습니다.
핸드셰이크 구현
핸드셰이크를 완료하기 위해 서버를 업데이트합시다. 먼저 요청 헤더에서 보안 토큰을 가져옵니다.
# Grab the security key from the headers.
# If one isn't present, close the 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
이제 보안 키를 사용하여 유효한 응답을 생성합니다.
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."
websocket 테스트 페이지를 새로고침하면 더 이상 연결 오류가 없음을 알 수 있습니다. 연결이 설정되었습니다!
다음은 보안 키와 응답 키를 보여주는 서버의 출력입니다.
$ ruby server2.rb
Incoming Request
Websocket handshake detected with key: Fh06+WnoTQQiVnX5saeYMg==
Responding to handshake with key: nJg1c2upAHixOmXz7kV2bJ2g/YQ=
Handshake completed.
웹 소켓 프레임 프로토콜
WebSocket 연결이 설정되면 HTTP는 더 이상 사용되지 않습니다. 대신 WebSocket 프로토콜을 통해 데이터가 교환됩니다.
프레임은 WebSocket 프로토콜의 기본 단위입니다.
WebSocket 프로토콜은 프레임 기반입니다. 하지만 이것은 무엇을 의미합니까?
웹 브라우저에 WebSocket을 통해 데이터를 보내도록 요청하거나 서버에 응답을 요청할 때마다 데이터는 각 청크에서 일련의 청크로 분할되어 프레임을 만들기 위해 일부 메타데이터에 래핑됩니다.
다음은 프레임 구조의 모습입니다. 상단의 숫자는 비트입니다. 확장된 페이로드 길이와 같은 일부 필드는 항상 존재하지 않을 수 있습니다.
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 ... |
+---------------------------------------------------------------+
가장 먼저 눈에 띄는 것은 이것이 바이너리 프로토콜이라는 것입니다. 우리는 약간의 조작을 해야 하지만 걱정하지 마십시오. 그렇게 어렵지는 않을 것입니다. 그림 상단의 숫자는 비트입니다. 그리고 일부 필드는 항상 존재하지 않을 수 있습니다. 예를 들어 페이로드가 127바이트 미만인 경우 확장된 페이로드 길이가 나타납니다.
데이터 수신
이제 핸드셰이크가 완료되었으므로 이진 프레임 구문 분석을 시작할 수 있습니다. 일을 단순하게 유지하기 위해 한 번에 한 바이트씩 들어오는 프레임을 살펴보겠습니다. 그 후에는 실제로 작동하는 모습을 볼 수 있도록 모든 것을 함께 정리하겠습니다.
바이트 1:FIN 및 Opcode
위의 표에서 첫 번째 바이트(처음 8비트)에 몇 가지 데이터가 포함되어 있음을 알 수 있습니다.
- FIN:1비트 false인 경우 메시지가 여러 프레임으로 분할됩니다.
- opcode:4비트 페이로드가 텍스트인지, 바이너리인지, 아니면 연결을 유지하기 위한 "핑"인지 알려줍니다.
- RSV:3비트 이들은 현재 WebSocket 사양에서 사용되지 않습니다.
첫 번째 바이트를 얻으려면 IO#getbyte
를 사용합니다. 방법. 그리고 데이터를 추출하기 위해 간단한 비트마스킹을 사용할 것입니다. 비트 연산자에 익숙하지 않다면 내 다른 기사 Bitwise hacks in Ruby
first_byte = socket.getbyte
fin = first_byte & 0b10000000
opcode = first_byte & 0b00001111
# Our server will only support single-frame, text messages.
# Raise an exception if the client tries to send anything else.
raise "We don't support continuations" unless fin
raise "We only support opcode 1" unless opcode == 1
바이트 2:MASK 및 페이로드 길이
프레임의 두 번째 바이트에는 페이로드에 대한 추가 정보가 포함되어 있습니다.
- 마스크:1비트 페이로드가 마스킹되었는지 여부를 나타내는 부울 플래그입니다. 그것이 사실이라면 페이로드를 사용하기 전에 "마스크 해제"해야 합니다. 이것은 클라이언트에서 들어오는 프레임에 대해 항상 사실이어야 합니다. 사양에 그렇게 나와 있습니다.
- 페이로드 길이:7비트 페이로드가 126바이트 미만이면 길이가 여기에 저장됩니다. 이 값이 126보다 크면 길이를 제공하기 위해 더 많은 바이트가 따라옵니다.
두 번째 바이트를 처리하는 방법은 다음과 같습니다.
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"
바이트 3-7:마스킹 키
들어오는 모든 프레임의 페이로드가 마스킹될 것으로 예상합니다. 콘텐츠의 마스크를 해제하려면 마스킹 키에 대해 XOR해야 합니다.
이 마스킹 키는 다음 4바이트를 구성합니다. 전혀 처리할 필요가 없으며 바이트를 배열로 읽어오기만 하면 됩니다.
mask = 4.times.map { socket.getbyte }
STDERR.puts "Got mask: #{ mask.inspect }"
<블록 인용>
4바이트를 배열로 읽는 더 좋은 방법을 알고 있다면 알려주세요. times.map
조금 이상하지만 내가 생각할 수있는 가장 간결한 접근 방식이었습니다. 저는 트위터의 @StarrHorne입니다.
바이트 8 이상:페이로드
좋습니다. 메타데이터 작업을 마쳤습니다. 이제 실제 페이로드를 가져올 수 있습니다.
data = payload_size.times.map { socket.getbyte }
STDERR.puts "Got masked data: #{ data.inspect }"
이 페이로드가 마스킹되었음을 기억하십시오. 따라서 출력하면 쓰레기처럼 보일 것입니다. 마스크를 해제하려면 각 바이트를 마스크의 해당 바이트와 XOR하면 됩니다. 마스크의 길이가 4바이트에 불과하므로 페이로드의 길이와 일치하도록 반복합니다.
unmasked_data = data.each_with_index.map { |byte, i| byte ^ mask[i % 4] }
STDERR.puts "Unmasked the data: #{ unmasked_data.inspect }"
이제 바이트 배열이 있습니다. 유니코드 문자열로 변환해야 합니다. Websockets의 모든 텍스트는 유니코드입니다.
STDERR.puts "Converted to a string: #{ unmasked_data.pack('C*').force_encoding('utf-8').inspect }"
모두 합치기
이 코드를 모두 합치면 다음과 같은 스크립트가 생성됩니다.
require 'socket' # Provides TCPServer and TCPSocket classes
require 'digest/sha1'
server = TCPServer.new('localhost', 2345)
loop do
# Wait for a connection
socket = server.accept
STDERR.puts "Incoming Request"
# Read the HTTP request. We know it's finished when we see a line with nothing but \r\n
http_request = ""
while (line = socket.gets) && (line != "\r\n")
http_request += line
end
# Grab the security key from the headers. If one isn't present, close the 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
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
WebSocket 테스터 웹 페이지를 새로 고치고 내 서버에 요청하면 다음과 같은 출력이 표시됩니다.
$ 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?"
클라이언트에 데이터 다시 보내기
그래서 우리는 클라이언트에서 장난감 WebSocket 서버로 테스트 메시지를 성공적으로 보냈습니다. 이제 서버에서 클라이언트로 메시지를 다시 보내는 것이 좋습니다.
이것은 마스킹 작업을 처리할 필요가 없기 때문에 조금 덜 복잡합니다. 서버에서 클라이언트로 전송된 프레임은 항상 마스크 해제됩니다.
한 번에 한 바이트씩 프레임을 소비한 것처럼 한 번에 한 바이트씩 구성할 것입니다.
바이트 1:FIN 및 opcode
우리의 페이로드는 한 프레임에 들어갈 것이고 텍스트가 될 것입니다. 즉, FIN은 1과 같고 opcode도 1과 같습니다. 이전에 사용한 것과 동일한 비트 형식을 사용하여 이들을 결합하면 다음과 같은 숫자가 나타납니다.
output = [0b10000001]
바이트 2:MASKED 및 페이로드 길이
이 프레임이 서버에서 클라이언트로 이동하기 때문에 MASKED는 0과 같습니다. 즉, 무시할 수 있습니다. 페이로드 길이는 문자열의 길이일 뿐입니다.
output = [0b10000001, response.size]
바이트 3 이상:페이로드
페이로드는 마스킹되지 않고 문자열일 뿐입니다.
response = "Loud and clear!"
STDERR.puts "Sending response: #{ response.inspect }"
output = [0b10000001, response.size, response]
폭탄이 떨어져!
이 시점에서 전송하려는 데이터가 포함된 배열이 있습니다. 이것을 유선으로 보낼 수 있는 바이트 문자열로 변환해야 합니다. 이를 위해 매우 다재다능한 Array#pack
방법.
socket.write output.pack("CCA#{ response.size }")
그 이상한 문자열 "CCA#{ response.size }"
Array#pack
에 알려줍니다. 배열에 2개의 8비트 unsigned int와 지정된 크기의 문자열이 포함됩니다.
Chrome에서 네트워크 검사기를 열면 메시지가 크고 명확하게 전달되는 것을 볼 수 있습니다.
추가 크레딧
그게 다야! WebSocket에 대해 배웠기를 바랍니다. 서버에 없는게 많습니다. 운동을 계속하고 싶다면 다음을 참조하십시오.
- 다중 프레임 페이로드 지원
- 바이너리 페이로드 지원
- 핑/퐁 지원
- 긴 페이로드 지원
- 닫는 악수