寫在開頭:
爲什麼要使用websocket協議(以下簡稱ws協議),什麼場景會使用?
我之前是做IM相關桌面端軟件的開發,基於TCP長鏈接自己封裝的一套私有協議,目前公司也有項目用到了ws協議,好像無論什麼行業,都會遇到這個ws協議。
想自己造輪子,可以參考我之前的代碼和文章:
原創:如何自己實現一個簡單的webpack構建工具 【附源碼】
首先它的使用是很簡單的,在H5和Node.js中都是基於事件驅動
在H5中
在H5中的使用案例:
<!DOCTYPE HTML>
<html>
<head>
<meta charset="utf-8">
<script type="text/javascript">
function WebSocketTest() {
if ("WebSocket" in window) {
alert("您的瀏覽器支持 WebSocket!");
// 打開一個 web socket
var ws = new WebSocket("ws://localhost:9998");
ws.onopen = function () {
// Web Socket 已連接上,使用 send() 方法發送數據
ws.send("發送數據");
alert("數據發送中...");
};
ws.onmessage = function (evt) {
var received_msg = evt.data;
alert("數據已接收...");
};
ws.onclose = function () {
// 關閉 websocket
alert("連接已關閉...");
};
}
else {
// 瀏覽器不支持 WebSocket
alert("您的瀏覽器不支持 WebSocket!");
}
}
</script>
</head>
<body>
<div id="sse">
<a href="javascript:WebSocketTest()">運行 WebSocket</a>
</div>
</body>
</html>
Node.js中的服務端搭建:
const {Server} = require('ws');//引入模塊
const wss = new Server({ port: 9998 });//創建一個WebSocketServer的實例,監聽端口9998
wss.on('connection', function connection(socket) {
socket.on('message', function incoming(message) {
console.log('received: %s', message);
socket.send('Hi Client');
});//當收到消息時,在控制檯打印出來,並回復一條信息
});
這樣你就愉快的通信了,不需要關注協議的實現,但是真正的項目場景中,可能會有UDP、TCP、FTP、SFTP等場景,你還是需要了解不同的協議實現細節,這裏我推薦一下某金的張師傅小冊《TCP協議》,看過都說好。(這裏沒收錢,就是覺得好)
正式開始:
爲什麼要使用ws協議?
傳統的Ajax輪詢(即一直不聽發請求去後端拿數據)或長輪詢的操作太過於粗暴,性能更不用說。
ws協議在目前瀏覽器中支持已經非常好了,另外這裏說一句,它也是一個應用層協議,成功升級ws協議,是101狀態碼,像webpack熱更新這些都有用ws協議
這就是連接了本地的ws服務器
現在開始,我們實現服務端的ws協議,就是自己實現一個websocket類,並且繼承Node.js的自定義事件模塊,還要一個起一個進程佔用端口,那麼就要用到http模塊
const { EventEmitter } = require('events');
const { createServer } = require('http');
class MyWebsocket extends EventEmitter {}
module.exports = MyWebsocket;
這是一個基礎的類,我們繼承了自定義事件模塊,還引入了http的createServer方法,此時先實現端口占用
const { EventEmitter } = require('events');
const { createServer } = require('http');
class MyWebsocket extends EventEmitter {
constructor(options) {
super(options);
this.options = options;
this.server = createServer();
options.port ? this.server.listen(options.port) : this.server.listen(8080); //默認端口8080
}
}
module.exports = MyWebsocket;
接下來,要先分析下請求ws協議的請求頭、響應頭
正常一個ws協議成功建立分下面這幾個步驟
客戶端請求升級協議
GET / HTTP/1.1Upgrade: websocket Connection:UpgradeHost: example.com Origin: http://example.comSec-WebSocket-Key: sN9cRrP/n9NdMgdcy2VJFQ==Sec-WebSocket-Version:13
服務端響應,
HTTP/1.1101SwitchingProtocolsUpgrade: websocket Connection:UpgradeSec-WebSocket-Accept: fFBooB7FAkLlXgRSz0BT3v4hq5s=Sec-WebSocket-Location: ws://example.com/
以下是官方對這些字段的解釋:
-
Connection 必須設置 Upgrade,表示客戶端希望連接升級。
-
Upgrade 字段必須設置 Websocket,表示希望升級到 Websocket 協議。
-
Sec-WebSocket-Key 是隨機的字符串,服務器端會用這些數據來構造出一個 SHA-1 的信息摘要。把 “Sec-WebSocket-Key” 加上一個特殊字符串 “258EAFA5-E914-47DA-95CA-C5AB0DC85B11”,然後計算 SHA-1 摘要,之後進行 BASE-64 編碼,將結果做爲 “Sec-WebSocket-Accept” 頭的值,返回給客戶端。如此操作,可以儘量避免普通 HTTP 請求被誤認爲 Websocket 協議。
-
Sec-WebSocket-Version 表示支持的 Websocket 版本。RFC6455 要求使用的版本是 13,之前草案的版本均應當棄用。
-
Origin 字段是可選的,通常用來表示在瀏覽器中發起此 Websocket 連接所在的頁面,類似於 Referer。但是,與 Referer 不同的是,Origin 只包含了協議和主機名稱。
-
其他一些定義在 HTTP 協議中的字段,如 Cookie 等,也可以在 Websocket 中使用。
這裏得先看這張圖
在第一次Http握手階段,觸發服務端的upgrade事件,我們把瀏覽器端的ws地址改成我們的自己實現的端口地址
websocket的協議特點:
-
建立在 TCP 協議之上,服務器端的實現比較容易。
-
與 HTTP 協議有着良好的兼容性。默認端口也是80和443,並且握手階段採用 HTTP 協議,因此握手時不容易屏蔽,能通過各種 HTTP 代理服務器。
-
數據格式比較輕量,性能開銷小,通信高效。
-
可以發送文本,也可以發送二進制數據。
-
沒有同源限制,客戶端可以與任意服務器通信。
-
協議標識符是ws(如果加密,則爲wss),服務器網址就是 URL。
如果你想系統學習TCP通信協議,我給你一個優惠碼,可以系統學習一下,將來如果你是去做一些有技術深度要求的工作,是很需要這個知識的(回扣我都會用來公衆號抽獎送禮物,並非盈利性質,是真的覺得這個學習資料好才推薦)
回到正題,將客戶端ws協議連接地址選擇我們的服務器地址,然後改造服務端代碼,監聽upgrade事件看看
const { EventEmitter } = require('events');
const { createServer } = require('http');
class MyWebsocket extends EventEmitter {
constructor(options) {
super(options);
this.options = options;
this.server = createServer();
options.port ? this.server.listen(options.port) : this.server.listen(8080); //默認端口8080
// 處理協議升級請求
this.server.on('upgrade', (req, socket, header) => {
this.socket = socket;
console.log(req.headers)
socket.write('hello');
});
}
}
module.exports = MyWebsocket;
我們可以看到,監聽到了協議請求升級事件,而且可以拿到請求頭部。上面提到過:
-
Sec-WebSocket-Key 是隨機的字符串,服務器端會用這些數據來構造出一個 SHA-1 的信息摘要。把 “Sec-WebSocket-Key” 加上一個特殊字符串 “258EAFA5-E914-47DA-95CA-C5AB0DC85B11”,然後計算 SHA-1 摘要,之後進行 BASE-64 編碼,將結果做爲 “Sec-WebSocket-Accept” 頭的值,返回給客戶端。如此操作,可以儘量避免普通 HTTP 請求被誤認爲 Websocket 協議。
說人話:
就是要給一個特定的響應頭,告訴瀏覽器,這ws協議請求升級,我同意了。
代碼實現:
const { EventEmitter } = require('events');
const { createServer } = require('http');
const crypto = require('crypto');
const MAGIC_STRING = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; // 固定的字符串
function hashWebSocketKey(key) {
const sha1 = crypto.createHash('sha1'); // 拿到sha1算法
sha1.update(key + MAGIC_STRING, 'ascii');
return sha1.digest('base64');
}
class MyWebsocket extends EventEmitter {
constructor(options) {
super(options);
this.options = options;
this.server = createServer();
options.port ? this.server.listen(options.port) : this.server.listen(8080); //默認端口8080
this.server.on('upgrade', (req, socket, header) => {
this.socket = socket;
console.log(req.headers['sec-websocket-key'], 'key');
const resKey = hashWebSocketKey(req.headers['sec-websocket-key']); // 對瀏覽器生成的key進行加密
// 構造響應頭
const resHeaders = [
'HTTP/1.1 101 Switching Protocols',
'Upgrade: websocket',
'Connection: Upgrade',
'Sec-WebSocket-Accept: ' + resKey,
]
.concat('', '')
.join('\r\n');
console.log(resHeaders, 'resHeaders');
socket.write(resHeaders); // 返回響應頭部
});
}
}
module.exports = MyWebsocket;
看看network面板,狀態碼已經變成了101,到這一步,我們已經把協議升級成功,並且寫入了響應頭
剩下的就是數據交互了,既然ws是長鏈接+雙工通訊,而且是應用層,建立在TCP之上封裝的,這張圖應該能很好的解釋(來自阮一峯老師的博客)
網絡鏈路已經通了,協議已經打通,剩下一個長鏈接+數據推送了,但是我們目前還是一個普通的http服務器
這是一個websocket的基本幀協議(其實websocket可以看成基於TCP封裝的私有協議,只不過大家採用了某個標準達成了共識,有興趣的可以看看微服務架構的相關內容,設計私有協議,端到端加密等)
其中FIN代表是否爲消息的最後一個數據幀(類似TCP的FIN,TCP也會分片傳輸)
-
RSV1,RSV2,Rsv3(每個佔1位),必須是0,除非一個擴展協商爲非零值定義的
-
Opcode表示幀的類型(4位),例如這個傳輸的幀是文本類型還是二進制類型,二進制類型傳輸的數據可以是圖片或者語音之類的。(這4位轉換成16進制值表示的意思如下):
-
0x0 表示附加數據幀
-
0x1 表示文本數據幀
-
0x2 表示二進制數據幀
-
0x3-7 暫時無定義,爲以後的非控制幀保留
-
0x8 表示連接關閉
-
0x9 表示ping
-
0xA 表示pong
-
0xB-F 暫時無定義,爲以後的控制幀保留
Mask(佔1位):表示是否經過掩碼處理, 1 是經過掩碼的,0是沒有經過掩碼的。如果Mask位爲1,表示這是客戶端發送過來的數據,因爲客戶端發送的數據要進行掩碼加密;如果Mask爲0,表示這是服務端發送的數據。
payload length (7位+16位,或者 7位+64位),定義負載數據的長度。
1. 如果數據長度小於等於125的話,那麼該7位用來表示實際數據長度。
2. 如果數據長度爲126到65535(2的16次方)之間,該7位值固定爲126,也就是 1111110,往後擴展2個字節(16爲,第三個區塊表示),用於存儲數據的實際長度。
3. 如果數據長度大於65535, 該7位的值固定爲127,也就是 1111111 ,往後擴展8個字節(64位),用於存儲數據實際長度。
Masking-key(0或者4個字節),該區塊用於存儲掩碼密鑰,只有在第二個子節中的mask爲1,也就是消息進行了掩碼處理時纔有,否則沒有,所以服務器端向客戶端發送消息就沒有這一塊。
Payload data 擴展數據,是0字節,除非已經協商了一個擴展。
現在我們需要保持長鏈接
⚠️:如果你是使用Node.js開啓基於TCP的私有雙工長鏈接協議,也要開啓這個選項
const { EventEmitter } = require('events');
const { createServer } = require('http');
const crypto = require('crypto');
const MAGIC_STRING = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; // 固定的字符串
function hashWebSocketKey(key) {
const sha1 = crypto.createHash('sha1'); // 拿到sha1算法
sha1.update(key + MAGIC_STRING, 'ascii');
return sha1.digest('base64');
}
class MyWebsocket extends EventEmitter {
constructor(options) {
super(options);
this.options = options;
this.server = createServer();
options.port ? this.server.listen(options.port) : this.server.listen(8080); //默認端口8080
this.server.on('upgrade', (req, socket, header) => {
this.socket = socket;
socket.setKeepAlive(true);
console.log(req.headers['sec-websocket-key'], 'key');
const resKey = hashWebSocketKey(req.headers['sec-websocket-key']); // 對瀏覽器生成的key進行加密
// 構造響應頭
const resHeaders = [
'HTTP/1.1 101 Switching Protocols',
'Upgrade: websocket',
'Connection: Upgrade',
'Sec-WebSocket-Accept: ' + resKey,
]
.concat('', '')
.join('\r\n');
console.log(resHeaders, 'resHeaders');
socket.write(resHeaders); // 返回響應頭部
});
}
}
module.exports = MyWebsocket;
OK,現在最重要的一個通信長鏈接和頭部已經實現,只剩下兩點:
-
進行與掩碼異或運行拿到真實數據
-
處理真實數據(根據opcode)
提示:如果這兩點你看不懂沒關係,只是一個運算過程,當你自己基於TCP設計私有協議時候,也要考慮這些,msgType、payloadLength、服務端發包粘包、客戶端收包粘包、斷線重傳、timeout、心跳、發送隊列等
給socket對象掛載事件,我們已經繼承了EventEmitter模塊
socket.on('data', (data) => {
// 監聽客戶端發送過來的數據,該數據是一個Buffer類型的數據
this.buffer = data; // 將客戶端發送過來的幀數據保存到buffer變量中
this.processBuffer(); // 處理Buffer數據
});
socket.on('close', (error) => {
// 監聽客戶端連接斷開事件
if (!this.closed) {
this.emit('close', 1006, 'timeout');
this.closed = true;
}
每次接受到了data,觸發事件,解析Buffer,進行運算
processBuffer() {
let buf = this.buffer;
let idx = 2; // 首先分析前兩個字節
// 處理第一個字節
const byte1 = buf.readUInt8(0); // 讀取buffer數據的前8 bit並轉換爲十進制整數
// 獲取第一個字節的最高位,看是0還是1
const str1 = byte1.toString(2); // 將第一個字節轉換爲二進制的字符串形式
const FIN = str1[0];
// 獲取第一個字節的後四位,讓第一個字節與00001111進行與運算,即可拿到後四位
let opcode = byte1 & 0x0f; //截取第一個字節的後4位,即opcode碼, 等價於 (byte1 & 15)
// 處理第二個字節
const byte2 = buf.readUInt8(1); // 從第一個字節開始讀取8位,即讀取數據幀第二個字節數據
const str2 = byte2.toString(2); // 將第二個字節轉換爲二進制的字符串形式
const MASK = str2[0]; // 獲取第二個字節的第一位,判斷是否有掩碼,客戶端必須要有
let length = parseInt(str2.substring(1), 2); // 獲取第二個字節除第一位掩碼之後的字符串並轉換爲整數
if (length === 126) {
// 說明125<數據長度<65535(16個位能描述的最大值,也就是16個1的時候)
length = buf.readUInt16BE(2); // 就用第三個字節及第四個字節表示數據的長度
idx += 2; // 偏移兩個字節
} else if (length === 127) {
// 說明數據長度已經大於65535,16個位也已經不足以描述數據長度了,就用第三到第十個字節這八個字節來描述數據長度
const highBits = buf.readUInt32BE(2); // 從第二個字節開始讀取32位,即4個字節,表示後8個字節(64位)用於表示數據長度,其中高4字節是0
if (highBits != 0) {
// 前四個字節必須爲0,否則數據異常,需要關閉連接
this.close(1009, ''); //1009 關閉代碼,說明數據太大;協議裏是支持 63 位長度,不過這裏我們自己實現的話,只支持 32 位長度,防止數據過大;
}
length = buf.readUInt32BE(6); // 獲取八個字節中的後四個字節用於表示數據長度,即從第6到第10個字節,爲真實存放的數據長度
idx += 8;
}
let realData = null; // 保存真實數據對應字符串形式
if (MASK) {
// 如果存在MASK掩碼,表示是客戶端發送過來的數據,是加密過的數據,需要進行數據解碼
const maskDataBuffer = buf.slice(idx, idx + 4); //獲取掩碼數據, 其中前四個字節爲掩碼數據
idx += 4; //指針前移到真實數據段
const realDataBuffer = buf.slice(idx, idx + length); // 獲取真實數據對應的Buffer
realData = handleMask(maskDataBuffer, realDataBuffer); //解碼真實數據
console.log(`realData is ${realData}`);
}
let realDataBuffer = Buffer.from(realData); // 將真實數據轉換爲Buffer
this.buffer = buf.slice(idx + length); // 清除已處理的buffer數據
if (FIN) {
// 如果第一個字節的第一位爲1,表示是消息的最後一個分片,即全部消息結束了(發送的數據比較少,一次發送完成)
this.handleRealData(opcode, realDataBuffer); // 處理操作碼
}
}
如果FIN不爲0,那麼意味着分片結束,可以解析Buffer。
處理mask掩碼(客戶端發過來的是1,服務端發的是0)得到真正到數據
function handleMask(maskBytes, data) {
const payload = Buffer.alloc(data.length);
for (let i = 0; i < data.length; i++) {
// 遍歷真實數據
payload[i] = maskBytes[i % 4] ^ data[i]; // 掩碼有4個字節依次與真實數據進行異或運算即可
}
return payload;
}
根據opcode(接受到的數據是字符串還是Buffer)進行處理:
const OPCODES = {
CONTINUE: 0,
TEXT: 1,
BINARY: 2,
CLOSE: 8,
PING: 9,
PONG: 10,
};
// 處理客戶端發送過來的真實數據
handleRealData(opcode, realDataBuffer) {
switch (opcode) {
case OPCODES.TEXT:
this.emit('data', realDataBuffer.toString('utf8')); // 服務端WebSocket監聽data事件即可拿到數據
break;
case OPCODES.BINARY: //二進制文件直接交付
this.emit('data', realDataBuffer);
break;
default:
this.close(1002, 'unhandle opcode:' + opcode);
}
}
如果是Buffer就轉換爲utf8的字符串(如果是protobuffer協議,那麼還要根據pb文件進行解析)
接受數據已經搞定,傳輸數據無非兩種,字符串和二進制,那麼發送也是。
下面把發送搞定
send(data) {
let opcode;
let buffer;
if (Buffer.isBuffer(data)) {
// 如果是二進制數據
opcode = OPCODES.BINARY; // 操作碼設置爲二進制類型
buffer = data;
} else if (typeof data === 'string') {
// 如果是字符串
opcode = OPCODES.TEXT; // 操作碼設置爲文本類型
buffer = Buffer.from(data, 'utf8'); // 將字符串轉換爲Buffer數據
} else {
throw new Error('cannot send object.Must be string of Buffer');
}
this.doSend(opcode, buffer);
}
// 開始發送數據
doSend(opcode, buffer) {
this.socket.write(encodeMessage(opcode, buffer)); //編碼後直接通過socket發送
}
首先把要發送的數據都轉換成二進制,然後進行數據幀格式拼裝
function encodeMessage(opcode, payload) {
let buf;
// 0x80 二進制爲 10000000 | opcode 進行或運算就相當於是將首位置爲1
let b1 = 0x80 | opcode; // 如果沒有數據了將FIN置爲1
let b2; // 存放數據長度
let length = payload.length;
console.log(`encodeMessage: length is ${length}`);
if (length < 126) {
buf = Buffer.alloc(payload.length + 2 + 0); // 服務器返回的數據不需要加密,直接加2個字節即可
b2 = length; // MASK爲0,直接賦值爲length值即可
buf.writeUInt8(b1, 0); //從第0個字節開始寫入8位,即將b1寫入到第一個字節中
buf.writeUInt8(b2, 1); //讀8―15bit,將字節長度寫入到第二個字節中
payload.copy(buf, 2); //複製數據,從2(第三)字節開始,將數據插入到第二個字節後面
}
return buf;
}
服務端發送的數據,Mask的值爲0
此時在外面監聽事件,像平時一樣使用ws協議一樣即可。
const MyWebSocket = require('./ws');
const ws = new MyWebSocket({ port: 8080 });
ws.on('data', (data) => {
console.log('receive data:' + data);
ws.send('this message from server');
});
ws.on('close', (code, reason) => {
console.log('close:', code, reason);
});
本文倉庫地址源碼:
https://github.com/JinJieTan/my-websocket
歷史的文章源碼:
手寫mini-react: https://github.com/JinJieTan/mini-react
手寫mini-webpack: https://github.com/JinJieTan/react-webpack
手寫靜態資源服務器 : https://github.com/JinJieTan/util-static-server
手寫微前端框架、vue .....