详解WebSocket

什么是 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");//注意,使用的是ws协议
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 作用大致归纳如下:

  1. 避免服务端收到非法的 websocket 连接(比如 http 客户端不小心请求连接 websocket 服务,此时服务端可以直接拒绝连接)
  2. 确保服务端理解 websocket 连接。因为 ws 握手阶段采用的是 http 协议,因此可能 ws 连接是被一个 http 服务器处理并返回的,此时客户端可以通过 Sec-WebSocket-Key 来确保服务端认识 ws 协议。(并非百分百保险,比如总是存在那么些无聊的 http 服务器,光处理 Sec-WebSocket-Key,但并没有实现 ws 协议。。。)
  3. 用浏览器里发起 ajax 请求,设置 header 时,Sec-WebSocket-Key 以及其他相关的 header 是被禁止的。这样可以避免客户端发送 ajax 请求时,意外请求协议升级(websocket upgrade)
  4. 可以防止反向代理(不理解 ws 协议)返回错误的数据。比如反向代理前后收到两次 ws 连接的升级请求,反向代理把第一次请求的返回给 cache 住,然后第二次请求到来时直接把 cache 住的请求给返回(无意义的返回)。
  5. Sec-WebSocket-Key 主要目的并不是确保数据的安全性,因为 Sec-WebSocket-Key、Sec-WebSocket-Accept 的转换计算公式是公开的,而且非常简单,最主要的作用是预防一些常见的意外情况(非故意的)。

既然这个东西这么重要,我们就看看它到底是怎么算的吧:

Sec-WebSocket-Accept 的计算

其实他的计算方法非常的简单,由于只是启到校验的作用,也用不到过于繁琐的加密手段。

步骤如下:

  1. 获取 Sec-WebSocket-Key
  2. 将 Sec-WebSocket-Key 的值和’258EAFA5-E914-47DA-95CA-C5AB0DC85B11’, 拼接
  3. 通过 SHA1 算法获取摘要,并转为 base64 字符串

接下来看一下 ws 源码的处理:

1
2
3
const digest = createHash("sha1")
.update(key + GUID)
.digest("base64");

试验结果:

加密结果

没啥问题,我们继续。

WebSocket 数据帧格式

先上图:

帧格式

这是 RFC 文档定义的帧格式。

我们分析一下:

  1. FIN:用来标记当前数据帧是不是最后一个数据帧

  2. RSV1, RSV2, RSV3:占位,用做扩展用途,默认置 0

  3. 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 都是预留作为其它用途的。

  4. MASK:一个字节位,标识数据有没有使用掩码,服务端发送给客户端的数据帧不能使用掩码,客户端发送给服务端的数据帧必须使用掩码。

  5. payload len:

    如果数据的长度小于 125 个字节(注意:是字节)则用默认的 7 个 bit 来标示数据的长度。

    如果数据的长度为 126 个字节,则用后面相邻的 2 个字节来保存一个 16bit 位的无符号整数作为数据的长度。

    如果数据的长度大于 126 个字节,则用后面相邻的 8 个字节来保存一个 64bit 位的无符号整数作为数据的长度。

  6. Masking-key:数据掩码,如果 MASK 设置位 0,则该部分可以省略

  7. 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, //8字节数据,前4字节一般没用留空
(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);
}

/**
* 服务器的回调
* @param {net.Socket} socket
*/
listener = (socket) => {
socket.setKeepAlive = true;
// 为socket添加send方法,可以用这个方法来发送数据
socket.send = (payloadData) => {
//TODO send方法实现
};

socket.on("data", (chunk) => {
if (chunk.toString().match(/Upgrade: websocket/)) {
// 处理协议升级请求
this.toUpgradeProtcol(socket, chunk.toString()); //TODO 协议升级处理
} else {
// 处理非协议升级请求
this.toHandleMessage(socket, chunk); //TODO 非协议升级处理
}
});
// 触发一个connection事件
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) => {
//TODO send方法实现
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:
//TODO 其他情况略
break;
}
}
};

这样基本就可以使用了node websocketTest.js

服务端:

服务端数据

客户端:

客户端数据

以上!

查看评论