【Node.js】論一個低配版Web實時通信庫是如何實現的1( WebSocket篇)
- 2019 年 10 月 3 日
- 筆記
引論
simple-socket是我寫的一個”低配版”的Web實時通信工具(相對於Socket.io),在參考了相關源碼和資料的基礎上,實現了前後端實時互通的基本功能
選用了WebSocket ->server-sent-event -> AJAX輪詢這三種方式做降級兼容,分為simple-socket-client和simple-socket-server兩套代碼,
並實現了最簡化的API:
-
前後端各自通過connect事件觸發,獲取各自的socket對象
-
前端通過socket.emit(‘message’, “data”)發送消息; 服務端通過socket.on(‘message’, function (data) { //… })接收
-
服務端通過socket.emit(‘message’, “data”)發送消息; 前端通過socket.on(‘message’, function (data) { //… })接收
為方便細節的理解,未直接引用ws,eventsource,sockjs,http://engine.io等已有的工具庫
下面把編碼的過程和細節,以及代碼予以記錄
github倉庫地址
https://github.com/penghuwan/simple-socket
npm命令
npm i simple-socket-serve (服務端npm包) npm i simple-socket-client (客戶端npm包)
使用方式(模仿Socket.io)
前端
var client = require('simple-socket-client'); var client = new Client(); client.on('connect', socket => { socket.on('reply', function (data) { console.log(data) }) socket.emit('message', "pppppppp"); })
服務端
const SocketServer = require('simple-socket-serve'); const http = require('http'); const server = http.createServer(function (request, response) { // 你的其他代碼~~ }) // Usage start const ss = new SocketServer({ httpSrv: server, // 需傳入Server對象 }); ss.on('connect', socket => { socket.on('message', data => { console.log(data); }); setTimeout(() => { socket.emit('reply', "aaaa"); }, 3000); }); // Usage end server.listen(3000);
Output
前端: 約3秒後輸出aaaa 服務端:輸出pppppp
下面梳理了我在編碼過程中的思路,其中有些是借鑒於已有的工具庫(如Socket.io)源碼,有些則是自己的思考所得。如有錯漏之處請多指點
需要思考的問題
-
我們需要編寫哪些通信方式?這些通信方式的上到下的兼容順序是什麼?
-
瀏覽器怎麼選擇最優的通信方式呢?
-
服務端怎麼知道當前發出請求的瀏覽器,它最高支持哪一種通信方式?
-
編寫的服務端代碼怎麼和當前的業務代碼銜接?
-
如何使用WebSocket實現通訊?
Q1. 我們需要編寫哪些通信方式?這些通信方式的上到下的兼容順序是什麼?
首先要先梳理一下可供選擇的實現雙向通信的方式,以及它們的瀏覽器兼容性 (兼容性數據來源於 can i use)
-
WebSocket: IE10以上才支持,Chrome16, FireFox11,Safari7以及Opera12以上完全支持,移動端形勢大
-
event-source: IE完全不支持(注意是任何版本都不支持),Edge76,Chrome6,Firefox6,Safari5和Opera以上支持, 移動端形勢大好
-
AJAX輪詢: 用於兼容低版本的瀏覽器
-
永久幀( forever iframe)可用於兼容低版本的瀏覽器
-
flash socket 可用於兼容低版本的瀏覽器
那麼它們的優缺點各是怎樣的呢?
1.WebSocket
-
優點:WebSocket 是 HTML5 開始提供的一種在單個 TCP 連接上進行全雙工通訊的協議,可從HTTP升級而來,瀏覽器和服務器只需要一次握手,就可以進行持續的,雙向的數據傳輸,因此能顯著節約資源和帶寬
-
缺點:1. 兼容性問題:不支持較低版本的IE瀏覽器(IE9及以下)2.不支持斷線重連,需要手寫心跳連接的邏輯 3.通信機制相對複雜
2. server-sent-event(event-source)
-
優點:(1)只需一次請求,便可以stream的方式多次傳送數據,節約資源和帶寬 (2)相對WebSocket來說簡單易用 (3)內置斷線重連功能(retry)
-
缺點: (1)是單向的,只支持服務端->客戶端的數據傳送,客戶端到服務端的通信仍然依靠AJAX,沒有”一家人整整齊齊“的感覺(2)兼容性令人擔憂,IE瀏覽器完全不支持
3. AJAX輪詢
-
優點:兼容性良好,對標低版本IE
-
缺點:請求中有大半是無用的請求,浪費資源
4.Flash Socket(這個感覺得先說缺點2333)
-
缺點:(1)瀏覽器開啟時flash需要用戶確認,(2)加載時間長,用戶體驗較差 (3)大多數移動端瀏覽器不支持flash,為重災區
-
優點: 兼容低版本瀏覽器
5. 永久幀( forever iframe)
-
缺點: iframe會產生進度條一直存在的問題,用戶體驗差
-
優點:兼容低版本IE瀏覽器
綜上,綜合兼容性和用戶體驗的問題,我在項目中選用了WebSocket ->server-sent-event -> AJAX輪詢這三種方式做從上到下的兼容
Q2: 瀏覽器端怎麼選擇最優的通信方式呢?
很簡單,做一下能力檢測就可以了,對於支持WebSocket的瀏覽器,window頂層對象可以檢測到WebSocket屬性,而支持SSE的瀏覽器,則可以檢測到window.EventSource屬性,這便可以作為判斷依據。對三種方式做從上到下的判斷即可。
// 備註: 此為前端代碼 function Client() { this.ws = null; this.es = null; init.call(this); } function init() { // 採用WebSocket作為通信方式 if (window.WebSocket) { this.type = 'websocket'; this.ws = new WebSocket(`ws://${url}`); return; } // 採用server-sent-event作為通信方式 if (window.EventSource) { this.type = 'eventsource'; this.es = new EventSource(`http://${url}/eventsource?connection=true`) return; } // 採用Ajax輪詢作為通信方式 this.type = 'polling'; }
Q3.服務端怎麼知道當前發出請求的瀏覽器,它最高支持哪一種通信方式?
因為服務端需要處理不同的瀏覽器發出的請求,這些請求的方式可能是不一樣的。
我的思路是:
-
對於websocket請求,可通過檢測connection首部字段是否包含’upgrade’,同時upgrade首部字段是否為 ‘websocket’這兩個條件進行判斷
-
對於event-source和AJAX輪詢,讓前端選擇方式後,傳URL路徑過去告知後端就可以了,路徑分別為host:/eventsource和host:/polling
-
event-source我覺得也可以在前端設置accept:’text/event-stream’的方式告知後端,這個待會改改
// 備註:Node.js服務端代碼 var url = require('url'); module.exports = { // 判斷請求的瀏覽器是否選擇了websocket進行通信 isWebSocket(req) { var connection = req.headers.connection || ''; var upgrade = req.headers.upgrade || ''; return connection.toLowerCase().indexOf('upgrade') >= 0 && upgrade.toLowerCase() === 'websocket'; }, // 判斷請求的瀏覽器是否選擇了event-source(SSE)進行通信 isEventSource(req) { var pathname = url.parse(req.url).pathname; return pathname === '/eventsource'; }, // 判斷請求的瀏覽器是否選擇了AJAX輪詢進行通信 isPolling(req) { var pathname = url.parse(req.url).pathname; return pathname === '/polling'; }, }
Q4. 編寫的服務端代碼怎麼和當前的業務代碼銜接?
我們定義一個SocketServer類,並在contructor中接收業務代碼中已有的server實例,並監聽其request事件去處理請求和響應。如下所示
// 備註: Node.js服務端代碼 class SocketServer { constructor (opt) { super(); // 以構造函數參數的方式接收業務代碼裏面已有的Server實例 this.httpSrv = opt.httpSrv; this._initHttp(); } _initHttp() { // 監聽外部Server實例的request事件,並處理請求和響應 this.httpSrv.on('request', (req,res) => { // ... } ); } }
使用方式
const server = http.createServer(function (request, response) { }) // 原有的業務代碼 const ss = new SocketServer({ httpSrv: server, // 需傳入Server對象 }); ss.on('connect', socket => { });
這樣做有兩個好處:
-
一方面,對原有的代碼沒有過多的侵入性
-
避免了創建新的server實例或監聽不同的端口,保持和原server同域,避免了前後端代碼產生跨域的問題
前後端組織邏輯概述
前端
1.定義構造函數Client
function Client(host) { this.type = null; // 通信方式 this.ws = null; // WebSocket對象 this.es = null; // EventSource對象 this.ajax = null; init.call(this); // 通過能力檢測, 設置this.type,初始化相關API對象 listen.call(this); // 監聽相關連接打開或消息接收的事件(例如ws.onpen/ws.onmessage; } Client.prototype.on = function (event,cb){ emitter.on(event, cb) }
2.在連接打開時觸發connect事件,把client對象自身給傳進去
this.ws.onopen = function () { emitter.emit('connect', this); } var client = new Client(); // 下面的寫法中,socket和client其實是同一個對象 client.on('connect', socket => { socket.on('reply', function (data) { console.log(data) }) socket.emit('message', "pppppppp"); })
後端
class Socket extends events.EventEmitter { constructor(socketId) { super(); this.transport = null; // 標記通信方式 this.id = socketId; // SocketId this.netSocket = null // updrage時獲取的net.socket的實例,供WebSocket通信使用 this.eventStream = null // Stream.readable實例,供Event-Source通信使用 this.toSendMes = []; // 待發送的信息,HTTP輪詢時使用 } // 其他代碼 ... on (event,cb) { // 接收前端傳送的信息 } emit (event,data) { // 發送信息給前端 } }
並且定義Server類如下:
class Server extends events.EventEmitter { constructor(opt) { super(); this.httpSrv = opt.httpSrv; // ... } // 其他代碼 ... } // 使用Server對象 const ss = new Server({ httpSrv: server, // 需傳入Server對象 }); ss.on('connect', socket => { socket.on('message', data => { console.log(data); }); socket.emit('reply', "aaaa"); });
Q5.如何實現WebSocket實時通信?
關於如何在前端利用WS發送和接收消息,MDN文檔里說得很詳細了請看 https://developer.mozilla.org/zh-CN/docs/Web/API/WebSocket這裡不再贅述,主要是用了這幾個API:
- 創建websocket對象:var ws = new WebSocket(url);
- 發送消息 ws.send(“XXXX”);
- 接收消息:ws.onmessage = function (payload) { console.log(payload.data) };
WebSocket前端代碼
前端接收消息
// 一開始能力檢測的時候判斷過通信類型並初始化 this.ws = new WebSocket(`ws://${url}`); // ... 中間隔了其他代碼 this.ws.onmessage = function (payload) { var dataObj = JSON.parse(payload.data); emitter.emit(dataObj.event, dataObj.data); // 觸發事件 }
前端發送消息
// 一開始能力檢測的時候判斷過通信類型並初始化 this.ws = new WebSocket(`ws://${url}`); // ... 中間隔了其他代碼 this.ws.send(JSON.stringify({ event: event, data: data }));
WebSocket服務端代碼(Node.js)
WebSocket的報文結構
接下來要講的是後端怎麼進行websocket消息的發送和接收。這首先要先從websocet請求報文和響應報文開始說起
Connection: Upgrade // 表示請求從HTTP升級為其他協議 Upgrade: websocket // 表示升級的協議是webSocket Sec-WebSocket-Key: VCKjclrCsM3LpMkEngmVhA== // 這個參數需要在服務端拼接後返回 Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits // WebSocket的擴展字段 Sec-WebSocket-Version: 13 // WebSocket版本 Sec-websocket-protocol //這個字段我的報文里沒有,它是前端webSocket構造函數指定的第二個參數(new WebSocket(url,[protocol]))
HTTP/1.1 101 Switching Protocols Connection: Upgrade Upgrade: websocket Sec-WebSocket-Accept: WLZzo5hbAQgXJ24D0mE3u3nj1Fo=
…
WebSocket的握手流程和代碼
要在後端完成基本的握手,你需要做這三件事情:
2. 把下面這三行字段原封不動地寫入響應報文里,準備返回去給前端~~
HTTP/1.1 101 Switching Protocols Connection: Upgrade Upgrade: websocket
3. 從前端請求報文中獲取Sec-WebSocket-Key,拼接上服務端自己定義的ID字符串,然後用sha1加密,再然後轉為base64編碼格式。最後放在Sec-WebSocket-Accept這個響應報文字段中返回給前端。返回數據的方法是調用socket.write方法
上面三件事完成了,基本的握手流程就可以跑通了
如果你想進一步知道怎麼對Sec-WebSocket-Extensions,Sec-websocket-protocol這幾個請求字段做處理,你可以看看這裡,這個是ws模塊的代碼 https://github.com/websockets/ws/blob/master/lib/websocket-server.js ,對,就是這個文件
class SocketServer { constructor (opt) { super(); // 以構造函數參數的方式接收業務代碼裏面已有的Server實例 this.httpSrv = opt.httpSrv; this._initWebSocket(); } _initWebSocket() { // 監聽upgrade事件,判斷是否請求是websocket,若是則進行握手 this.httpSrv.on('upgrade', (req, netSocket) => { // ... other code this._handleWShandShake(req, netSocket, () => { const socket = new Socket(null); // 握手成功後觸發onConnection事件, 同時傳遞socket對象進去 this.emit('connect', socket); }) }); } }
上面的_handleWShandShake方法代碼如下:
handleWShandShake(req, netSocket, cb) { if (!detect.isWebSocket(req)) { return; } const key = req.headers['sec-websocket-key'] !== undefined ? req.headers['sec-websocket-key'].trim() : ''; const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; const digest = createHash('sha1') .update(key + GUID) .digest('base64'); const headers = [ 'HTTP/1.1 101 Switching Protocols', 'Upgrade: websocket', 'Connection: Upgrade', `Sec-WebSocket-Accept: ${digest}` ]; netSocket.write(headers.concat('rn').join('rn')); cb(); }
上面講了websocket的握手過程,下面講一下怎麼進行server端消息的發送和接收
服務端接收消息
我們上回說到,監聽server對象的upgrade事件可以獲取socket對象,我們可以通過監聽socket對象的data方法,獲取前端通過websocket.send傳來的數據 。
但是這裡有一個坑!上面data的回調里接收的payload是一個Buffer類型的對象,那我們能否通過Buffer.string去獲得前端傳來的JSON字符串呢?
答案是
因為傳來的—— 是一個封裝好的幀的數據,你需要把它手動解析出來,才能取出我們想要的那部分數據。
(如果你發現報了failed: One or more reserved bits are on: reserved1 = 1, reserved2 = 1, reserved3 = 1 這個錯誤,恭喜你!踩中坑了)
WebSocket幀的編碼和解碼
在介紹幀的編碼和解碼之前,讓我們先看看WebSocket的幀的格式是怎樣的
WebSocket的幀格式
詳細介紹參考Websocket的RFC文檔:https://tools.ietf.org/html/rfc6455 (在page27處)
了解了websocket幀的格式後,這裡介紹一下幾個非(jin)常(chang)有(keng)用(ren)的字段
-
FIN: 表示是否是最後一個幀,1代表是,0不是 // 返回數據幀給前端的時候FIN一定要為1,不然前端收不到
-
Opcode:幀類型,1代表文本數據,2代表二進制數據 // 這個影響前端onmessage接收的數據類型到底是String還是Blob
-
RSV 1 RSV2 RSV3 留以後備用 //也就是。。現在還沒有卵用,如果控制台報了這個有錯八成是沒有解析幀數據
其他一些字段
-
Mask :1bit 掩碼,是否加密數據,默認必須置為1
-
Payload len : 7bit,表示數據的長度
-
Payload data :為數據內容
解析數據幀的代碼
OK!介紹完了幀的格式,下面show一下(別人的)解析幀的代碼
// 解析Socket數據幀的方法 // 作者:龍恩0707 // 參考地址: https://www.cnblogs.com/tugenhua0707/p/8542890.html function decodeFrame(e) { var i = 0, j, s, arrs = [], frame = { // 解析前兩個位元組的基本數據 FIN: e[i] >> 7, Opcode: e[i++] & 15, Mask: e[i] >> 7, PayloadLength: e[i++] & 0x7F }; // 處理特殊長度126和127 if (frame.PayloadLength === 126) { frame.PayloadLength = (e[i++] << 8) + e[i++]; } if (frame.PayloadLength === 127) { i += 4; // 長度一般用4個位元組的整型,前四個位元組一般為長整型留空的。 frame.PayloadLength = (e[i++] << 24) + (e[i++] << 16) + (e[i++] << 8) + e[i++]; } // 判斷是否使用掩碼 if (frame.Mask) { // 獲取掩碼實體 frame.MaskingKey = [e[i++], e[i++], e[i++], e[i++]]; // 對數據和掩碼做異或運算 for (j = 0, arrs = []; j < frame.PayloadLength; j++) { arrs.push(e[i + j] ^ frame.MaskingKey[j % 4]); } } else { // 否則的話 直接使用數據 arrs = e.slice(i, i + frame.PayloadLength); } // 數組轉換成緩衝區來使用 arrs = new Buffer.from(arrs); // 如果有必要則把緩衝區轉換成字符串來使用 if (frame.Opcode === 1) { arrs = arrs.toString(); } // 設置上數據部分 frame.PayloadLength = arrs; // 返回數據幀 return frame; }
幀解碼後接收前端傳來的消息
幀解碼
藉助於上面的decodeFrame方法,我們就可以愉快地通過WebSocket從前端接收消息啦!
this.httpSrv.on('upgrade', (req, netSocket) => { // ... other code netSocket.on('data', payload => { // 對接收的WebSocket幀數據進行解析,對應前端調用ws.send方法發來的數據 const str = decodeFrame(payload).PayloadLength; }); });
通過WebSocket向前端發送消息
根據上文容易聯想,既然接收消息要解析幀,那麼發送消息也肯定要把數據封裝成幀再發送對不對~~ 看代碼
WebSocket幀的封裝
// 接收數據並返回Socket數據幀的方法 // 作者:小鬍子哥 // 參考地址: https://www.cnblogs.com/hustskyking/p/websocket-with-node.html function encodeFrame(e) { var s = [], o = Buffer.from(e.PayloadData), l = o.length; //輸入第一個位元組 s.push((e.FIN << 7) + e.Opcode); //輸入第二個位元組,判斷它的長度並放入相應的後續長度消息 //永遠不使用掩碼 if (l < 126) s.push(l); else if (l < 0x10000) s.push(126, (l & 0xFF00) >> 8, l & 0xFF); else s.push( 127, 0, 0, 0, 0, //8位元組數據,前4位元組一般沒用留空 (l & 0xFF000000) >> 6, (l & 0xFF0000) >> 4, (l & 0xFF00) >> 8, l & 0xFF ); //返回頭部分和數據部分的合併緩衝區 return Buffer.concat([new Buffer(s), o]); }
好的大夥,故事到這裡就講完了,祝大家 。。。
等等!!
好像還有什麼重要的事情要說。
WebSocket編碼的技術總結
下面開始WebSocket編碼的技術總結~(美食作家王剛的口音)
「Node篇」
-
httpServer的Upgrade事件並不是Upgrade成功時觸發的,而是包含Upgrade首部的請求報文到達服務端時觸發的,也即每次服務器響應升級請求時發出。我們可以在這裡確認請求是否為Websocket升級請求並進行握手
-
在simple-socket-server中,是將其附加到已有的server實例中根據其自有的請求和響應進行處理,而不是另外啟動一個server,這樣是為了避免產生跨域的問題,因為simple-socket-client的JS代碼和項目本身的服務端代碼是同域的,simple-socket-server自然也要和原有的服務端代碼同域
-
可以通過httpserver對象的request事件監聽請求和響應,從外部附加socket-server的業務代碼
「WebSocket篇」
-
websocket不是永久連接的。一段時間就會斷開,websocket需要手寫定時心跳連接的代碼(待會填上去)
-
服務端接收Websocket數據需手動解析WebSocket幀。當你嘗試接收前端的數據時,即在服務端獲取到連接的socket後,通過socket.on(‘data’, payload => { … })獲取的payload。這個payload是一個Buffer類型, 然而蛋疼的是你也不能直接通過Buffer.toString拿到這個字符串數據,如果直接toString輸出將會得到一串亂碼!!因為收到的這個Buffer是一個被封裝後的幀,需要進行解析
-
服務端發送Websocket數據需手動封裝WebSocket幀。 正如上一條所示,在websocket的服務端,你不能直接通過socket.write(String)或者socket.write(Buffer)去寫數據,而是要手動先把數據封裝成幀,才能發送過去
-
在服務端發送websocket數據幀時,要確保FIN為1(表示最後一個幀)。前端onmessage才能收到響應!否則無法響應。
-
WebSocket的onmessage = (event) =>{ event.data }中前端接收的event.data的類型取決於服務端返回的數據幀的opcode這一字段, event.data可能為Blob (opcode = 2,代表發送過去的是二進制數據) 或者字符串(opcode = 1,表示字符串數據)
本文完,完整代碼請參考
github倉庫地址
https://github.com/penghuwan/simple-socket