最近正在研究 Websocket 相關的知識,想着如何能本身實現 Websocket 協議。到網上搜羅了一番資料後用 Node.js 實現該協議,倒也沒有想象中那麼複雜,除去註釋語句和 console 語句後,大約 200 行代碼左右。本文記錄了實現過程當中的經驗和總結。html
若是你想要寫一個 WebSocket 服務器,首先須要讀懂對應的網絡協議 RFC6455,不過這對於通常人來講有些 「晦澀」,英文且不說,還得咬文嚼字理解 網絡編程 含義。前端
好在 WebSocket 技術出現比較早,因此能夠搜到 RFC6455 中文版,網上也有不少針對該協議的剖析文章,不少文章裏還有現成的實現代碼能夠參考,因此說實現一個簡單的 Websocket 服務並不是難事。node
本文更偏向實戰(in action),會從知識儲備、具體代碼分析以及注意事項角度去講解如何用 Node.js 實現一個簡單的 Websocket 服務,至於 Websocket 概念、定義、解釋和用途等基礎知識不會涉及,由於這些知識在本文所列的參考文章中輕鬆找到。(也能夠自行網上隨便一搜,就能找到不少)git
若是要本身寫一個 Websocket 服務,主要有兩個難點:github
同時還須要具有兩個基礎知識點:web
具體的作法以下,推薦先閱讀如下幾篇參考文章:編程
而後開始寫代碼,在實現過程當中的大部分代碼能夠從下面 3 篇文章中找到並借鑑(copy):segmentfault
閱讀完上面的文章,你會有發現一個共同點,就是在實現 WebSockets 過程當中,最最核心的部分就是 解析 或者 生成 Frame(幀),就是下面這結構:
api
截圖來自規範 Base Framing Protocol
想要理解 frame 各個字段的含義,可參考 WebSocket詳解(三):深刻WebSocket通訊協議細節,文中做者繪製了一副圖來解釋這個 frame 結構;瀏覽器
而在代碼層面,frame 的解析或生成能夠在 RocketEngine - parser 或者 _processBuffer
中找到。
在完成上面幾個方面的知識儲備以後,並且大多有現成的代碼,因此本身邊抄邊寫一個 Websocket 服務器並不算太難。
對於 Websocket 初學者,請務必閱讀以上參考文章,對 Websocket 協議有大概的瞭解以後再繼續本文剩下部分的閱讀,不然頗有可能會以爲我寫得雲裏霧裏,不知所云。
實現代碼放在本身的 demos 倉庫的 micro-ws 的目錄 了,git clone 後本地運行,執行
node index.js
將會在 http://127.0.0.1:3000 建立服務。運行服務以後,打開控制檯就能看到效果:
動圖中瀏覽器 console 所執行的 js 代碼步驟以下:
1.先創建鏈接:
var ws = new WebSocket("ws://127.0.0.1:3000"); ws.onmessage = function(evt) { console.log( "Received Message: " + evt.data); };
2.而後發送消息:(注意必定要在創建鏈接以後再執行該語句,不然發不出消息的)
ws.send('hello world');
從效果可見,咱們已經實現 Websocket 最基本的通信功能了。
接下來咱們詳細看一下具體實現的細節。
站在使用者的角度,假設咱們已經完成 Websocket 類了,那麼應該怎麼使用?
客戶端經過 HTTP Upgrade 請求,即 101 Switching Protocol 到 HTTP 服務器,而後由服務器進行協議轉換。
在 Node.js 中咱們經過 http.createServer 獲取 http.server
實例,而後監聽 upgrade 事件,在處理這個事件:
// HTTP服務器部分 var server = http.createServer(function(req, res) { res.end('websocket test\r\n'); }); // Upgrade請求處理 server.on('upgrade', function(req, socket, upgradeHead){ // 初始化 ws var ws = new WebSocket(req, socket, upgradeHead); // ... ws 監聽 data、error 的邏輯等 });
這裏監聽 upgrade 事件的回調函數中第二個參數 socket
是 net.Socket 實例,這個類是 TCP 或 UNIX Socket 的抽象,同時一個 net.Socket 也是一個 duplex stream,因此它能被讀或寫,而且它也是一個 EventEmitter。
咱們就利用這個 socket 對象上進行 Websocket 類實例的初始化工做;
因此不難理解 Websocket 的構造函數就是下面這個樣子:
class WebSocket extends EventEmitter { constructor(req, socket, upgradeHead){ super(); // 調用 EventEmitter 構造函數 // 1. 構造響應頭 resHeaders 部分 // 2. 監聽 socket 的 data 事件,以及 error 事件 // 3. 初始化成員屬性 } }
注意,咱們須要繼承內置的 EventEmitter ,這樣生成的實例才能監聽、綁定事件;
Node.js 採用事件驅動、異步編程,天生就是爲了網絡服務而設計的,繼承 EventEmitter 就能享受到非阻塞模式的 IO 處理;
講一下其中 響應頭的構造 和 事件監聽 部分。
根據協議規範,咱們能寫出響應頭的內容:
Sec-WebSocket-Key
跟 258EAFA5-E914-47DA-95CA-C5AB0DC85B11
拼接。具體代碼以下:
var resKey = hashWebSocketKey(req.headers['sec-websocket-key']); // 構造響應頭 var resHeaders = [ 'HTTP/1.1 101 Switching Protocols', 'Upgrade: websocket', 'Connection: Upgrade', 'Sec-WebSocket-Accept: ' + resKey ] .concat('', '') .join('\r\n'); socket.write(resHeaders);
當執行 socket.write(resHeaders);
到後就和客戶端創建起 WebSocket 鏈接了,剩下去就是數據的處理。
socket
就是 TCP 協議的抽象,直接在上面監聽已有的 data
事件和 close
事件這兩個事件。
還有其餘事件,好比error
、end
等,詳細參考 net.Socket 文檔
socket.on('data', data => { this.buffer = Buffer.concat([this.buffer, data]); while (this._processBuffer()) {} // 循環處理返回的 data 數據 }); socket.on('close', had_error => { if (!this.closed) { this.emit('close', 1006); this.closed = true; } });
close
的事件邏輯比較簡單,比較重要的是 data
的事件監聽部分。核心就是 this._processBuffer()
這個方法,用於處理客戶端傳送過來的數據(即 Frame 數據)
。注意該方法是放在 while
循環語句裏,處理好邊界狀況,防止死循環。
WebSocket 客戶端、服務端通訊的最小單位是幀(frame),由1個或多個幀組成一條完整的消息(message)。
這 this._processBuffer()
部分代碼邏輯就是用來解析幀數據的,因此它是實現 Websocket 代碼的關鍵;(該方法裏面用到了大量的位操做符以及 Buffer 類的操做)
幀數據結構詳細定義可參考 RFC6455 5.2節,上面羅列的參考文章都有詳細的解讀,我在這兒也不囉嗦講細節了,直接看代碼比聽我用文字講要好。
這裏就其中兩個細節須要鋪墊一下,方便更好地理解代碼。
Opcode
即 操做代碼,Opcode 的值決定了應該如何解析後續的數據載荷(data payload)
根據 Opcode 咱們能夠大體將數據幀分紅兩大類:數據幀 和 控制幀。
數據幀:目前只有 3 種,對應的 opcode 是:
控制幀:除了上述 3 種數據幀以外,剩下的都是控制幀
在代碼裏,咱們會先從幀數據中提取操做碼:
var opcode = byte1 & 0x0f; //截取第一個字節的後 4 位,即 opcode 碼
而後根據協議獲取到真正的數據載荷(data payload),而後將這兩部分傳給 _handleFrame
方法:
this._handleFrame(opcode, payload); // 處理操做碼
該方法會根據不一樣的 opcode
作出不一樣的操做:
_handleFrame(opcode, buffer) { var payload; switch (opcode) { case OPCODES.TEXT: payload = buffer.toString('utf8'); //若是是文本須要轉化爲utf8的編碼 this.emit('data', opcode, payload); //Buffer.toString()默認utf8 這裏是故意指示的 break; case OPCODES.BINARY: //二進制文件直接交付 payload = buffer; this.emit('data', opcode, payload); break; case OPCODES.PING: // 發送 pong 作響應 this._doSend(OPCODES.PONG, buffer); break; case OPCODES.PONG: //不作處理 console.log('server receive pong'); break; case OPCODES.CLOSE: // close有不少關閉碼 let code, reason; // 用於獲取關閉碼和關閉緣由 if (buffer.length >= 2) { code = buffer.readUInt16BE(0); reason = buffer.toString('utf8', 2); } this.close(code, reason); this.emit('close', code, reason); break; default: this.close(1002, 'unhandle opcode:' + opcode); } }
規範文檔: 5.4 - Fragmentation
一旦 WebSocket 客戶端、服務端創建鏈接後,後續的操做都是基於數據幀的傳遞。理論上來講,每一個幀(Frame)的大小是沒有限制的。
對於大塊的數據,Websocket 協議建議對數據進行分片(Fragment)操做。
分片的意義主要是兩方面:
WebSocket 協議提供的分片方法,是將本來一個大的幀拆分紅數個小的幀。下面是把一個大的Frame分片的圖示:
分片編號 | 0 | 1 | ... | n-2 | n-1 |
---|---|---|---|---|---|
FIN | 0 | 0 | ... | 0 | 1 |
opcode | !0 | 0 | 0 | 0 | 0 |
由圖可知,第一個分片的 FIN
爲 0,Opcode 爲非0值(0x1 或 0x2),最後一個分片的FIN爲1,Opcode爲 0。中間分片的 FIN
和 opcode
兩者均爲 0。
根據 FIN 的值來判斷,是否已經收到消息的最後一個數據幀。
FIN=1
表示當前數據幀爲消息的最後一個數據幀,此時接收方已經收到完整的消息,能夠對消息進行處理。FIN=0
,則接收方還須要繼續監聽接收其他的數據幀。opcode在數據交換的場景下,表示的是數據的類型。
0x01
表示文本,永遠是 utf8
編碼的0x02
表示二進制0x00
比較特殊,表示 延續幀(continuation frame),顧名思義,就是完整消息對應的數據幀還沒接收完。代碼裏,咱們須要檢測 FIN 的值,若是爲 0 說明有分片,須要記錄第一個 FIN 爲 0 時的 opcode
值,緩存到 this.frameOpcode
屬性中,將載荷緩存到 this.frames
屬性中:
var FIN = byte1 & 0x80; // 若是爲0x80,則標誌傳輸結束,獲取高位 bit // 若是是 0 的話,說明是延續幀,須要保存好 opCode if (!FIN) { this.frameOpcode = opcode || this.frameOpcode; // 確保不爲 0; } //.... // 有多是分幀,須要拼接數據 this.frames = Buffer.concat([this.frames, payload]); // 保存到 frames 中
當接收到最後一個 FIN
幀的時候,就能夠組裝後給 _handleFrame
方法:
if (FIN) { payload = this.frames.slice(0); // 獲取全部拼接完整的數據 opcode = opcode || this.frameOpcode; // 若是是 0 ,則保持獲取以前保存的 code this.frames = Buffer.alloc(0); // 清空 frames this.frameOpcode = 0; // 清空 opcode this._handleFrame(opcode, payload); // 處理操做碼 }
上面講的都是接收並解析來自客戶端的數據幀,當咱們想給客戶端發送數據幀的時候,也得按協議來。
這部分操做至關因而上述 _processBuffer
方法的逆向操做,在代碼裏咱們使用 encodeMessage
方法(爲了簡單起見,咱們發送給客戶端的數據沒有通過掩碼處理)將發送的數據分裝成數據幀的格式,而後調用 socket.write
方法發送給客戶端;
_doSend(opcode, payload) { // 1. 考慮數據分片 this.socket.write( encodeMessage(count > 0 ? OPCODES.CONTINUE : opcode, payload) ); //編碼後直接經過socket發送
爲了考慮分片場景,特地設置 MAX_FRAME_SIZE
來對每次發送的數據長度作截斷作分片:
// ... var len = Buffer.byteLength(payload); // 分片的距離邏輯 var count = 0; // 這裏能夠針對 payload 的長度作分片 while (len > MAX_FRAME_SIZE) { var framePayload = payload.slice(0, MAX_FRAME_SIZE); payload = payload.slice(MAX_FRAME_SIZE); this.socket.write( encodeMessage( count > 0 ? OPCODES.CONTINUE : opcode, framePayload, false ) ); //編碼後直接經過socket發送 count++; len = Buffer.byteLength(payload); } // ...
至此已經實現 Websocket 協議的關鍵部分,所組裝起來的代碼就能和客戶端創建 Websocket 鏈接並進行數據交互了。
這個標誌性字符串是專門標示 Websocket
協議的 UUID;UUID 是長度爲 16-byte(128-bit)的ID,通常以形如f81d4fae-7dec-11d0-a765-00a0c91e6bf6
的字符串做爲 URN(Uniform Resource Name,統一資源名稱)
UUID 能夠移步到 UUID原理 和 RFC 4122 獲取更多知識
爲啥選擇這個字符串?
在規範的第七頁已經有明確的說明了:
之因此選用這個 UUID ,主要該 ID 極大不太可能被其餘不瞭解 Websocket 協議的網絡終端所使用;
我也不曉得該怎麼翻譯。。。總之就說這個 ID 就至關於 Websocket
協議的 「身份證號」 了。
HTTP、WebSocket 等應用層協議,都是基於 TCP 協議來傳輸數據的,咱們能夠把這些高級協議理解成對 TCP 的封裝。
既然你們都使用 TCP 協議,那麼你們的鏈接和斷開,都要遵循 TCP 協議中的三次握手和四次握手 ,只是在鏈接以後發送的內容不一樣,或者是斷開的時間不一樣。
對於 WebSocket 來講,它必須依賴 HTTP 協議進行一次握手 ,握手成功後,數據就直接從 TCP 通道傳輸,與 HTTP 無關了。
答案是:看具體瀏覽器的實現。
WebSocket是一個 message based 的協議,它能夠自動將數據分片,而且自動將分片的數據組裝。
每一個 message 能夠是一個或多個分片。message 不記錄長度,分片才記錄長度;
根據協議 websocket 協議中幀長度上限爲 2^63 byte(爲 8388608 TB),能夠認爲沒有限制,很明顯按協議的最大上限來傳輸數據是不靠譜的。因此在實際使用中 websocket
消息長度限制取決於具體的實現。關於哲方面,找了兩篇參考文章:
在文章 WebSocket探祕 中,做者就作了一個實驗,做者發送 27378 個字節,結果被迫分包了;若是是大數據量,就會被socket自動分包發送。
而通過我本人試驗,發現 Chrome 瀏覽器(版本 68.0.3440.106 - 64bit)會針對 131072(=2^17)bytes 大小進行自動分包。我是經過如下測試代碼驗證:
var ws = new WebSocket("ws://127.0.0.1:3000"); ws.onmessage = function(evt) { console.log( "Received Message: " + evt.data); }; var myArray = new ArrayBuffer(131072 * 2 + 1); ws.send(myArray);
服務端日誌:
server detect fragment, sizeof payload: 131072 server detect fragment, sizeof payload: 131072 receive data: 2 262145
客戶端日誌:
Received Message: good job
截圖以下:
而以一樣的方式去測試一些本身機器上的瀏覽器:
這些客戶端上的 Websocket 幾乎沒有大小的分片(隨着數據量增大,發送會減緩,但並無發現分片現象)。
從剛開始決定閱讀 Websocket 協議,到本身使用 Node.js 實現一套簡單的 Websocket 協議,到這篇文章的產出,先後耗費大約 1 個月時間(拖延症。。。)。
感謝文中所說起的參考文獻所給予的幫助,讓我實現過程當中事半功倍。
之因此可以使用較少的代碼實現 Websocket,是由於 Node.js 體系自己了很好的基礎,好比其所提供的 EventEmitter
類自帶事件循環,http 模塊讓你直接使用封裝好的 socket
對象,咱們只要按照 Websocket 協議實現 Frame(幀)的解析和組裝便可。
在使用 Node.js 實現一遍 Websocket 協議後,就能較爲深入地理解如下知識點(理解起來一切都是那麼天然而然):
本文僅僅是協議的簡單實現,對於 Websocket 的其實還有不少事情能夠作(好比支持 命名空間、流式 API 等),有興趣的能夠參考業界流行的 Websocket 倉庫,去練習鍛造一個健壯的 Websocket 工具庫輪子:
本文完。
下面的是個人公衆號二維碼圖片,歡迎關注。