2018-10-09 WebSocket通信過程與實現(xiàn)

來源:WebSocket通信過程與實現(xiàn)

什么是 WebSocket ?

WebSocket 是一種標準協(xié)議拥刻,用于在客戶端和服務(wù)端之間進行雙向數(shù)據(jù)傳輸怜瞒。但它跟 HTTP 沒什么關(guān)系,它是基于 TCP 的一種獨立實現(xiàn)般哼。

以前客戶端想知道服務(wù)端的處理進度吴汪,要不停地使用 Ajax 進行輪詢,讓瀏覽器隔個幾秒就向服務(wù)器發(fā)一次請求蒸眠,這對服務(wù)器壓力較大漾橙。另外一種輪詢就是采用 long poll 的方式,這就跟打電話差不多楞卡,沒收到消息就一直不掛電話霜运,也就是說脾歇,客戶端發(fā)起連接后,如果沒消息淘捡,就一直不返回 Response 給客戶端藕各,連接階段一直是阻塞的。

而 WebSocket 解決了 HTTP 的這幾個難題焦除。當服務(wù)器完成協(xié)議升級后( HTTP -> WebSocket )激况,服務(wù)端可以主動推送信息給客戶端,解決了輪詢造成的同步延遲問題膘魄。由于 WebSocket 只需要一次 HTTP 握手乌逐,服務(wù)端就能一直與客戶端保持通信,直到關(guān)閉連接创葡,這樣就解決了服務(wù)器需要反復(fù)解析 HTTP 協(xié)議黔帕,減少了資源的開銷。

image.png

隨著新標準的推進蹈丸,WebSocket 已經(jīng)比較成熟了成黄,并且各個主流瀏覽器對 WebSocket 的支持情況比較好(不兼容低版本 IE,IE 10 以下)逻杖,有空可以看看奋岁。

image.png

使用 WebSocket 的時候,前端使用是比較規(guī)范的荸百,js 支持 ws 協(xié)議闻伶,感覺類似于一個輕度封裝的 Socket 協(xié)議,只是以前需要自己維護 Socket 的連接够话,現(xiàn)在能夠以比較標準的方法來進行蓝翰。

image.png

下面我們就結(jié)合上圖具體來聊一下 WebSocket 的通信過程。

建立連接

客戶端請求報文 Header

客戶端請求報文:

GET / HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Host: example.com
Origin: http://example.com
Sec-WebSocket-Key: sN9cRrP/n9NdMgdcy2VJFQ==
Sec-WebSocket-Version: 13

與傳統(tǒng) HTTP 報文不同的地方:

Upgrade: websocket
Connection: Upgrade

這兩行表示發(fā)起的是 WebSocket 協(xié)議女嘲。

Sec-WebSocket-Key: sN9cRrP/n9NdMgdcy2VJFQ==
Sec-WebSocket-Version: 13

Sec-WebSocket-Key 是由瀏覽器隨機生成的畜份,提供基本的防護温眉,防止惡意或者無意的連接幸冻。

Sec-WebSocket-Version 表示 WebSocket 的版本烦粒,最初 WebSocket 協(xié)議太多棕孙,不同廠商都有自己的協(xié)議版本,不過現(xiàn)在已經(jīng)定下來了所宰。如果服務(wù)端不支持該版本述呐,需要返回一個 Sec-WebSocket-Versionheader吗坚,里面包含服務(wù)端支持的版本號菇晃。

創(chuàng)建 WebSocket 對象:

var ws = new websocket("ws://127.0.0.1:8001");

ws 表示使用 WebSocket 協(xié)議册倒,后面接地址及端口

完整的客戶端代碼:

<script type="text/javascript">
    var ws;
    var box = document.getElementById('box');

    function startWS() {
        ws = new WebSocket('ws://127.0.0.1:8001');
        ws.onopen = function (msg) {
            console.log('WebSocket opened!');
        };
        ws.onmessage = function (message) {
            console.log('receive message: ' + message.data);
            box.insertAdjacentHTML('beforeend', '<p>' + message.data + '</p>');
        };
        ws.onerror = function (error) {
            console.log('Error: ' + error.name + error.number);
        };
        ws.onclose = function () {
            console.log('WebSocket closed!');
        };
    }

    function sendMessage() {
        console.log('Sending a message...');
        var text = document.getElementById('text');
        ws.send(text.value);
    }

    window.onbeforeunload = function () {
        ws.onclose = function () {};  // 首先關(guān)閉 WebSocket
        ws.close()
    };
</script>

服務(wù)端響應(yīng)報文 Header

首先我們來看看服務(wù)端的響應(yīng)報文:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
Sec-WebSocket-Protocol: chat

我們一行行來解釋

  1. 首先,101 狀態(tài)碼表示服務(wù)器已經(jīng)理解了客戶端的請求磺送,并將通過 Upgrade 消息頭通知客戶端采用不同的協(xié)議來完成這個請求驻子;
  2. 然后屈尼,Sec-WebSocket-Accept 這個則是經(jīng)過服務(wù)器確認,并且加密過后的 Sec-WebSocket-Key拴孤;
  3. 最后,Sec-WebSocket-Protocol 則是表示最終使用的協(xié)議甲捏。

Sec-WebSocket-Accept 的計算方法:

  1. Sec-WebSocket-Key 跟 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 拼接演熟;
  2. 通過 SHA1 計算出摘要,并轉(zhuǎn)成 base64 字符串司顿。

注意:Sec-WebSocket-Key/Sec-WebSocket-Accept 的換算芒粹,只能帶來基本的保障,但連接是否安全大溜、數(shù)據(jù)是否安全化漆、客戶端 / 服務(wù)端是否合法的 ws 客戶端、ws 服務(wù)端钦奋,其實并沒有實際性的保證座云。

創(chuàng)建主線程,用于實現(xiàn)接受 WebSocket 建立請求:

def create_socket():
    # 啟動 Socket 并監(jiān)聽連接
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    try:
        sock.bind(('127.0.0.1', 8001))

        # 操作系統(tǒng)會在服務(wù)器 Socket 被關(guān)閉或服務(wù)器進程終止后馬上釋放該服務(wù)器的端口付材,否則操作系統(tǒng)會保留幾分鐘該端口朦拖。
        sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        sock.listen(5)
    except Exception as e:
        logging.error(e)
        return
    else:
        logging.info('Server running...')

    # 等待訪問
    while True:
        conn, addr = sock.accept()  # 此時會進入 waiting 狀態(tài)

        data = str(conn.recv(1024))
        logging.debug(data)

        header_dict = {}
        header, _ = data.split(r'\r\n\r\n', 1)
        for line in header.split(r'\r\n')[1:]:
            key, val = line.split(': ', 1)
            header_dict[key] = val

        if 'Sec-WebSocket-Key' not in header_dict:
            logging.error('This socket is not websocket, client close.')
            conn.close()
            return

        magic_key = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
        sec_key = header_dict['Sec-WebSocket-Key'] + magic_key
        key = base64.b64encode(hashlib.sha1(bytes(sec_key, encoding='utf-8')).digest())
        key_str = str(key)[2:30]
        logging.debug(key_str)

        response = 'HTTP/1.1 101 Switching Protocols\r\n' \
                   'Connection: Upgrade\r\n' \
                   'Upgrade: websocket\r\n' \
                   'Sec-WebSocket-Accept: {0}\r\n' \
                   'WebSocket-Protocol: chat\r\n\r\n'.format(key_str)
        conn.send(bytes(response, encoding='utf-8'))

        logging.debug('Send the handshake data')

        WebSocketThread(conn).start()

進行通信

服務(wù)端解析 WebSocket 報文

Server 端接收到 Client 發(fā)來的報文需要進行解析

Client 包格式

image.png
  1. FIN: 占 1bit

    0:不是消息的最后一個分片
    1:是消息的最后一個分片

  2. RSV1, RSV2, RSV3:各占 1bit

    一般情況下全為 0。當客戶端厌衔、服務(wù)端協(xié)商采用 WebSocket 擴展時璧帝,這三個標志位可以非
    0,且值的含義由擴展進行定義富寿。如果出現(xiàn)非零的值睬隶,且并沒有采用 WebSocket 擴展,連接出錯页徐。

  3. Opcode: 4bit

    %x0:表示一個延續(xù)幀苏潜。當 Opcode 為 0 時,表示本次數(shù)據(jù)傳輸采用了數(shù)據(jù)分片变勇,當前收到的數(shù)據(jù)幀為其中一個數(shù)據(jù)分片窖贤;
    %x1:表示這是一個文本幀(text frame);
    %x2:表示這是一個二進制幀(binary frame)贰锁;
    %x3-7:保留的操作代碼赃梧,用于后續(xù)定義的非控制幀;
    %x8:表示連接斷開豌熄;
    %x9:表示這是一個心跳請求(ping)授嘀;
    %xA:表示這是一個心跳響應(yīng)(pong);
    %xB-F:保留的操作代碼锣险,用于后續(xù)定義的控制幀蹄皱。

  4. Mask: 1bit

    表示是否要對數(shù)據(jù)載荷進行掩碼異或操作览闰。
    0:否
    1:是

  5. Payload length: 7bit or (7 + 16)bit or (7 + 64)bit

    表示數(shù)據(jù)載荷的長度
    0~126:數(shù)據(jù)的長度等于該值;
    126:后續(xù) 2 個字節(jié)代表一個 16 位的無符號整數(shù)巷折,該無符號整數(shù)的值為數(shù)據(jù)的長度压鉴;
    127:后續(xù) 8 個字節(jié)代表一個 64 位的無符號整數(shù)(最高位為 0),該無符號整數(shù)的值為數(shù)據(jù)的長度锻拘。

  6. Masking-key: 0 or 4bytes

    當 Mask 為 1油吭,則攜帶了 4 字節(jié)的 Masking-key;
    當 Mask 為 0署拟,則沒有 Masking-key婉宰。
    掩碼算法:按位做循環(huán)異或運算,先對該位的索引取模來獲得 Masking-key 中對應(yīng)的值 x推穷,然后對該位與 x 做異或心包,從而得到真實的 byte 數(shù)據(jù)。
    注意:掩碼的作用并不是為了防止數(shù)據(jù)泄密馒铃,而是為了防止早期版本的協(xié)議中存在的代理緩存污染攻擊(proxy cache poisoning attacks)等問題蟹腾。

  7. Payload Data: 載荷數(shù)據(jù)

解析 WebSocket 報文代碼如下:

def read_msg(data):
    logging.debug(data)

    msg_len = data[1] & 127  # 數(shù)據(jù)載荷的長度
    if msg_len == 126:
        mask = data[4:8]  # Mask 掩碼
        content = data[8:]  # 消息內(nèi)容
    elif msg_len == 127:
        mask = data[10:14]
        content = data[14:]
    else:
        mask = data[2:6]
        content = data[6:]

    raw_str = ''  # 解碼后的內(nèi)容
    for i, d in enumerate(content):
        raw_str += chr(d ^ mask[i % 4])
    return raw_str

服務(wù)端發(fā)送 WebSocket 報文

返回時不攜帶掩碼,所以 Mask 位為 0区宇,再按載荷數(shù)據(jù)的大小寫入長度岭佳,最后寫入載荷數(shù)據(jù)。

struct 模塊解析

struct.pack(fmt, v1, v2, ...)

按照給定的格式 fmt萧锉,把數(shù)據(jù)封裝成字符串 ( 實際上是類似于 C 結(jié)構(gòu)體的字節(jié)流 )

struct 中支持的格式如下表:

Format C Type Python type Standard size
x pad byte no value
c char bytes of length 1 1
b signed char integer 1
B unsigned char integer 1
? _Bool bool 1
h short integer 2
H unsigned short integer 2
i int integer 4
I unsigned int integer 4
l long integer 4
L unsigned long integer 4
q long long integer 8
Q unsigned long long integer 8
n ssize_t integer
N size_t integer
e -7 float 2
f float float 4
d double float 8
s char[] bytes
p char[] bytes
P void * integer

為了同 C 語言中的結(jié)構(gòu)體交換數(shù)據(jù)珊随,還要考慮有的 C 或 C++ 編譯器使用了字節(jié)對齊,通常是以 4 個字節(jié)為單位的 32 位系統(tǒng)柿隙,故而 struct 根據(jù)本地機器字節(jié)順序轉(zhuǎn)換叶洞。可以用格式中的第一個字符來改變對齊方式禀崖,定義如下:

Character Byte order Size Alignment
@ native native native
= native standard none
< little-endian standard none
> big-endian standard none
! network (= big-endian) standard none

發(fā)送 WebSocket 報文代碼如下:

def write_msg(message):
    data = struct.pack('B', 129)  # 寫入第一個字節(jié)衩辟,10000001

    # 寫入包長度
    msg_len = len(message)
    if msg_len <= 125:
        data += struct.pack('B', msg_len)
    elif msg_len <= (2 ** 16 - 1):
        data += struct.pack('!BH', 126, msg_len)
    elif msg_len <= (2 ** 64 - 1):
        data += struct.pack('!BQ', 127, msg_len)
    else:
        logging.error('Message is too long!')
        return

    data += bytes(message, encoding='utf-8')  # 寫入消息內(nèi)容
    logging.debug(data)
    return data

總結(jié)

沒有其他能像 WebSocket 一樣實現(xiàn)全雙工傳輸?shù)募夹g(shù)了,迄今為止波附,大部分開發(fā)者還是使用 Ajax 輪詢來實現(xiàn)艺晴,但這是個不太優(yōu)雅的解決辦法,WebSocket 雖然用的人不多掸屡,可能是因為協(xié)議剛出來的時候有安全性的問題以及兼容的瀏覽器比較少封寞,但現(xiàn)在都有解決。如果你有這些需求可以考慮使用 WebSocket:

  1. 多個用戶之間進行交互仅财;
  2. 需要頻繁地向服務(wù)端請求更新數(shù)據(jù)狈究。

比如彈幕、消息訂閱盏求、多玩家游戲抖锥、協(xié)同編輯亿眠、股票基金實時報價、視頻會議磅废、在線教育等需要高實時的場景纳像。

參考文章

https://www.zhihu.com/questio...

http://fullstackpython.atjian...

http://www.52im.net/thread-13...

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市拯勉,隨后出現(xiàn)的幾起案子竟趾,更是在濱河造成了極大的恐慌,老刑警劉巖谜喊,帶你破解...
    沈念sama閱讀 206,602評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異倦始,居然都是意外死亡斗遏,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,442評論 2 382
  • 文/潘曉璐 我一進店門鞋邑,熙熙樓的掌柜王于貴愁眉苦臉地迎上來诵次,“玉大人,你說我怎么就攤上這事枚碗∮庖唬” “怎么了?”我有些...
    開封第一講書人閱讀 152,878評論 0 344
  • 文/不壞的土叔 我叫張陵肮雨,是天一觀的道長遵堵。 經(jīng)常有香客問我,道長怨规,這世上最難降的妖魔是什么陌宿? 我笑而不...
    開封第一講書人閱讀 55,306評論 1 279
  • 正文 為了忘掉前任,我火速辦了婚禮波丰,結(jié)果婚禮上壳坪,老公的妹妹穿的比我還像新娘。我一直安慰自己掰烟,他們只是感情好爽蝴,可當我...
    茶點故事閱讀 64,330評論 5 373
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著纫骑,像睡著了一般蝎亚。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上先馆,一...
    開封第一講書人閱讀 49,071評論 1 285
  • 那天颖对,我揣著相機與錄音,去河邊找鬼磨隘。 笑死缤底,一個胖子當著我的面吹牛顾患,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播个唧,決...
    沈念sama閱讀 38,382評論 3 400
  • 文/蒼蘭香墨 我猛地睜開眼江解,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了徙歼?” 一聲冷哼從身側(cè)響起犁河,我...
    開封第一講書人閱讀 37,006評論 0 259
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎魄梯,沒想到半個月后桨螺,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 43,512評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡酿秸,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 35,965評論 2 325
  • 正文 我和宋清朗相戀三年灭翔,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片辣苏。...
    茶點故事閱讀 38,094評論 1 333
  • 序言:一個原本活蹦亂跳的男人離奇死亡肝箱,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出稀蟋,到底是詐尸還是另有隱情煌张,我是刑警寧澤,帶...
    沈念sama閱讀 33,732評論 4 323
  • 正文 年R本政府宣布退客,位于F島的核電站骏融,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏萌狂。R本人自食惡果不足惜绎谦,卻給世界環(huán)境...
    茶點故事閱讀 39,283評論 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望粥脚。 院中可真熱鬧窃肠,春花似錦、人聲如沸刷允。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,286評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽树灶。三九已至纤怒,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間天通,已是汗流浹背泊窘。 一陣腳步聲響...
    開封第一講書人閱讀 31,512評論 1 262
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人烘豹。 一個月前我還...
    沈念sama閱讀 45,536評論 2 354
  • 正文 我出身青樓瓜贾,卻偏偏與公主長得像,于是被迫代替她去往敵國和親携悯。 傳聞我的和親對象是個殘疾皇子祭芦,可洞房花燭夜當晚...
    茶點故事閱讀 42,828評論 2 345

推薦閱讀更多精彩內(nèi)容