1. 程式人生 > >python之WebSocket協議

python之WebSocket協議

一、WebSocket理論部分

1、websocket是什麼

Websocket是html5提出的一個協議規範,參考rfc6455。

websocket約定了一個通訊的規範,通過一個握手的機制,客戶端(瀏覽器)和伺服器(webserver)之間能建立一個類似tcp的連線,從而方便c-s之間的通訊。在websocket出現之前,web互動一般是基於http協議的短連線或者長連線。

WebSocket是為解決客戶端與服務端實時通訊而產生的技術。websocket協議本質上是一個基於tcp的協議,是先通過HTTP/HTTPS協議發起一條特殊的http請求進行握手後建立一個用於交換資料的TCP連線,此後服務端與客戶端通過此TCP連線進行實時通訊。

注意:此時不再需要原HTTP協議的參與了。

2、websocket的優點

以前web server實現推送技術或者即時通訊,用的都是輪詢(polling),在特點的時間間隔(比如1秒鐘)由瀏覽器自動發出請求,將伺服器的訊息主動的拉回來,在這種情況下,我們需要不斷的向伺服器傳送請求,然而HTTP request 的header是非常長的,裡面包含的資料可能只是一個很小的值,這樣會佔用很多的頻寬和伺服器資源。

而最比較新的技術去做輪詢的效果是Comet – 用了AJAX。但這種技術雖然可達到全雙工通訊,但依然需要發出請求(reuqest)。

WebSocket API最偉大之處在於伺服器和客戶端可以在給定的時間範圍內的任意時刻,相互推送資訊。 瀏覽器和伺服器只需要要做一個握手的動作,在建立連線之後,伺服器可以主動傳送資料給客戶端,客戶端也可以隨時向伺服器傳送資料。 此外,伺服器與客戶端之間交換的標頭資訊很小。

WebSocket並不限於以Ajax(或XHR)方式通訊,因為Ajax技術需要客戶端發起請求,而WebSocket伺服器和客戶端可以彼此相互推送資訊;

因此從伺服器角度來說,websocket有以下好處:

    1. 節省每次請求的header
      http的header一般有幾十位元組

    2. Server Push
      伺服器可以主動傳送資料給客戶端

3、websocket的協議規範

3.1基於flash的握手協議

使用場景是IE的多數版本,因為IE的多數版本不都不支援WebSocket協議,以及FF、CHROME等瀏覽器的低版本,還沒有原生的支援WebSocket。此處,server唯一要做的,就是準備一個WebSocket-Location域給client,沒有加密,可靠性很差。

3.2基於md5加密方式的握手協議

其中 Sec-WebSocket-Key1,Sec-WebSocket-Key2 和 [8-byte security key] 這幾個頭資訊是web server用來生成應答資訊的來源,依據 draft-hixie-thewebsocketprotocol-76 草案的定義。
web server基於以下的演算法來產生正確的應答資訊:

1. 逐個字元讀取 Sec-WebSocket-Key1 頭資訊中的值,將數值型字元連線到一起放到一個臨時字串裡,同時統計所有空格的數量;
2. 將在第(1)步裡生成的數字字串轉換成一個整型數字,然後除以第(1)步裡統計出來的空格數量,將得到的浮點數轉換成整數型;
3. 將第(2)步裡生成的整型值轉換為符合網路傳輸的網路位元組陣列;
4. 對 Sec-WebSocket-Key2 頭資訊同樣進行第(1)到第(3)步的操作,得到另外一個網路位元組陣列;
5. 將 [8-byte security key] 和在第(3)、(4)步裡生成的網路位元組數組合併成一個16位元組的陣列;
6. 對第(5)步生成的位元組陣列使用MD5演算法生成一個雜湊值,這個雜湊值就作為安全金鑰返回給客戶端,以表明伺服器端獲取了客戶端的請求,同意建立websocket連線

3.3基於sha加密方式的握手協議

也是目前見的最多的一種方式,這裡的版本號目前是需要13以上的版本。

客戶端請求:

GET /ls HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Host: www.qixing318.com
Sec-WebSocket-Origin: http://www.qixing318.com
Sec-WebSocket-Key: 2SCVXUeP9cTjV+0mWB8J6A==
Sec-WebSocket-Version: 13

伺服器返回:

HTTP/1.1 101 Switching Protocols 
Upgrade: websocket Connection: 
Upgrade Sec-WebSocket-Accept: mLDKNeBNWz6T9SxU+o0Fy/HgeSw=

其中 server就是把客戶端上報的key拼上一段GUID( “258EAFA5-E914-47DA-95CA-C5AB0DC85B11″),拿這個字串做SHA-1 hash計算,然後再把得到的結果通過base64加密,最後再返回給客戶端。

-格式:\r\n

-建立連結之後預設不斷開

3.4、基於sha加密的Opening Handshake(握手環節)

客戶端發起連線Handshake請求

GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Origin: http://example.com
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13

伺服器端響應:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat
Upgrade:WebSocket
表示這是一個特殊的 HTTP 請求,請求的目的就是要將客戶端和伺服器端的通訊協議從 HTTP 協議升級到 WebSocket 協議。
Sec-WebSocket-Key
是一段瀏覽器base64加密的金鑰,server端收到後需要提取Sec-WebSocket-Key 資訊,然後加密。
Sec-WebSocket-Accept
伺服器端在接收到的Sec-WebSocket-Key金鑰後追加一段神奇字串“258EAFA5-E914-47DA-95CA-C5AB0DC85B11”,並將結果進行sha-1雜湊,然後再進行base64加密返回給客戶端(就是Sec-WebSocket-Key)。 比如:

function encry($req)
{
   $key = $this->getKey($req);
   $mask = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; 
   # 將 SHA-1 加密後的字串再進行一次 base64 加密
   return base64_encode(sha1($key . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', true));
}
如果加密演算法錯誤,客戶端在進行校檢的時候會直接報錯。如果握手成功,則客戶端側會出發onopen事件。
Sec-WebSocket-Protocol
表示客戶端請求提供的可供選擇的子協議,及伺服器端選中的支援的子協議,“Origin”伺服器端用於區分未授權的websocket瀏覽器
Sec-WebSocket-Version: 13
客戶端在握手時的請求中攜帶,這樣的版本標識,表示這個是一個升級版本,現在的瀏覽器都是使用的這個版本。
HTTP/1.1 101 Switching Protocols
101為伺服器返回的狀態碼,所有非101的狀態碼都表示handshake並未完成。

Data Framing

Websocket協議通過序列化的資料幀傳輸資料。資料封包協議中定義了opcode、payload length、Payload data等欄位。其中要求:

  1. 客戶端向伺服器傳輸的資料幀必須進行掩碼處理:伺服器若接收到未經過掩碼處理的資料幀,則必須主動關閉連線。

  2. 伺服器向客戶端傳輸的資料幀一定不能進行掩碼處理。客戶端若接收到經過掩碼處理的資料幀,則必須主動關閉連線。

針對上情況,發現錯誤的一方可向對方傳送close幀(狀態碼是1002,表示協議錯誤),以關閉連線。
具體資料幀格式如下圖所示:

FIN
標識是否為此訊息的最後一個數據包,佔 1 bit

RSV1, RSV2, RSV3: 用於擴充套件協議,一般為0,各佔1bit

Opcode
資料包型別(frame type),佔4bits
0x0:標識一箇中間資料包
0x1:標識一個text型別資料包
0x2:標識一個binary型別資料包
0x3-7:保留
0x8:標識一個斷開連線型別資料包
0x9:標識一個ping型別資料包
0xA:表示一個pong型別資料包
0xB-F:保留

MASK:佔1bits
用於標識PayloadData是否經過掩碼處理。如果是1,Masking-key域的資料即是掩碼金鑰,用於解碼PayloadData。客戶端發出的資料幀需要進行掩碼處理,所以此位是1。

Payload length
Payload data的長度,佔7bits,7+16bits,7+64bits:

如果其值在0-125,則是payload的真實長度。

如果值是126,則後面2個位元組形成的16bits無符號整型數的值是payload的真實長度。注意,網路位元組序,需要轉換。

如果值是127,則後面8個位元組形成的64bits無符號整型數的值是payload的真實長度。注意,網路位元組序,需要轉換。

這裡的長度表示遵循一個原則,用最少的位元組表示長度(儘量減少不必要的傳輸)。舉例說,payload真實長度是124,在0-125之間,必須用前7位表示;不允許長度1是126或127,然後長度2是124,這樣違反原則。

Payload data

應用層資料

server解析client端的資料

接收到客戶端資料後的解析規則如下:

1byte

1bit: frame-fin,x0表示該message後續還有frame;x1表示是message的最後一個frame

3bit: 分別是frame-rsv1、frame-rsv2和frame-rsv3,通常都是x0

4bit: frame-opcode,x0表示是延續frame;x1表示文字frame;x2表示二進位制frame;x3-7保留給非控制frame;x8表示關 閉連線;x9表示ping;xA表示pong;xB-F保留給控制frame

2byte

1bit: Mask,1表示該frame包含掩碼;0表示無掩碼

7bit、7bit+2byte、7bit+8byte: 7bit取整數值,若在0-125之間,則是負載資料長度;若是126表示,後兩個byte取無符號16位整數值,是負載長度;127表示後8個 byte,取64位無符號整數值,是負載長度

3-6byte: 這裡假定負載長度在0-125之間,並且Mask為1,則這4個byte是掩碼

7-end byte: 長度是上面取出的負載長度,包括擴充套件資料和應用資料兩部分,通常沒有擴充套件資料;若Mask為1,則此資料需要解碼,解碼規則為- 1-4byte掩碼迴圈和資料byte做異或操作。

示例程式碼:

while True:
   # 對資料進行解密
   # send_msg(conn, bytes('alex', encoding='utf-8'))
   # send_msg(conn, bytes('SB', encoding='utf-8'))
   # info = conn.recv(8096)
   # print(info)

   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)
   msg = str(bytes_list, encoding='utf-8')

   rep = msg + 'sb'
   send_msg(conn,bytes(rep,encoding='utf-8'))

5、原理程式碼:

後端

import socket
import hashlib
import base64


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

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()

# WebSocket發來的連線
# 1. 獲取握手資料
data = conn.recv(1024)
headers = get_headers(data)

# 2. 對握手資訊進行加密:
magic_string = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
value = headers['Sec-WebSocket-Key'] + magic_string
ac = base64.b64encode(hashlib.sha1(value.encode('utf-8')).digest())

# 3. 返回握手資訊
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://127.0.0.1:8002\r\n\r\n"

response_str = response_tpl % (ac.decode('utf-8'),)

conn.sendall(bytes(response_str, encoding='utf-8'))

# 之後,才能進行首發資料。

while True:
   # 對資料進行解密
   # send_msg(conn, bytes('alex', encoding='utf-8'))
   # send_msg(conn, bytes('SB', encoding='utf-8'))
   # info = conn.recv(8096)
   # print(info)

   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)
   msg = str(bytes_list, encoding='utf-8')

   rep = msg + 'sb'
   send_msg(conn,bytes(rep,encoding='utf-8'))

二、應用:

1、Flask中應用: pip3 install gevent-websocket

View Code

from flask import Flask,request,render_template,session,redirect
import uuid
import json
from geventwebsocket.handler import WebSocketHandler
from gevent.pywsgi import WSGIServer


app = Flask(__name__)
app.secret_key = 'asdfasdf'

GENTIEMAN = {
   '1':{'name':'鋼彈','count':0},
   '2':{'name':'鐵錘','count':0},
   '3':{'name':'閆帥','count':0},
}

WEBSOCKET_DICT = {

}

@app.before_request
def before_request():
   if request.path == '/login':
       return None
   user_info = session.get('user_info')
   if user_info:
       return None
   return redirect('/login')

@app.route('/login',methods=['GET','POST'])
def login():
   if request.method == "GET":
       return render_template('login.html')
   else:
       uid = str(uuid.uuid4())
       session['user_info'] = {'id':uid,'name':request.form.get('user')}
       return redirect('/index')

@app.route('/index')
def index():
   return render_template('index.html',users=GENTIEMAN)

@app.route('/message')
def message():
   # 1. 判斷到底是否是websocket請求?
   ws = request.environ.get('wsgi.websocket')
   if not ws:
       return "請使用WebSocket協議"
   # ----- ws連線成功 -------
   current_user_id = session['user_info']['id']
   WEBSOCKET_DICT[current_user_id] = ws
   while True:
       # 2. 等待使用者傳送訊息,並接受
       message = ws.receive() # 帥哥ID
       # 關閉:message=None
       if not message:
           del WEBSOCKET_DICT[current_user_id]
           break

       # 3. 獲取使用者要投票的帥哥ID,並+1
       old = GENTIEMAN[message]['count']
       new = old + 1
       GENTIEMAN[message]['count'] = new

       data = {'user_id': message, 'count': new,'type':'vote'}
       # 4. 給所有客戶端推送訊息
       for conn in WEBSOCKET_DICT.values():
           conn.send(json.dumps(data))
   return 'close'

@app.route('/notify')
def notify():
   data = {'data': "你的訂單已經生成,請及時處理;", 'type': 'alert'}
   print(WEBSOCKET_DICT)
   for conn in WEBSOCKET_DICT.values():
       conn.send(json.dumps(data))
   return '傳送成功'

if __name__ == '__main__':
   http_server = WSGIServer(('192.168.11.143', 5000), app, handler_class=WebSocketHandler)
   http_server.serve_forever()

login.html

<!DOCTYPE html>
<html lang="en">
<head>
   <meta charset="UTF-8">
   <title>Title</title>
</head>
<body>
<form method="post">
   <input type="text" name="user">
   <input type="submit" value="提交">
</form>
</body>
</html>

login.html

index.html

<!DOCTYPE html>
<html lang="en">
<head>
   <meta charset="UTF-8">
   <title>Title</title>
</head>
<body>
   <h1>投票系統:參與投票的人</h1>
   <ul>
       {% for k,v in users.items() %}
           <li id="user_{{k}}" ondblclick="vote('{{k}}')">{{v.name}} <span>{{v.count}}</span> </li>
       {% endfor %}

   </ul>
   <script src="{{ url_for('static',filename='jquery-3.3.1.min.js')}}"></script>
   <script>
       var socket = new WebSocket("ws://192.168.11.143:5000/message");

       socket.onmessage = function (event) {
           /* 伺服器端向客戶端傳送資料時,自動執行 */
           var response = JSON.parse(event.data); // {'user':1,'count':new}
           if(response.type == 'vote'){
               var nid = '#user_' + response.user_id;
               $(nid).find('span').text(response.count)
           }else{
               alert(response.data);
           }
       };

       /*
       我要給某人投票
        */
        function vote(id) {
           socket.send(id);
       }

   </script>
</body>
</html>

2、Django應用:channel

3、Tornado應用:自己有

識別圖中二維碼,領取python全套視訊資料