웹소켓으로 채팅 프로그램 만들기
cilent : javascript
server: python2
순서
- 소켓 준비
- WebSocket Handshake (클라이언트 -> 서버 : 핸드쉐이킹 요청, 서버 -> 클라이언트 : 핸드 쉐이킹 응답)
- 데이터 프레임 교환
- Ping and Pong
- Closing the connection
1. WebSocket Handshake
클라이언트 -> 서버 : 핸드쉐이킹 요청
GET /chat HTTP/1.1 Host: example.com:8000 Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== ( 키 값 ) Sec-WebSocket-Version: 13
서버 -> 클라이언트 : 핸드 쉐이킹 응답
HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= ( 변환된 키 값 )
키 값에 "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"를 더해 준 후 SHA-1 hash 변환
2. 데이터 프레임 교환
웹소켓 데이터 프레임
+---------------------------------------------------------------+
0(byte) 1 2 3
0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7
+-+-+-+-+-------+-+-------------+-------------------------------+
|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| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
4 5 6 7
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
8 9 10 11
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
12 13 14 15
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------- - - - - - - - - - - - - - - - +
: Payload Data continued ... :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Payload Data continued ... |
+---------------------------------------------------------------+
MSB (최상위 비트)
FIN : 마지막 메세지
FIN = 0 ( 마지막 메세지가 아님. 서버에서 뒤에 메세지들 까지 수신)
FIN = 1 ( 마지막 메세지. 전체 메세지 수신 완료 메세지 처리)
RSV1-3 : 0. 사용안함. 확장 프로토콜 또는 추후를 위해 할당
opcode : 뒤의 Payload 데이터의 포멧을 나타냄
- 0x0 denotes a continuation frame ( 연속 프레임. 데이터가 여러 조각으로 분리된 경우 )
- 0x1 denotes a text frame ( 문자열 데이터 )
- 0x2 denotes a binary frame ( 2진 데이터 )
- 0x3-7 are reserved for further non-control frames (아무런 의미 없음)
- 0x8 denotes a connection close ( 접속 종료 )
- 0x9 denotes a ping
- 0xA denotes a pong
- 0xB-F are reserved for further control frames (아무런 의미 없음)
MASK : 메세지 인코딩 여부.
클라이언트 -> 서버 : MASK = 1 ( 항상 마스킹 되어야 함. 아니면 연결 종료)
서버 -> 클라이언트 : MASK = 0 ( 항상 데이터는 마스킹 되지 않은 상태)
Payload len : 9-15bit를 unsigned integer로 읽음.
Payload len >= 125 : Payload의 길이 = 9-15bit의 unsigned integer 값 (Extended payload length 존재 X)
Payload len == 126 : Payload의 길이 = 16-31bit의 unsigned integer 값 (16bits)
Payload len == 127 : Payload의 길이 = 16-79bit의 unsigned integer 값 (64bits)
Extended payload length : Payload len 값 에 따라 존재하지 않거나 2bytes,8bytes를 가짐
Masging-key : MASK가 1일때(클라이언트 -> 서버 전송 시 데이터가 마스킹 된 경우) 보내는 데이터를 마스크 할 때 사용하는 키.
(MASK가 0일 경우 존재하지 않음)
데이터를 마스크 처리 하거나 복원 할 때 사용
원본data-octet ^ 마스킹key-octet = 마스킹된data-octet
마스킹data-octet ^ 마스킹key-octet = 원본data-octet
j = i MOD 4
transformed-octet-i = original-octet-i XOR masking-key-octet-j
Payload Data : 보내는 데이터. Payload len 이나, Extended payload length 값에 해당하는 길이를 가짐
Server (python2) (select사용)
# -*- coding:utf-8-*-
import socket
import select
import re
import base64
import hashlib
import struct
import sys
from signal import signal, SIGPIPE, SIG_DFL
signal(SIGPIPE,SIG_DFL)
def send(input_list, msg):
try:
data = bytearray(msg.encode('utf-8'))
# payload가 126일때 extended_payload_len을 2바이트 가지는데 이때 최대 값이 65535
if len(data) > 65535 : frame = bytearray([b'\x81', 127]) + bytearray(struct.pack('>Q', len(data)))+ data
elif len(data) > 125 : frame = bytearray([b'\x81', 126]) + bytearray(struct.pack('>H', len(data)))+ data
else : frame = bytearray([b'\x81', len(data)]) + data
# 클라이언트 리스트 모두에게 send ( 서버가 아니고 sys.stdin도 아닌 )
for client in [sock for sock in input_list if not sock == server and not sock == sys.stdin] : client.sendall(frame)
except Exception as e:
print "send ERROR : " + str(e)
def recv(client):
first_byte = bytearray(client.recv(1))[0]
second_byte = bytearray(client.recv(1))[0]
FIN = (0xFF & first_byte) >> 7
opcode = (0x0F & first_byte)
mask = (0xFF & second_byte) >> 7
payload_len = (0x7F & second_byte)
if opcode < 3:
# payload_len 구하기
if payload_len == 126 : payload_len = struct.unpack_from('>H', bytearray(client.recv(2)))[0]
elif payload_len == 127 : payload_len = struct.unpack_from('>Q', bytearray(client.recv(8)))[0]
# masking_key 구해서 masking된것 복구하기 ( mask가 1일 경우에만 존재 )
if mask == 1:
masking_key = bytearray(client.recv(4))
masked_data = bytearray(client.recv(payload_len))
data = [masked_data[i] ^ masking_key[i%4] for i in range(len(masked_data))]
else: data = bytearray(client.recv(payload_len))
else: return opcode, bytearray(b'\x00') # opcode 값을 넘김
print bytearray(data).decode('utf-8', 'ignore') # 받은거 콘솔에 출력용
return opcode, bytearray(data)
def handshake(client):
try:
request = client.recv(2048)
m = re.match('[\\w\\W]+Sec-WebSocket-Key: (.+)\r\n[\\w\\W]+\r\n\r\n', request)
key = m.group(1)+'258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
response = "HTTP/1.1 101 Switching Protocols\r\n"+\
"Upgrade: websocket\r\n"+\
"Connection: Upgrade\r\n"+\
"Sec-WebSocket-Accept: %s\r\n"+\
"\r\n"
r = response % ((base64.b64encode(hashlib.sha1(key).digest()),))
client.send(r)
print "---handshake end!---"
except Exception as e:
print "handshake ERROR : " + str(e)
def handler_client(client):
try:
opcode, data = recv(client)
if opcode == 0x8:
print 'close frame received'
input_list.remove(client)
return
elif opcode == 0x1:
if len(data) == 0:
# input_list.remove(client)
# return
print "emty data"
else:
# msg = data.decode('utf-8', 'ignore')
msg = [c[1][0]+":"+str(c[1][1]) for c in client_info if c[0] == client][0] + " :: " + data.decode('utf-8', 'ignore')
# 클라이언트 리스트를 매개변수로 보냄
send(input_list, msg)
else : print 'frame not handled : opcode=' + str(opcode) + ' len=' + str(len(data))
except Exception as e:
print "handler ERROR : " + str(e)
print "disconnected"
input_list.remove(client)
def start_server(host,port):
try:
# host = 'localhost'
# port = 8765
global server
global input_list
global client_info
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind((host,port))
server.listen(0)
input_list = [server, sys.stdin] # sys.stdin은 쉘창에서 입력받은것 때문에 넣어줌
client_info = []
print "Server : " + host + ":" + str(port)
while True:
# select 함수는 관찰될 read, write, except 리스트가 인수로 들어가며
# 응답받은 read, write, except 리스트가 반환된다.
# input_list 내에 있는 소켓들에 데이터가 들어오는지 감시한다.
# 다르게 말하면 input_list 내에 읽을 준비가 된 소켓이 있는지 감시한다.
input_ready, write_ready, except_ready = select.select(input_list, [], [],10)
# 응답받은 read 리스트 처리
for ir in input_ready:
# 클라이언트가 접속했으면 처리함
if ir == server:
client, addr = server.accept()
print "connected : " + str(addr)
handshake(client)
# input_list에 추가함으로써 데이터가 들어오는 것을 감시함
input_list.append(client)
client_info.append((client, addr))
# 쉘 창 입력. 입력된 데이터 클라이언트에게 전송
elif ir == sys.stdin : send(input_list, "Administrator :: " + sys.stdin.readline())
# 클라이언트소켓에 데이터가 들어왔으면
else : handler_client(ir)
except Exception as e:
print "start_server ERROR " + str(e)
server.close()
sys.exit()
except KeyboardInterrupt:
# 부드럽게 종료하기
print "키보드 강제 종료"
server.close()
sys.exit()
start_server('192.168.10.1(아이피주소)',8765)
(multi thread 사용)
# -*- coding:utf-8-*-
import socket
import threading
import re
import base64
import hashlib
import struct
import sys
from signal import signal, SIGPIPE, SIG_DFL
signal(SIGPIPE,SIG_DFL)
def send(client_tuple, msg):
try:
data = bytearray(msg.encode('utf-8'))
# payload가 126일때 extended_payload_len을 2바이트 가지는데 이때 최대 값이 65535
if len(data) > 65535 : frame = bytearray([b'\x81', 127]) + bytearray(struct.pack('>Q', len(data)))+ data
elif len(data) > 125 : frame = bytearray([b'\x81', 126]) + bytearray(struct.pack('>H', len(data)))+ data
else : frame = bytearray([b'\x81', len(data)]) + data
# 클라이언트 리스트 모두에게 send
for client in client_tuple : client[0].sendall(frame)
return 0
except Exception as e:
print "send ERROR : " + str(e)
return -1
def recv(client):
first_byte = bytearray(client.recv(1))[0]
second_byte = bytearray(client.recv(1))[0]
FIN = (0xFF & first_byte) >> 7
opcode = (0x0F & first_byte)
mask = (0xFF & second_byte) >> 7
payload_len = (0x7F & second_byte)
if opcode < 3:
# payload_len 구하기
if payload_len == 126 : payload_len = struct.unpack_from('>H', bytearray(client.recv(2)))[0]
elif payload_len == 127 : payload_len = struct.unpack_from('>Q', bytearray(client.recv(8)))[0]
# masking_key 구해서 masking된것 복구하기 ( mask가 1일 경우에만 존재 )
if mask == 1:
masking_key = bytearray(client.recv(4))
masked_data = bytearray(client.recv(payload_len))
data = [masked_data[i] ^ masking_key[i%4] for i in range(len(masked_data))]
else: data = bytearray(client.recv(payload_len))
else: return opcode, bytearray(b'\x00') # opcode 값을 넘김
print bytearray(data).decode('utf-8', 'ignore') # 받은거 콘솔에 출력용
return opcode, bytearray(data)
def handshake(client):
request = client.recv(2048)
m = re.match('[\\w\\W]+Sec-WebSocket-Key: (.+)\r\n[\\w\\W]+\r\n\r\n', request)
key = m.group(1)+'258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
response = "HTTP/1.1 101 Switching Protocols\r\n"+\
"Upgrade: websocket\r\n"+\
"Connection: Upgrade\r\n"+\
"Sec-WebSocket-Accept: %s\r\n"+\
"\r\n"
r = response % ((base64.b64encode(hashlib.sha1(key).digest()),))
client.send(r)
print "---handshake end!---"
return 0
clients = [] # (클라이언트, addr) 리스트
threads = [] # (스레드 , addr) 리스트
def handler_client(client, addr):
handshakestat = handshake(client)
if not handshakestat == 0 : return
try:
while True:
opcode, data = recv(client)
if opcode == 0x8:
clients.remove((client, addr))
return
elif opcode == 0x1:
if len(data) == 0:
# return
print "emty data"
continue
# msg = data.decode('utf-8', 'ignore')
msg = addr[0] + ":" +str(addr[1]) + ' :: ' + data.decode('utf-8', 'ignore')
# 클라이언트 리스트를 매개변수로 보냄
sendstat = send(clients, msg)
if not sendstat == 0 : print 'sendstat err'
else : print 'frame not handled : opcode=' + str(opcode) + ' len=' + str(len(data))
except Exception as e:
print "handler ERROR : " + str(e)
print "disconnected"
client.close()
def start_server(host,port):
try:
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # 기본값
# SO_REUSEADDR 이미 사용된 주소를 재사용
# 소켓이 종료 되도 커널단에서 해당 소켓을 바인딩해서 사용 중
# SO_REUSEADDR 옵션을 이용하여 기존에 바인딩된 주소를 재사용
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind((host,port))
server.listen(0)
print "Server : " + host + ":" + str(port)
while True:
# clients와 threads의 addr을 비교하여 thread를 죽임 새로운 접근이 있어야만 동작
for t in [th for th in threads if not th[1] in [c[1] for c in clients]]:
t[0].join()
threads.remove(t)
if len(threads) == 0: print "쓰레드 없음"
client, addr = server.accept()
print "connected : " + str(addr)
thread = threading.Thread(target = handler_client, args = (client, addr))
thread.start()
clients.append((client, addr))
threads.append((thread, addr))
except Exception as e:
print "start_server ERROR " + str(e)
except KeyboardInterrupt:
print "키보드 강제 종료"
server.close()
sys.exit(0)
start_server('192.168.0.1(아이피주소)',8765)
Client (javascript)
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css">
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.2/jquery.min.js"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/js/bootstrap.min.js"></script>
<title>채팅</title>
<div class="container">
<br>
<div id="chatArea" style="overflow-y: auto; height: 379px;"></div>
<div class="form-inline">
<input type="text" class="form-control" id="msg" onkeypress="if( event.keyCode==13 ){sendMsg();}">
<input type="button" class="btn btn-default" onclick="sendMsg()" value="전송">
</div>
</div>
<script type="text/javascript">
var ws = 0
document.addEventListener("DOMContentLoaded", function(){
if (ws != 0 && ws.readyState != 1) return;
if ("WebSocket" in window) {
// alert("WebSocket is supported by your Browser!");
ws = new WebSocket("ws://192.168.10.1(아이피주소):8765");
ws.onopen = function() {
console.log("connected");
};
ws.onmessage = function(event) {
var data = event.data.replace(/</gi, "<");
data = data.replace(/>/gi, "> ");
$("#chatArea").append(data + "<br />");
$("#chatArea").scrollTop($("#chatArea")[0].scrollHeight);
}
window.onbeforeunload = function(event) {
ws.close();
};
}
else {
console.log("WebSocket NOT supported by your Browser!");
}
});
function sendMsg() {
ws.send(document.getElementById("msg").value);
document.getElementById("msg").value = '';
}
function ChatAreaResize() {
var div2 = document.getElementById('chatArea');
// div2.style.width = window.innerWidth - 200 + 'px';
div2.style.height = window.innerHeight -200 + 'px';
}
window.onload = function() {
ChatAreaResize();
// 브라우저 크기가 변할 시 동적으로 사이즈를 조절해야 하는경우
window.addEventListener('resize', ChatAreaResize);
}
</script>
구동 화면
출처 : 웹소켓 설명
참고: websocket 프레임, js, 파이썬 select 함수
티스토리블로그 : https://unhosted.tistory.com/15?category=735973