什么是 WebSocket?
以下摘自 MDN:
WebSocket 是一种在客户端与服务器之间保持TCP长连接的网络协议,这样它们就可以随时进行信息交换。
虽然任何客户端或服务器上的应用都可以使用 WebSocket,但原则上还是指浏览器与服务器之间使用。通过 WebSocket,服务器可以直接向客户端发送数据,而无须客户端周期性的请求服务器,以动态更新数据内容。
简单的说,WebSocket 是一个基于 TCP,复用 HTTP 通路的,用于客户端服务器之间的持久连接的轻量协议。
Why WebSocket?
为啥要有 WebSocket?这是第一接触 WebSocket 的人首先会疑惑的。
在项目中,一定会遇到这种情景:我们需要去动态去获取服务器的某些资源。如,抢票的信息,订单的状态,最近做的东西也需要从服务器查询状态。
这时候,在没有 WebSocket 的情况下,我们该怎么做?
我们可以使用一个 GET 请求,有如下两种情况:
1. 如果服务器端有新的数据需要传送,就立即把数据发回给客户端,客户端收到数据后,立即再发送GET请求给服务器。
2. 如果服务器端没有新的数据需要发送,服务器不立即发送回应给服务器,而是把这个请求保持住,等待有新的数据到来时,再来响应这个请求。
那么这个方法的问题有什么副作用呢?
HTTP 作为一个很重的协议,一次请求数据量往往很大(通常有 400 多个字节)。但是很多时候,我们只需要传输极少的数据,这时候使用这种协议真是大材小用了。并且若是服务端的更新速度很慢,使用 HTTP 协议数据的往返时间最短时间也有 2RTT 考虑网络拥塞问题,无疑效果是不尽如人意。
这时候就需要 WebSocket 了。
使用
本文不会过分拘泥于使用,只是简单的举例,具体服务端可以看ws npm,客户端可以看WebSocket MDN。
此处为方便下面的讲解简单写一个例子:
服务端:
1 2 3 4 5 6 7 8 9 10 11 12
| const { Server } = require("ws"); const wsServer = new Server({ port: 8808, });
wsServer.on("connection", (socket) => { console.log("已建立链接"); socket.on("message", (msg) => { console.log("服务器获得text消息:", msg); socket.send("你好,客户端"); }); });
|
客户端:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| <script> window.onload = () =>{ let uploadBtn = document.querySelector("#upload"); let inputArea = document.querySelector("#inputArea");
uploadBtn.addEventListener("click", () => { let socket = new WebSocket("ws://127.0.0.1:8808"); socket.onopen = () => { socket.send(inputArea.value); }
socket.onmessage = (event) => { event.returnValue ? console.log(event.data) : null; } }); } </script> <input type="text" id="inputArea" /> <button id="upload">send message</button>
|
服务端效果图:
客户端效果图:
很简单,就把讲解写入注释了,此处不再赘述。
分析:
我们使用浏览器自带的抓包工具,看看建立一个 ws 链接到底都做了些什么?
我们看一下这个请求头(此处只截取对本文有意义的):
1 2 3
| Sec-WebSocket-Key:f4t2h1c6ShhGdc6awSlX+A== Upgrade:"websocket" Sec-WebSocket-Version:"13"
|
我们可以看到,客户端向服务器发起了一个升级协议的请求,我们的这个请求依然是基于 http 协议发送,其中 Sec-WebSocket-Key 是一个客户端随机生成的字符串,用来验证服务端是否理解 WebSocket 协议。我们后面会去说他的校验方法。
再看一下响应头:
1 2 3 4
| HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: vIXWy9ERVK8O90X/sgRPi+Ofmgc=
|
可以看到,服务器给出了 101 状态码,表示服务器应客户端升级协议的请求。同时服务器返回了一个 Sec-WebSocket-Accept,他是根据 Sec-WebSocket-Key 算出的校验码。如果这个码正确,则通信会建立,如果不正确,为了安全,那么就拒绝喽。
总结一下 Sec-WebSocket-Key/Accept 作用大致归纳如下:
- 避免服务端收到非法的 websocket 连接(比如 http 客户端不小心请求连接 websocket 服务,此时服务端可以直接拒绝连接)
- 确保服务端理解 websocket 连接。因为 ws 握手阶段采用的是 http 协议,因此可能 ws 连接是被一个 http 服务器处理并返回的,此时客户端可以通过 Sec-WebSocket-Key 来确保服务端认识 ws 协议。(并非百分百保险,比如总是存在那么些无聊的 http 服务器,光处理 Sec-WebSocket-Key,但并没有实现 ws 协议。。。)
- 用浏览器里发起 ajax 请求,设置 header 时,Sec-WebSocket-Key 以及其他相关的 header 是被禁止的。这样可以避免客户端发送 ajax 请求时,意外请求协议升级(websocket upgrade)
- 可以防止反向代理(不理解 ws 协议)返回错误的数据。比如反向代理前后收到两次 ws 连接的升级请求,反向代理把第一次请求的返回给 cache 住,然后第二次请求到来时直接把 cache 住的请求给返回(无意义的返回)。
- Sec-WebSocket-Key 主要目的并不是确保数据的安全性,因为 Sec-WebSocket-Key、Sec-WebSocket-Accept 的转换计算公式是公开的,而且非常简单,最主要的作用是预防一些常见的意外情况(非故意的)。
既然这个东西这么重要,我们就看看它到底是怎么算的吧:
Sec-WebSocket-Accept 的计算
其实他的计算方法非常的简单,由于只是启到校验的作用,也用不到过于繁琐的加密手段。
步骤如下:
- 获取 Sec-WebSocket-Key
- 将 Sec-WebSocket-Key 的值和’258EAFA5-E914-47DA-95CA-C5AB0DC85B11’, 拼接
- 通过 SHA1 算法获取摘要,并转为 base64 字符串
接下来看一下 ws 源码的处理:
1 2 3
| const digest = createHash("sha1") .update(key + GUID) .digest("base64");
|
试验结果:
没啥问题,我们继续。
WebSocket 数据帧格式
先上图:
这是 RFC 文档定义的帧格式。
我们分析一下:
FIN:用来标记当前数据帧是不是最后一个数据帧
RSV1, RSV2, RSV3:占位,用做扩展用途,默认置 0
Opcode:用来描述要传递的数据作用
0x0 denotes a continuation frame 标示当前数据帧为分片的数据帧,也就是当一个消息需要分成多个数据帧来传送的时候,需要将 opcode 设置位 0x0。
0x1 denotes a text frame 标示当前数据帧传递的内容是文本
0x2 denotes a binary frame 标示当前数据帧传递的是二进制内容,不要转换成字符串
0x8 denotes a connection close 标示请求关闭连接
0x9 denotes a ping 标示 Ping 请求
0xA denotes a pong 标示 Pong 数据包,当收到 Ping 请求时自动给回一个 Pong
目前协议中就规定了这么多,0x30x7 以及 0xB0xF 都是预留作为其它用途的。
MASK:一个字节位,标识数据有没有使用掩码,服务端发送给客户端的数据帧不能使用掩码,客户端发送给服务端的数据帧必须使用掩码。
payload len:
如果数据的长度小于 125 个字节(注意:是字节)则用默认的 7 个 bit 来标示数据的长度。
如果数据的长度为 126 个字节,则用后面相邻的 2 个字节来保存一个 16bit 位的无符号整数作为数据的长度。
如果数据的长度大于 126 个字节,则用后面相邻的 8 个字节来保存一个 64bit 位的无符号整数作为数据的长度。
Masking-key:数据掩码,如果 MASK 设置位 0,则该部分可以省略
Payload data:帧真正要发送的数据
一旦 WebSocket 客户端、服务端建立连接后,后续的操作都是基于数据帧的传递。
我们可以封装一个数据帧
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| function encodeDataFrame(e) { var s = [], o = new Buffer(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, (l & 0xff000000) >> 24, (l & 0xff0000) >> 16, (l & 0xff00) >> 8, l & 0xff ); return Buffer.concat([new Buffer(s), o]); }
|
封装一个简单的 WebSocket 服务器
测试驱动开发,先编写测试:
1 2 3 4 5 6 7 8 9 10
| const ws = new WebSocket({ port: 8000, });
ws.on("connection", (socket) => { socket.on("message", (msg) => { console.log(msg); socket.send(msg); }); });
|
几个工具方法:
除了上面的封装数据帧方法外还有以下几个方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| const { createHash } = require("crypto"); const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
function getDigest(key) { return createHash("sha1") .update(key + GUID) .digest("base64"); }
function unmask(buffer, mask) { const length = buffer.length; for (let i = 0; i < length; i++) { buffer[i] ^= mask[i & 3]; } }
function parseHttpHeader(header) { let headerMap = {}; let headerArray = header.split("\r\n"); headerArray = headerArray.slice(1, -2); for (let item of headerArray) { const [k, v] = item.split(": "); headerMap[k] = v; } return headerMap; }
exports.getDigest = getDigest; exports.unmask = unmask; exports.parseHttpHeader = parseHttpHeader;
|
搭建 WebSocket 类的基本骨架:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| const EventEmitter = require("events"); const net = require("net"); const { parseHttpHeader, getDigest } = require("./utils"); const { unmask } = require("ws/lib/buffer-util");
class WebSocket extends EventEmitter { constructor(options) { super(options); this.options = options; this.server = net.createServer(this.listener); this.server.listen(options.port || 8808); }
listener = (socket) => { socket.setKeepAlive = true; socket.send = (payloadData) => { };
socket.on("data", (chunk) => { if (chunk.toString().match(/Upgrade: websocket/)) { this.toUpgradeProtcol(socket, chunk.toString()); } else { this.toHandleMessage(socket, chunk); } }); this.emit("connection", socket); }; }
|
实现 send 方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| socket.send = (payloadData) => { let opcode; if (Buffer.isBuffer(payloadData)) { opcode = 1; } else { opcode = 2; payloadData = Buffer.from(payloadData); } const payloadLength = payloadData.length; const buffer = Buffer.alloc(payloadLength + 2); buffer[0] = 0b10000000 | opcode; buffer[1] = 0b00000000 | payloadLength; payloadData.copy(buffer, 2); socket.write(buffer); };
|
这里需要注意,数据包的问题。客户端给服务器端发送数据不需要使用掩码,所以只需要使用 2 字节头即可。此处仅仅实现了当数据长度小于 126 的时候的数据格式。
接下来是升级协议的处理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| toUpgradeProtcol = (socket, chunk) => { const headerMap = parseHttpHeader(chunk); const SecWebSocketKey = headerMap["Sec-WebSocket-Key"]; const swa = getDigest(SecWebSocketKey); if (headerMap["Upgrade"] === "websocket") { const resp = [ "HTTP/1.1 101 Switching Protocols", "Upgrade: websocket", "Connection: Upgrade", `Sec-WebSocket-Accept: ${swa}`, "mark: kilic", "\r\n", ].join("\r\n"); socket.write(resp); } };
|
没啥好说的,就是简单的响应了协议升级请求。
然后是普通请求的处理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| toHandleMessage = (socket, chunk) => { const FIN = (chunk[0] & 0b10000000) === 0b10000000; const opcode = chunk[0] & 0b00001111; const masked = (chunk[1] & 0b10000000) === 0b10000000; const payloadLength = chunk[1] & 0b01111111; let payloadData; if (masked) { const maskingKey = chunk.slice(2, 6); payloadData = chunk.slice(6, 6 + payloadLength); unmask(payloadData, maskingKey); } else { payloadData = chunk.slice(6, 6 + payloadLength); } if (FIN) { switch (opcode) { case 1: socket.emit("message", payloadData.toString("utf8")); break; case 2: socket.emit("message", payloadData); break; default: break; } } };
|
这样基本就可以使用了node websocketTest.js
服务端:
客户端:
以上!