웹소켓으로 채팅 프로그램 만들기

cilent : javascript

server: python2

순서

  1. 소켓 준비
  2. WebSocket Handshake (클라이언트 -> 서버 : 핸드쉐이킹 요청, 서버 -> 클라이언트 : 핸드 쉐이킹 응답)
  3. 데이터 프레임 교환
  4. Ping and Pong
  5. 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