1. 程式人生 > >web新特性 之 WebSocket

web新特性 之 WebSocket

雙工 pda flat ads switch hide quest 斷開連接 lib

詳情參見:你真的了解WebSocket嗎? WebSocket系列教程 HTML5新特性之WebSocket

  WebSocket協議是基於TCP的一種新的協議。WebSocket最初在HTML5規範中被引用為TCP連接,作為基於TCP的套接字API的占位符。它實現了瀏覽器與服務器全雙工(full-duplex)通信。其本質是保持TCP連接,在瀏覽器和服務端通過Socket進行通信。
  服務端與客戶端的連接不斷開,實現全雙工的操作。及服務端或是客戶端都會給對方發送消息。

 WebSocket(內部還是socket)
	- 本質(magic string)魔法字符串,通過內部封裝的通信加密規則,進行通信。
	- 應用
		- 連接
		- 驗證【握手信息】(magic string)
		- 收發消息

一、啟動服務端

import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((‘127.0.0.1‘, 8002))
sock.listen(5)
# 等待用戶連接
conn, address = sock.accept()
...
...
...

啟動Socket服務器後,等待用戶【連接】,然後進行收發數據。

二、 客戶端連接

  客戶端:瀏覽器 (必須有websocket包) 在瀏覽器中已經高度封裝,url地址前引用標誌:ws

#客戶端 通過websocket 創建一個對象,完成的是三個操作!
#創建連接
#發送消息
#接收驗證消息
<script type="text/javascript">
    var socket = new WebSocket("ws://127.0.0.1:8002/xxoo");
    ...
</script>

  當客戶端向服務端發送連接請求時,不僅連接還會發送【握手】信息,並等待服務端響應,至此連接才創建成功!

三、建立連接【握手】

操作對象:服務端, socket

import socket
 
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((‘127.0.0.1‘, 8002))
sock.listen(5)
# 獲取客戶端socket對象
conn, address = sock.accept()
# 獲取客戶端的【握手】信息
data = conn.recv(1024)
...
...
...
conn.send(‘響應【握手】信息‘)

  連接成功,在請求的信息中有用的信息為:Sec-WebSocket-Key:***的鍵值對,用於驗證服務端有沒有websocket通信的功能。這個功能的驗證就是判斷服務端能不能按照歸則對這段字符串進行加密【叫做握手信息】。加密之後的字符串【握手信息】返回給客戶端進行解密,進行通信認證。

  加密規則:hmac1,base64加密,+ magic string

請求【握手】信息為:

GET /chatsocket HTTP/1.1
Host: 127.0.0.1:8002
Connection: Upgrade
Pragma: no-cache
Cache-Control: no-cache
Upgrade: websocket
Origin: http://localhost:63342
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: mnwFxiOlctXFN/DeMt1Amg==
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
...
...

請求和響應的【握手】信息需要遵循規則:

  從請求【握手】信息中提取 Sec-WebSocket-Key
  利用magic_string 和 Sec-WebSocket-Key 進行hmac1加密,再進行base64加密
  將加密結果響應給客戶端
  註:magic string為:258EAFA5-E914-47DA-95CA-C5AB0DC85B11

技術分享
import socket
import base64
import hashlib
 
def get_headers(data):
    """
    將請求頭格式化成字典
    :param data:
    :return:
    """
    header_dict = {}
    data = str(data, encoding=utf-8)
 
    for i in data.split(\r\n):
        print(i)
    header, body = data.split(\r\n\r\n, 1)
    header_list = header.split(\r\n)
    for i in range(0, len(header_list)):
        if i == 0:
            if len(header_list[i].split( )) == 3:
                header_dict[method], header_dict[url], header_dict[protocol] = header_list[i].split( )
        else:
            k, v = header_list[i].split(:, 1)
            header_dict[k] = v.strip()
    return header_dict
 
 
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((127.0.0.1, 8002))
sock.listen(5)
 
conn, address = sock.accept()
data = conn.recv(1024)
headers = get_headers(data) # 提取請求頭信息
# 對請求頭中的sec-websocket-key進行加密
response_tpl = "HTTP/1.1 101 Switching Protocols\r\n"       "Upgrade:websocket\r\n"       "Connection: Upgrade\r\n"       "Sec-WebSocket-Accept: %s\r\n"       "WebSocket-Location: ws://%s%s\r\n\r\n"
magic_string = 258EAFA5-E914-47DA-95CA-C5AB0DC85B11
value = headers[Sec-WebSocket-Key] + magic_string
ac = base64.b64encode(hashlib.sha1(value.encode(utf-8)).digest())
response_str = response_tpl % (ac.decode(utf-8), headers[Host], headers[url])
# 響應【握手】信息
conn.send(bytes(response_str, encoding=utf-8))
...
...
...
    
提取Sec-WebSocket-Key值並加密:

四、客戶端和服務端收發數據

  進行通信交互的話,就需要瀏覽器向服務端發送數據:socket.send(),而服務端接收到的數據是加密類型的字節數據【因為websocket內部有封包機制】。只有對數據進行進行解包,才能正常顯示瀏覽器發送的信息。
  客戶端和服務端傳輸數據時,需要對數據進行【封包】和【解包】。客戶端的JavaScript類庫已經封裝【封包】和【解包】過程,但Socket服務端需要手動實現。

1、針對服務端來說,獲取客戶端發送的數據進行解包。

解包規則:(數據頭+數據全部加密) 用第二個字節的後7位判斷(做位運算獲取!與127做與判斷)
  獲取所有的數據值,每個字節都與mask進行與運算進行解碼

解包詳細過程:

0                   1                   2                   3
 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 ...                |
+---------------------------------------------------------------+

官方文檔詳細講解:

The MASK bit simply tells whether the message is encoded. Messages from the client must be masked, so your server should expect this to be 1. (In fact, section 5.1 of the spec says that your server must disconnect from a client if that client sends an unmasked message.) When sending a frame back to the client, do not mask it and do not set the mask bit. We‘ll explain masking later. Note: You have to mask messages even when using a secure socket.RSV1-3 can be ignored, they are for extensions.

The opcode field defines how to interpret the payload data: 0x0 for continuation, 0x1 for text (which is always encoded in UTF-8), 0x2 for binary, and other so-called "control codes" that will be discussed later. In this version of WebSockets, 0x3 to 0x7 and 0xB to 0xF have no meaning.

The FIN bit tells whether this is the last message in a series. If it‘s 0, then the server will keep listening for more parts of the message; otherwise, the server should consider the message delivered. More on this later.

Decoding Payload Length

To read the payload data, you must know when to stop reading. That‘s why the payload length is important to know. Unfortunately, this is somewhat complicated. To read it, follow these steps:

Read bits 9-15 (inclusive) and interpret that as an unsigned integer. If it‘s 125 or less, then that‘s the length; you‘re done. If it‘s 126, go to step 2. If it‘s 127, go to step 3.
Read the next 16 bits and interpret those as an unsigned integer. You‘re done.
Read the next 64 bits and interpret those as an unsigned integer (The most significant bit MUST be 0). You‘re done.
Reading and Unmasking the Data

If the MASK bit was set (and it should be, for client-to-server messages), read the next 4 octets (32 bits); this is the masking key. Once the payload length and masking key is decoded, you can go ahead and read that number of bytes from the socket. Let‘s call the data ENCODED, and the key MASK. To get DECODED, loop through the octets (bytes a.k.a. characters for text data) of ENCODED and XOR the octet with the (i modulo 4)th octet of MASK. In pseudo-code (that happens to be valid JavaScript):

 

var DECODED = "";
for (var i = 0; i < ENCODED.length; i++) {
    DECODED[i] = ENCODED[i] ^ MASK[i % 4];
}

 

Now you can figure out what DECODED means depending on your application.
技術分享
info = conn.recv(8096) #接收到的數據

    payload_len = info[1] & 127
    if payload_len == 126:
        extend_payload_len = info[2:4]
        mask = info[4:8] #解密的鑰匙
        decoded = info[8:] #加密的數據
    elif payload_len == 127:
        extend_payload_len = info[2:10]
        mask = info[10:14]
        decoded = info[14:]
    else:
        extend_payload_len = None
        mask = info[2:6]
        decoded = info[6:]
    #對每個加密的字節進行循環,與解密鑰匙進行與運算
    bytes_list = bytearray()
    for i in range(len(decoded)):
        chunk = decoded[i] ^ mask[i % 4]
        bytes_list.append(chunk)
    body = str(bytes_list, encoding=utf-8)
    print(body)
基於Python實現解包過程(未實現長內容)

2、而發送數據還需要進行封包:

技術分享
def send_msg(conn, msg_bytes):
    """
    WebSocket服務端向客戶端發送消息
    :param conn: 客戶端連接到服務器端的socket對象,即: conn,address = socket.accept()
    :param msg_bytes: 向客戶端發送的字節
    :return: 
    """
    import struct

    token = b"\x81"
    length = len(msg_bytes)
    if length < 126:
        token += struct.pack("B", length)
    elif length <= 0xFFFF:
        token += struct.pack("!BH", 126, length)
    else:
        token += struct.pack("!BQ", 127, length)

    msg = token + msg_bytes
    conn.send(msg)
    return True
封包方法

五、基於Python實現簡單示例

a. 基於Python socket實現的WebSocket服務端:

#!/usr/bin/env python
# -*- coding:utf-8 -*-
import socket
import base64
import hashlib
 
 
def get_headers(data):
    """
    將請求頭格式化成字典
    :param data:
    :return:
    """
    header_dict = {}
    data = str(data, encoding=‘utf-8‘)
 
    header, body = data.split(‘\r\n\r\n‘, 1)
    header_list = header.split(‘\r\n‘)
    for i in range(0, len(header_list)):
        if i == 0:
            if len(header_list[i].split(‘ ‘)) == 3:
                header_dict[‘method‘], header_dict[‘url‘], header_dict[‘protocol‘] = header_list[i].split(‘ ‘)
        else:
            k, v = header_list[i].split(‘:‘, 1)
            header_dict[k] = v.strip()
    return header_dict
 
 
def send_msg(conn, msg_bytes):
    """
    WebSocket服務端向客戶端發送消息
    :param conn: 客戶端連接到服務器端的socket對象,即: conn,address = socket.accept()
    :param msg_bytes: 向客戶端發送的字節
    :return:
    """
    import struct
 
    token = b"\x81"
    length = len(msg_bytes)
    if length < 126:
        token += struct.pack("B", length)
    elif length <= 0xFFFF:
        token += struct.pack("!BH", 126, length)
    else:
        token += struct.pack("!BQ", 127, length)
 
    msg = token + msg_bytes
    conn.send(msg)
    return True
 
 
def run():
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    sock.bind((‘127.0.0.1‘, 8003))
    sock.listen(5)
 
    conn, address = sock.accept()
    data = conn.recv(1024)
    headers = get_headers(data)
    response_tpl = "HTTP/1.1 101 Switching Protocols\r\n"                    "Upgrade:websocket\r\n"                    "Connection:Upgrade\r\n"                    "Sec-WebSocket-Accept:%s\r\n"                    "WebSocket-Location:ws://%s%s\r\n\r\n"
 
    value = headers[‘Sec-WebSocket-Key‘] + ‘258EAFA5-E914-47DA-95CA-C5AB0DC85B11‘
    ac = base64.b64encode(hashlib.sha1(value.encode(‘utf-8‘)).digest())
    response_str = response_tpl % (ac.decode(‘utf-8‘), headers[‘Host‘], headers[‘url‘])
    conn.send(bytes(response_str, encoding=‘utf-8‘))
 
    while True:
        try:
            info = conn.recv(8096)
        except Exception as e:
            info = None
        if not info:
            break
        payload_len = info[1] & 127
        if payload_len == 126:
            extend_payload_len = info[2:4]
            mask = info[4:8]
            decoded = info[8:]
        elif payload_len == 127:
            extend_payload_len = info[2:10]
            mask = info[10:14]
            decoded = info[14:]
        else:
            extend_payload_len = None
            mask = info[2:6]
            decoded = info[6:]
 
        bytes_list = bytearray()
        for i in range(len(decoded)):
            chunk = decoded[i] ^ mask[i % 4]
            bytes_list.append(chunk)
        body = str(bytes_list, encoding=‘utf-8‘)
        send_msg(conn,body.encode(‘utf-8‘))
 
    sock.close()
 
if __name__ == ‘__main__‘:
    run()

b. 利用JavaScript類庫實現客戶端

<!DOCTYPE html>
<html>
<head lang="en">
    <meta charset="UTF-8">
    <title></title>
</head>
<body>
    <div>
        <input type="text" id="txt"/>
        <input type="button" id="btn" value="提交" onclick="sendMsg();"/>
        <input type="button" id="close" value="關閉連接" onclick="closeConn();"/>
    </div>
    <div id="content"></div>
 
<script type="text/javascript">
    var socket = new WebSocket("ws://127.0.0.1:8003/chatsocket");
 
    socket.onopen = function () {
        /* 與服務器端連接成功後,自動執行 */
 
        var newTag = document.createElement(‘div‘);
        newTag.innerHTML = "【連接成功】";
        document.getElementById(‘content‘).appendChild(newTag);
    };
 
    socket.onmessage = function (event) {
        /* 服務器端向客戶端發送數據時,自動執行 */
        var response = event.data;
        var newTag = document.createElement(‘div‘);
        newTag.innerHTML = response;
        document.getElementById(‘content‘).appendChild(newTag);
    };
 
    socket.onclose = function (event) {
        /* 服務器端主動斷開連接時,自動執行 */
        var newTag = document.createElement(‘div‘);
        newTag.innerHTML = "【關閉連接】";
        document.getElementById(‘content‘).appendChild(newTag);
    };
 
    function sendMsg() {
        var txt = document.getElementById(‘txt‘);
        socket.send(txt.value);
        txt.value = "";
    }
    function closeConn() {
        socket.close();
        var newTag = document.createElement(‘div‘);
        newTag.innerHTML = "【關閉連接】";
        document.getElementById(‘content‘).appendChild(newTag);
    }
 
</script>
</body>
</html>

六、基於Tornado框架實現Web聊天室

  關於推送消息:Tornado原生支持websocket,我們可以借助Tornado框架內部封裝的方法實現消息的推送,把接收到的消息發送給所有的客戶。

  Tornado是一個支持WebSocket的優秀框架,其內部原理正如1~5步驟描述,當然Tornado內部封裝功能更加完整。

以下是基於Tornado實現的聊天室示例:

技術分享
import uuid
import json
import tornado.ioloop
import tornado.web
import tornado.websocket


class IndexHandler(tornado.web.RequestHandler):
    def get(self):
        self.render(index.html)


class ChatHandler(tornado.websocket.WebSocketHandler):
    # 用戶存儲當前聊天室用戶
    waiters = set() # 每個人的socket對象# self
    # 用於存儲歷時消息
    messages = []

    def open(self):
        """
        客戶端連接成功時,自動執行
        :return:
        """
        ChatHandler.waiters.add(self)
        uid = str(uuid.uuid4())
        self.write_message(uid)

        for msg in ChatHandler.messages:
            content = self.render_string(message.html, **msg)
            self.write_message(content)

    def on_message(self, message):
        """
        客戶端連發送消息時,自動執行
        :param message:
        :return:
        """
        msg = json.loads(message)
        ChatHandler.messages.append(msg)

        for client in ChatHandler.waiters:
            content = client.render_string(message.html, **msg)
            client.write_message(content)

    def on_close(self):
        """
        客戶端關閉連接時,,自動執行
        :return:
        """
        ChatHandler.waiters.remove(self)


def run():
    settings = {
        template_path: templates,
        static_path: static,
    }
    application = tornado.web.Application([
        (r"/", IndexHandler),
        (r"/chat", ChatHandler),
    ], **settings)
    application.listen(8888,0.0.0.0)
    tornado.ioloop.IOLoop.instance().start()


if __name__ == "__main__":
    run()
app.py 技術分享
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Python聊天室</title>
</head>
<body>
    <div>
        <input type="text" id="txt"/>
        <input type="button" id="btn" value="提交" onclick="sendMsg();"/>
        <input type="button" id="close" value="關閉連接" onclick="closeConn();"/>
    </div>
    <div id="container" style="border: 1px solid #dddddd;margin: 20px;min-height: 500px;">

    </div>

    <script src="/static/jquery-1.12.4.js"></script>
    <script type="text/javascript">
        $(function () {
            wsUpdater.start();
        });

        var wsUpdater = {
            socket: null,
            uid: null,
            start: function() {
                var url = "ws://192.168.11.211:8888/chat";
                wsUpdater.socket = new WebSocket(url);
                wsUpdater.socket.onmessage = function(event) {
                    if(wsUpdater.uid){
                        wsUpdater.showMessage(event.data);
                    }else{
                        wsUpdater.uid = event.data;
                    }
                }
            },
            showMessage: function(content) {
                $(#container).append(content);
            }
        };

        function sendMsg() {
            var msg = {
                uid: wsUpdater.uid,
                message: $("#txt").val()
            };
            wsUpdater.socket.send(JSON.stringify(msg));
        }

</script>

</body>
</html>
index.html 技術分享
<div style="border: 1px solid #dddddd;margin: 10px;">
    <div>遊客{{uid}}</div>
    <div style="margin-left: 20px;">{{message}}</div>
</div>
message.html

web新特性 之 WebSocket