我們知道,http 是一問一答的模式,客戶端向伺服器發送 http 請求,伺服器回傳 http 回應。
這種模式對資源、資料的載入夠用,但是需要資料推送的場景就不合適了。
有同學說,http2 不是有 server push 麼?
那隻是推資源用的:
例如瀏覽器請求了 html,服務端可以連帶把 css 一起推送給瀏覽器。瀏覽器可以決定接不接收。 【相關教學推薦:nodejs影片教學、程式設計教學】
#對於即時通訊等即時性要求高的場景,就需要用 websocket 了。
websocket 嚴格來說和 http 沒什麼關係,是另一種協定格式。但是需要一次從 http 到 websocekt 的切換過程。
切換過程詳細來說是這樣的:
請求的時候帶上這幾個header:
Connection: Upgrade Upgrade: websocket Sec-WebSocket-Key: Ia3dQjfWrAug/6qm7mTZOg==
前兩個很容易理解,就是升級到websocket 協定的意思。
第三個 header 是保證安全用的一個 key。
服務端回傳這樣的 header:
HTTP/1.1 101 Switching Protocols Connection: Upgrade Upgrade: websocket Sec-WebSocket-Accept: JkE58n3uIigYDMvC+KsBbGZsp1A=
和請求 header 類似,Sec-WebSocket-Accept 是對請求帶過來的 Sec-WebSocket-Key 處理之後的結果。
加入這個 header 的校驗是為了確定對方一定是有 WebSocket 能力的,不然萬一建立了連接對方卻一直沒消息,那不就白等了麼。
那 Sec-WebSocket-Key 經過什麼處理能得到 Sec-WebSocket-Accept 呢?
我用node 實作了一下,是這樣的:
const crypto = require('crypto'); function hashKey(key) { const sha1 = crypto.createHash('sha1'); sha1.update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'); return sha1.digest('base64'); }
也就是用客戶端傳過來的key,加上一個固定的字串,經過sha1 加密之後,轉成base64的結果。
這個字串258EAFA5-E914-47DA-95CA-C5AB0DC85B11 是固定的,不信你搜搜看:
#隨便找個有websocket 的網站,例如知乎就有:
過濾出ws 類型的請求,看看這幾個header,是不是就是前面說的那些。
這個Sec-WebSocket-Key 是wk60yiym2FEwCAMVZE3FgQ==
而回應的Sec-WebSocket-Accept 是XRfPnS 8xl11QWZherej/dkHPHM=
##我們算算看:#是不是一毛一樣! 這就是 websocket 升級協定時候的 Sec-WebSocket-Key 對應的 Sec-WebSocket-Accept 的計算過程。 這一步之後就換到websocket 的協定了,那是一個全新的協議:勾選message 這一欄可以看到傳輸的訊息,可以是文字、可以是二進制:全新的協定?那具體是什麼樣的協議呢? 這樣的:大家習慣的http 協定是key:value 的header 帶個body 的:它是文字協議,每個header 都是容易理解的字元。 這樣好懂是好懂,但是傳輸佔的空間太大了。 而websocket 是二進位協議,一個位元組可以用來儲存很多資訊:例如協議的第一個位元組,就儲存了FIN (結束標誌)、opcode(內容類型是binary 還是text) 等資訊。 第二個位元組儲存了 mask(是否有加密),payload(資料長度)。 僅僅兩個字節,儲存了多少資訊呀! 這就是二進位協定比文字協定好的地方。 我們看到的 weboscket 的 message 的收發,其實底層都是拼成這樣的格式。 只是瀏覽器幫我們解析了這種格式的協定資料。这就是 weboscket 的全部流程了。
其实还是挺清晰的,一个切换协议的过程,然后是二进制的 weboscket 协议的收发。
那我们就用 Node.js 自己实现一个 websocket 服务器吧!
定义个 MyWebsocket 的 class:
const { EventEmitter } = require('events'); const http = require('http'); class MyWebsocket extends EventEmitter { constructor(options) { super(options); const server = http.createServer(); server.listen(options.port || 8080); server.on('upgrade', (req, socket) => { }); } }
继承 EventEmitter 是为了可以用 emit 发送一些事件,外界可以通过 on 监听这个事件来处理。
我们在构造函数里创建了一个 http 服务,当 ungrade 事件发生,也就是收到了 Connection: upgrade 的 header 的时候,返回切换协议的 header。
返回的 header 前面已经见过了,就是要对 sec-websocket-key 做下处理。
server.on('upgrade', (req, socket) => { this.socket = socket; socket.setKeepAlive(true); const resHeaders = [ 'HTTP/1.1 101 Switching Protocols', 'Upgrade: websocket', 'Connection: Upgrade', 'Sec-WebSocket-Accept: ' + hashKey(req.headers['sec-websocket-key']), '', '' ].join('\r\n'); socket.write(resHeaders); socket.on('data', (data) => { console.log(data) }); socket.on('close', (error) => { this.emit('close'); }); });
我们拿到 socket,返回上面的 header,其中 key 做的处理就是前面聊过的算法:
function hashKey(key) { const sha1 = crypto.createHash('sha1'); sha1.update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'); return sha1.digest('base64'); }
就这么简单,就已经完成协议切换了。
不信我们试试看。
引入我们实现的 ws 服务器,跑起来:
const MyWebSocket = require('./ws'); const ws = new MyWebSocket({ port: 8080 }); ws.on('data', (data) => { console.log('receive data:' + data); }); ws.on('close', (code, reason) => { console.log('close:', code, reason); });
然后新建这样一个 html:
<!DOCTYPE HTML> <html> <body> <script> const ws = new WebSocket("ws://localhost:8080"); ws.onopen = function () { ws.send("发送数据"); setTimeout(() => { ws.send("发送数据2"); }, 3000) }; ws.onmessage = function (evt) { console.log(evt) }; ws.onclose = function () { }; </script> </body> </html>
用浏览器的 WebSocket api 建立连接,发送消息。
用 npx http-server . 起个静态服务。
然后浏览器访问这个 html:
这时打开 devtools 你就会发现协议切换成功了:
这 3 个 header 还有 101 状态码都是我们返回的。
message 里也可以看到发送的消息:
再去服务端看看,也收到了这个消息:
只不过是 Buffer 的,也就是二进制的。
接下来只要按照协议格式解析这个 Buffer,并且生成响应格式的协议数据 Buffer 返回就可以收发 websocket 数据了。
这一部分还是比较麻烦的,我们一点点来看。
我们需要第一个字节的后四位,也就是 opcode。
这样写:
const byte1 = bufferData.readUInt8(0); let opcode = byte1 & 0x0f;
读取 8 位无符号整数的内容,也就是一个字节的内容。参数是偏移的字节,这里是 0。
通过位运算取出后四位,这就是 opcode 了。
然后再处理第二个字节:
第一位是 mask 标志位,后 7 位是 payload 长度。
可以这样取:
const byte2 = bufferData.readUInt8(1); const str2 = byte2.toString(2); const MASK = str2[0]; let payloadLength = parseInt(str2.substring(1), 2);
还是用 buffer.readUInt8 读取一个字节的内容。
先转成二进制字符串,这时第一位就是 mask,然后再截取后 7 位的子串,parseInt 成数字,这就是 payload 长度了。
这样前两个字节的协议内容就解析完了。
有同学可能问了,后面咋还有俩 payload 长度呢?
这是因为数据不一定有多长,可能需要 16 位存长度,可能需要 32 位。
于是 websocket 协议就规定了如果那个 7 位的内容不超过 125,那它就是 payload 长度。
如果 7 位的内容是 126,那就不用它了,用后面的 16 位的内容作为 payload 长度。
如果 7 位的内容是 127,也不用它了,用后面那个 64 位的内容作为 payload 长度。
其实还是容易理解的,就是 3 个 if else。
用代码写出来就是这样的:
let payloadLength = parseInt(str2.substring(1), 2); let curByteIndex = 2; if (payloadLength === 126) { payloadLength = bufferData.readUInt16BE(2); curByteIndex += 2; } else if (payloadLength === 127) { payloadLength = bufferData.readBigUInt64BE(2); curByteIndex += 8; }
这里的 curByteIndex 是存储当前处理到第几个字节的。
如果是 126,那就从第 3 个字节开始,读取 2 个字节也就是 16 位的长度,用 buffer.readUInt16BE 方法。
如果是 127,那就从第 3 个字节开始,读取 8 个字节也就是 64 位的长度,用 buffer.readBigUInt64BE 方法。
这样就拿到了 payload 的长度,然后再用这个长度去截取内容就好了。
但在读取数据之前,还有个 mask 要处理,这个是用来给内容解密的:
读 4 个字节,就是 mask key。
再后面的就可以根据 payload 长度读出来。
let realData = null; if (MASK) { const maskKey = bufferData.slice(curByteIndex, curByteIndex + 4); curByteIndex += 4; const payloadData = bufferData.slice(curByteIndex, curByteIndex + payloadLength); realData = handleMask(maskKey, payloadData); } else { realData = bufferData.slice(curByteIndex, curByteIndex + payloadLength);; }
然后用 mask key 来解密数据。
这个算法也是固定的,用每个字节的 mask key 和数据的每一位做按位异或就好了:
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]; } return payload; }
这样,我们就拿到了最终的数据!
但是传给处理程序之前,还要根据类型来处理下,因为内容分几种类型,也就是 opcode 有几种值:
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')); break; case OPCODES.BINARY: this.emit('data', realDataBuffer); break; default: this.emit('close'); break; } }
文本就转成 utf-8 的字符串,二进制数据就直接用 buffer 的数据。
这样,处理程序里就能拿到解析后的数据。
我们来试一下:
之前我们已经能拿到 weboscket 协议内容的 buffer 了:
而现在我们能正确解析出其中的数据:
至此,我们 websocket 协议的解析成功了!
这样的协议格式的数据叫做 frame,也就是帧:
解析可以了,接下来我们再实现数据的发送。
发送也是构造一样的 frame 格式。
定义这样一个 send 方法:
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'); } else { console.error('暂不支持发送的数据类型') } this.doSend(opcode, buffer); } doSend(opcode, bufferDatafer) { this.socket.write(encodeMessage(opcode, bufferDatafer)); }
根据发送的是文本还是二进制数据来对内容作处理。
然后构造 websocket 的 frame:
function encodeMessage(opcode, payload) { //payload.length < 126 let bufferData = Buffer.alloc(payload.length + 2 + 0);; let byte1 = parseInt('10000000', 2) | opcode; // 设置 FIN 为 1 let byte2 = payload.length; bufferData.writeUInt8(byte1, 0); bufferData.writeUInt8(byte2, 1); payload.copy(bufferData, 2); return bufferData; }
我们只处理数据长度小于 125 的情况。
第一个字节是 opcode,我们把第一位置 1 ,通过按位或的方式。
服务端给客户端回消息不需要 mask,所以第二个字节就是 payload 长度。
分别把这前两个字节的数据写到 buffer 里,指定不同的 offset:
bufferData.writeUInt8(byte1, 0); bufferData.writeUInt8(byte2, 1);
之后把 payload 数据放在后面:
payload.copy(bufferData, 2);
这样一个 websocket 的 frame 就构造完了。
我们试一下:
收到客户端消息后,每两秒回一个消息。
收发消息都成功了!
就这样,我们自己实现了一个 websocket 服务器,实现了 websocket 协议的解析和生成!
完整代码如下:
MyWebSocket:
//ws.js const { EventEmitter } = require('events'); const http = require('http'); const crypto = require('crypto'); function hashKey(key) { const sha1 = crypto.createHash('sha1'); sha1.update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'); return sha1.digest('base64'); } 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]; } return payload; } const OPCODES = { CONTINUE: 0, TEXT: 1, BINARY: 2, CLOSE: 8, PING: 9, PONG: 10, }; function encodeMessage(opcode, payload) { //payload.length < 126 let bufferData = Buffer.alloc(payload.length + 2 + 0);; let byte1 = parseInt('10000000', 2) | opcode; // 设置 FIN 为 1 let byte2 = payload.length; bufferData.writeUInt8(byte1, 0); bufferData.writeUInt8(byte2, 1); payload.copy(bufferData, 2); return bufferData; } class MyWebsocket extends EventEmitter { constructor(options) { super(options); const server = http.createServer(); server.listen(options.port || 8080); server.on('upgrade', (req, socket) => { this.socket = socket; socket.setKeepAlive(true); const resHeaders = [ 'HTTP/1.1 101 Switching Protocols', 'Upgrade: websocket', 'Connection: Upgrade', 'Sec-WebSocket-Accept: ' + hashKey(req.headers['sec-websocket-key']), '', '' ].join('\r\n'); socket.write(resHeaders); socket.on('data', (data) => { this.processData(data); // console.log(data); }); socket.on('close', (error) => { this.emit('close'); }); }); } handleRealData(opcode, realDataBuffer) { switch (opcode) { case OPCODES.TEXT: this.emit('data', realDataBuffer.toString('utf8')); break; case OPCODES.BINARY: this.emit('data', realDataBuffer); break; default: this.emit('close'); break; } } processData(bufferData) { const byte1 = bufferData.readUInt8(0); let opcode = byte1 & 0x0f; const byte2 = bufferData.readUInt8(1); const str2 = byte2.toString(2); const MASK = str2[0]; let curByteIndex = 2; let payloadLength = parseInt(str2.substring(1), 2); if (payloadLength === 126) { payloadLength = bufferData.readUInt16BE(2); curByteIndex += 2; } else if (payloadLength === 127) { payloadLength = bufferData.readBigUInt64BE(2); curByteIndex += 8; } let realData = null; if (MASK) { const maskKey = bufferData.slice(curByteIndex, curByteIndex + 4); curByteIndex += 4; const payloadData = bufferData.slice(curByteIndex, curByteIndex + payloadLength); realData = handleMask(maskKey, payloadData); } this.handleRealData(opcode, realData); } 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'); } else { console.error('暂不支持发送的数据类型') } this.doSend(opcode, buffer); } doSend(opcode, bufferDatafer) { this.socket.write(encodeMessage(opcode, bufferDatafer)); } } module.exports = MyWebsocket;
Index:
const MyWebSocket = require('./ws'); const ws = new MyWebSocket({ port: 8080 }); ws.on('data', (data) => { console.log('receive data:' + data); setInterval(() => { ws.send(data + ' ' + Date.now()); }, 2000) }); ws.on('close', (code, reason) => { console.log('close:', code, reason); });
html:
<!DOCTYPE HTML> <html> <body> <script> const ws = new WebSocket("ws://localhost:8080"); ws.onopen = function () { ws.send("发送数据"); setTimeout(() => { ws.send("发送数据2"); }, 3000) }; ws.onmessage = function (evt) { console.log(evt) }; ws.onclose = function () { }; </script> </body> </html>
实时性较高的需求,我们会用 websocket 实现,比如即时通讯、游戏等场景。
websocket 和 http 没什么关系,但从 http 到 websocket 需要一次切换的过程。
这个切换过程除了要带 upgrade 的 header 外,还要带 sec-websocket-key,服务端根据这个 key 算出结果,通过 sec-websocket-accept 返回。响应是 101 Switching Protocols 的状态码。
这个计算过程比较固定,就是 key + 固定的字符串 通过 sha1 加密后再 base64 的结果。
加这个机制是为了确保对方一定是 websocket 服务器,而不是随意返回了个 101 状态码。
之后就是 websocket 协议了,这是个二进制协议,我们根据格式完成了 websocket 帧的解析和生成。
这样就是一个完整的 websocket 协议的实现了。
我们自己手写了一个 websocket 服务,有没有感觉对 websocket 的理解更深了呢?
更多node相关知识,请访问:nodejs 教程!
以上是手把手帶你用Node手寫WebSocket協議的詳細內容。更多資訊請關注PHP中文網其他相關文章!