WebSocket剖析
WebSocket剖析
http協議
在了解WebSocket之前,有必要簡單復習一下http協議。
請求和響應
Http協議用于客戶端與服務端的通信,客戶端發出請求(request),服務端返回響應(response)。下面我們以訪問https://www.sogou.com/搜狗首頁為例,來看看請求報文和響應報文:
下面是從客戶端訪問服務器的請求報文的截取內容:
第一行的GET表示請求方法;隨后的 / 表示請求訪問的資源對象(request-URI),這里是根頁面;最后的HTTP/1.1是協議和版本號。
第二行開始是首部字段:Host字段表示服務器域名。(這里只截取了部分首部字段,實際的字段更多)
http請求報文由請求方法、URI、HTTP版本、HTTP首部字段構成。
下面是服務器返回的響應報文的截取內容:
HTTP/1.1 200 OK Server: nginx Date: Tue, 19 Sep 2017 08:37:38 GMT Content-Type: text/html; charset=UTF-8 Transfer-Encoding: chunked Connection: keep-alive<html> ......第一行的HTTP/1.1表示服務器對應的HTTP版本; 200 OK表示請求處理結果的狀態碼和原因短語。
第二行開始是首部字段,包括服務器安裝的軟件版本,響應日期等。
響應報文包括響應頭和響應體。
響應頭由HTTP版本、狀態碼、原因短語、首部字段組成。
最下面的<html>開始是響應體,也就是用戶在瀏覽器上看到的具體網頁,由兩組\r\n換行符與上面的響應頭分隔。
http是被動的協議
使用http協議,通信只能由客戶端發起,服務端返回響應永遠是被動的,不能由服務端主動發起。
http是非持久的協議
使用http協議通信時,需要不斷地建立,關閉http連接。每當有新請求到達時,就會有對應的新的響應產生。一次請求,一次響應,結束,這就是http的生命周期。http1.1中多了一個keep-alive,在一次http連接中,可以發送多個請求,接收多個響應。但是一個請求只能有一個響應。
輪詢和長輪詢
如果希望實現持久連接的效果,比如在聊天室應用中,就要借助輪詢(poll)或者長輪詢(long poll)。簡單來說,輪詢就是客戶端每隔幾秒,就向服務端發送一次請求,詢問是否有新消息。而長輪詢則是阻塞模式,客戶端發起請求后,一段時間內(web微信是25秒的樣子,可以打開瀏覽器的開發者工具,查看Network,有一個pending狀態,定期會刷新一次),服務端只要沒有新消息,就不返回響應。一旦新消息到達或者超時,就返回響應給客戶端,一次連接結束,客戶端重新發起請求,周而復始。web QQ 和 web 微信,都是用長輪詢做的。
輪詢和長輪詢效率低,消耗資源:
- 輪詢要求不停地連接,即瀏覽器隔幾秒就要向服務器發出請求,然而HTTP請求可能包含較長的頭部,其中真正有效的數據可能只是很小的一部分,顯然這樣會浪費很多的帶寬資源。同理,服務器隔幾秒就要返回響應,消息可能存在延時,不僅浪費帶寬,還要求服務器有很快的處理速度。
- 長輪詢要求http連接始終打開,也會對服務器造成很大壓力,要求服務器處理大并發的能力。
http是無狀態協議
http協議本身并不保留之前的一切請求或響應報文的信息。這是為了更快地處理大量事物,確保協議的可伸縮性,也能減輕服務器的壓力。而且由于不需要保存狀態,http協議本身比較簡單,能被應用在各種場景里。如果需要管理狀態,需要借助Cookie和Session,在每次連接中,告訴服務端你是誰。
WebSocket
是什么
WebSocket協議是在HTML5中定義的,目前主流瀏覽器都支持這一標準。它能更好的節省服務器資源和帶寬,并且能夠更實時地進行通訊。
WebSocket是一種在單個TCP連接上進行全雙工通訊的協議。使得客戶端和服務器之間的數據交換變得更加簡單,允許服務端主動向客戶端推送數據。在WebSocket API中,瀏覽器和服務器只需要完成一次握手,兩者之間就直接可以創建持久性的連接,并進行雙向數據傳輸。
主要特點
較少的控制開銷。在連接創建后,服務器和客戶端之間交換數據時,用于協議控制的數據包頭部相對較小。在不包含擴展的情況下,對于服務器到客戶端的內容,此頭部大小只有2至10字節(和數據包長度有關);對于客戶端到服務器的內容,此頭部還需要加上額外的4字節的掩碼。相對于HTTP請求每次都要攜帶完整的頭部,此項開銷顯著減少了。
更強的實時性。由于協議是全雙工的,所以服務器可以隨時主動給客戶端下發數據。相對于HTTP請求需要等待客戶端發起請求服務端才能響應,延遲明顯更少;即使是和Comet等類似的長輪詢比較,其也能在短時間內更多次地傳遞數據。
保持連接狀態。與HTTP不同的是,Websocket需要先創建連接,這就使得其成為一種有狀態的協議,之后通信時可以省略部分狀態信息。而HTTP請求可能需要在每個請求都攜帶狀態信息(如身份認證等)。
更好的二進制支持。Websocket定義了二進制幀,相對HTTP,可以更輕松地處理二進制內容。
沒有同源限制,客戶端可以與任意服務器通信。
Websocket使用ws或wss的統一資源標志符,比如:ws://example.com/path。類似于HTTPS,其中wss表示在TLS之上的Websocket。Websocket使用和 HTTP 相同的 TCP 端口,可以繞過大多數防火墻的限制。默認情況下,Websocket協議使用80端口;運行在TLS之上時,默認使用443端口。
握手過程
為了創建Websocket連接,需要通過瀏覽器發出請求,之后服務器進行回應,這個過程通常稱為“握手”(handshaking)。Websocket 通過 HTTP/1.1 協議的101狀態碼進行握手。過程如下:
客戶端請求
GET / HTTP/1.1 Upgrade: websocket Connection: Upgrade Host: example.com Origin: http://example.com Sec-WebSocket-Key: sN9cRrP/n9NdMgdcy2VJFQ== Sec-WebSocket-Version: 13服務端回應
HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: fFBooB7FAkLlXgRSz0BT3v4hq5s= Sec-WebSocket-Location: ws://example.com/說明
- Connection必須設置Upgrade,表示客戶端希望連接升級
- Upgrade字段必須設置Websocket,表示希望升級到Websocket協議
- Sec-WebSocket-Key是隨機的字符串,作驗證用的,為了避免和HTTP請求混淆:
- 服務端提取Sec-WebSocket-Key
- 將一個特殊字符串(magic_string)和Sec-WebSocket-Key先進行SHA-1摘要計算,之后進行BASE-64編碼
- 編碼結果作為響應頭Sec-WebSocket-Accept字段的值,返回給客戶端
- 客戶端將這個值和本地計算的值對比,如果一致,則進行Websocket通信
- Sec-WebSocket-Version 表示支持的Websocket版本。RFC6455要求使用的版本是13,之前草案的版本均應當棄用
- 其他一些定義在HTTP協議中的字段,比如cookie,也可以在Websocket中使用。
客戶端和服務端通過Websocket通信示例
客戶端和服務端傳輸數據時,需要對數據進行【封包】和【解包】。客戶端的JavaScript類庫已經封裝【封包】和【解包】過程,直接調用API即可。這里用python的Socket來手動實現服務端。
客戶端示例代碼
<!DOCTYPE html> <html lang="en"> <head><meta charset="UTF-8"><title>Title</title> </head> <body> <div><input type="text" id="txt"><input type="button" id="submit" value="提交" onclick="sendMsg()"><input type="button" id="close" value="關閉連接" onclick="closeConn()"> </div> <div id="info"></div><script>var ws = new WebSocket("ws://127.0.0.1:8000");/* WebSocket 對象的回調函數:* onopen 連接成功后自動執行* onmessage 服務端向客戶端發送數據時,自動執行* onclese 服務端斷開連接時,自動執行* */ws.onopen = function () {var ele = document.createElement('div');ele.innerText = '【服務端 連接成功】';document.getElementById('info').appendChild(ele);};ws.onmessage = function (event) {var response = event.data;var ele = document.createElement('div');ele.innerText = response;document.getElementById('info').appendChild(ele);};ws.onclose = function (event) {var ele = document.createElement('div');ele.innerText = '【websocket 連接關閉】';document.getElementById('info').appendChild(ele);};function sendMsg() {var txt = document.getElementById('txt');ws.send(txt.value); //發送數據txt.value = '';}function closeConn() {ws.close(); //關閉websocketvar ele = document.createElement('div');ele.innerText = '【客戶端 連接關閉】';document.getElementById('info').appendChild(ele);} </script></body> </html>API說明
實例化WebSocket對象
var ws = new WebSocket('ws://127.0.0.1:8080');readyState
readyState屬性返回實例對象的當前狀態,共有四種:
- CONNECTING:值為0,表示正在連接。
- OPEN:值為1,表示連接成功,可以通信了。
- CLOSING:值為2,表示連接正在關閉。
- CLOSED:值為3,表示連接已經關閉,或者打開連接失敗。
onopen
用于指定連接成功后的回調函數
ws.onopen = function() {consoel.log('連接成功');// do something }onclose
服務端斷開連接時,要執行的回調函數
ws.onclose = function(event) {var code = event.code;var reason = event.reason;var wasClean = event.wasClean;// handle close event };onmessage
該屬性用于指定收到服務器消息后的回調函數
ws.onmessage = function(event) {var data = event.data;// 處理數據 };send()
用于webSocket對象向服務器發送數據
ws.send('hello world');onerror
指定出錯時的回到函數
ws.onerror = function(event) {console.log('something wrong'); };其它
如果要為webSocket對象的某個事件指定多個回調函數,可以使用addEventListener方法來擴展:
function foo() {console.log('do something'); }ws.addEventListener('open', foo);擴展閱讀:
https://developer.mozilla.org/zh-CN/docs/Web/API/EventTarget/addEventListener
服務端
由于服務端是用python的Socket手動實現的,因此,握手,消息解包,消息封包,都需要手動完成。
客戶端發送過來的是二進制數據,握手階段必然要提取請求頭信息,并進行websocket通信的驗證。
提取請求頭
def get_headers(data):"""將請求頭轉化為字典"""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['uri'], header_dict['protocol'] = header_list[i].split(' ')else: # 首部字段k, v = header_list[i].split(':', 1)header_dict[k] = v.strip()return header_dictwebsocket通信的驗證
def handshaking_response(data):"""響應客戶端websocket握手:1.提取請求頭 2.計算Sec-WebSocket-Key 3.返回攜帶Sec-WebSocket-Accept的響應:param data: 客戶端握手請求數據:return: """headers = get_headers(data) # 提取請求頭# 從請求頭提取Sec-WebSocket-Key# 將magic_string和Sec-WebSocket-Key先進行SHA-1摘要計算,# 之后進行BASE - 64編碼,編碼結果作為響應頭Sec-WebSocket-Accept字段的值,返回給客戶端magic_string = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11' # 協議規定的魔法字符串value = headers['Sec-WebSocket-Key'] + magic_stringres = base64.b64encode(hashlib.sha1(value.encode('utf-8')).digest())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"# 響應response = response_tpl % (res.decode('utf-8'), headers['Host'], headers['uri'])return response消息解包
def get_msg(data):"""服務端手動解包客戶端發來的數據:param data: 客戶端發來的原始bytes數據:return: msg 解包后的請求體數據"""payload_len = data[1] & 127if payload_len == 126:extend_payload_len = msg[2:4]mask = data[4:8]decoded = data[8:] # decoded 是請求體數據elif payload_len == 127:extend_payload_len = data[2:10]mask = data[10:14]decoded = data[14:]else:extend_payload_len = Nonemask = data[2:6]decoded = data[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')return msg消息封包
def send_msg(conn, msg_bytes):"""服務端向客戶端發送消息:param conn: 客戶端連接到服務器的socket對象:param msg_bytes: 向客戶端發送的字節:return: """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_bytesconn.send(msg)return True主程序
主程序中將調用以上函數,進行websocket通信
def main():# 創建TCP套接字tcpsock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)tcpsock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)tcpsock.bind(('127.0.0.1', 8000))tcpsock.listen(5)while True: # 連接循環print('waitting for connection...')# 收到握手請求conn, addr = tcpsock.accept()data = conn.recv(1024)print('connected from', addr)# 返回握手響應response = handshaking_response(data)conn.send(bytes(response, encoding='utf-8'))# 通訊循環while True:try:data = conn.recv(8096)if not data:breakmsg = get_msg(data) # 解包收到的數據print('收到信息:',msg)send_msg(conn, ('服務端響應:'+ msg).encode('utf-8')) # 封包,發送數據except Exception as e:print('客戶端異常斷開')conn.close()tcpsock.close()if __name__ == '__main__':main()在tornado中使用WebSocket
WebSocket作為一種較新的標準,并不被所有的web框架所支持,比如大名鼎鼎的Django,是不支持的。不過tornado框架原生支持tornado,并且簡單易用,基本使用流程如下:
- 視圖繼承tornado.websocket.WebSocketHandler類
- 定義回調函數open, 客戶端連接成功時,自動執行
- 定義回調函數on_message, 收到客戶端消息時,自動執行
- 定義回調函數 on_close, 客戶端斷開連接時,自動執行
實現基于WebSocket的實時聊天室
下面我們來實現一個簡單的web聊天室,基本邏輯如下:
- 客戶端通過http協議訪問:http://127.0.0.1:8000/
- 服務端返回index.html頁面
- index.html頁面加載完成后,會通過JS創建WebSocket對象,訪問ws://127.0.0.1:8000/chat
- 服務端執行繼承了WebSocketHandler的視圖中的回調函數,開始進行websocket通信。
服務端代碼:
#! user/bin/env python # -*- coding: utf-8 -*- import uuid import json import tornado.web import tornado.ioloop import tornado.websocketclass IndexHandler(tornado.web.RequestHandler):"""處理客戶端的http請求"""def get(self):self.render('index.html')class ChatHandler(tornado.websocket.WebSocketHandler):"""處理websocket請求"""waiters = set() # 存儲當前聊天室用戶messages = [] # 存儲歷史消息def open(self):print('連接建立')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):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):# 客戶端斷開連接后,移除該對象ChatHandler.waiters.remove(self)def main():settings = {'template_path': 'templates',}application = tornado.web.Application([(r'/', IndexHandler),(r'/chat', ChatHandler),], **settings)application.listen(8000)tornado.ioloop.IOLoop.instance().start()if __name__ == '__main__':main()message.html模板:
<div style="border: 1px solid #dddddd;margin: 10px;"><div>游客{{uid}}</div><div style="margin-left: 20px;">{{message}}</div> </div>index.html模板:
<!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>window.onload = function() {wsUpdater.start();};var wsUpdater = {socket: null,uid: null,start: function() {var url = "ws://127.0.0.1:8000/chat";wsUpdater.socket = new WebSocket(url);wsUpdater.socket.onmessage = function(event) {console.log(event);if(wsUpdater.uid){wsUpdater.showMessage(event.data);}else{wsUpdater.uid = event.data;}}},// 顯示消息showMessage: function(content) {var container = document.getElementById('container');var ele = document.createElement('div');ele.innerHTML = content;container.appendChild(ele);}};//發送消息function sendMsg() {var msg = {uid: wsUpdater.uid,message: document.getElementById('txt').value};wsUpdater.socket.send(JSON.stringify(msg));}//關閉連接function closeConn() {wsUpdater.socket.close();}</script></body> </html>可以開多個瀏覽器窗口測試,真正做到了消息的即時推送。基本效果如下,比較簡陋:
本文所有源碼,可以在這里查看:https://github.com/Ayhan-Huang/WebSocket-Test
本文參考了以下文章,在此表示感謝:
http://www.ruanyifeng.com/blog/2017/05/websocket.html
http://www.cnblogs.com/wupeiqi/p/6558766.html
總結
以上是生活随笔為你收集整理的WebSocket剖析的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 最简单的基于FFMPEG的封装格式转换器
- 下一篇: 同行blog收集