目前各大主流浏览器都已经实现 WebSocket
,本文主要是实现 server
端的WebSocket
服务。
完整代码见文章末尾,代码受到了很多优秀开源项目的影响,比如本次就借用了不知道传了第几手并非原创的发布订阅模式。
所以项目已加上了MIT
协议,希望也能对其他人有所帮助。如果看完后觉得能有一点点的帮助,希望能随手点个赞或者star
~
既然要手写WebSocket
,那么首先需要了解它是什么?
一.WebSocket
是什么?
WebSocket
是一个通信协议,由IETF
定义,标准为RFC 6455
。那么我们这时候就去看看它的标准。
RFC 6455
标准链接如下:www.rfc-editor.org/rfc/rfc6455…
首先看全文的摘要,摘要非常精炼,一共四句话。
第一句话说明它是一个双向通信协议。
第二句讲安全,是基于基于源的安全模型。
第三句表明它是基于消息帧,基于 TCP
。所以我们可以导入http
模块,用于创建WebSocket
。
import http from 'node:http'
class WebSocket extends EventEmitter {
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>;
clients: Array<Client>
constructor({ port = 30104 }: { port: number }) {
super()
this.server = http.createServer();
this.server.listen(port);
this.clients = []
}
}
最后一句说明目的,为了解决不使用多个 http 连接,也能实现客户端和服务端的双向通信。
紧接着我们看下目录:
目录也很清晰,从背景,协议概览,握手,挥手等等开始介绍。但我们为了实现一个最基本的WebSocket
通信,不用去看那么全,只需要关注通信的最基本三要素即可:
- 握手
- 数据传递
- 挥手
二.握手(Opening Handshake)
找到握手 Opening Handshake
这一章:www.rfc-editor.org/rfc/rfc6455…
这些是客户端的握手部分,可以后面再看,毕竟主流浏览器都已实现,我们要实现的server
端WebSocket
通信。
server
端的握手部分比client
端更加简单,只有以下几部分:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
而且这几部分全是固定值
翻译下Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
的意思是:
将字符串 258EAFA5-E914-47DA-95CA-C5AB0DC85B11
使用 SHA-1
加密,取 base64-encoded
。
socket.write([
"HTTP/1.1 101 Switching Protocols",
"Upgrade: websocket",
"Connection: Upgrade",
"Sec-WebSocket-Accept: " + crypto.createHash("sha1").update(req.headers['sec-websocket-key'] + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest("base64")
].join("\n") + "\n\n");
其中 "Connection: Upgrade"
,所以
import http from 'node:http'
class WebSocket extends EventEmitter {
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>;
clients: Array<Client>
constructor({ port = 30104 }: { port: number }) {
super()
this.server = http.createServer();
this.server.listen(port);
this.clients = []
this.server.on("upgrade", (req, socket) => this.create(req, socket))
}
}
三.数据传递(Data Framing)
我们跳转到 Data Framing
这一章
可以知道
WebSocket
是通过帧(frames
)来传递数据。- 如果是
client
端向server
通信,必须设置mask
。如果是server
端向client
通信,不能设置mask
。
既然是通过帧来传递,那就必须知道帧的结构
帧的结构文档中都有解释,这里翻译一下:
-
FIN
:1bit
指这是消息的最后一个片段 -
RSV1
,RSV2
,RSV3
:1 bit each
必须为0 -
Opcode
:4 bits
定义传输的数据内容的解释-
%x0表示延续帧
-
%x1表示文本帧
-
%x2表示二进制帧
-
%x3-7保留给更多的非控制帧
-
%x8表示连接关闭
-
%x9表示ping
-
%xA表示pong
-
%xB-F为进一步的控制帧保留
-
-
Mask
:1 bit
上面解释过,如果是server
端向client
端通信,不能设置,即为0。但如果是client
向server
端通信,需要设置为1 -
Payload length
:7 bits, 7+16 bits, or 7+64 bits
内容的大小,分三种情况。0-125表示内容小于7 bits
,126表示内容大于7 bits
但小于16 bits
。如果是127,表示内容是64 bits
。 -
Masking-key
:0 or 4 bytes
没有Mask
自然也没有Masking-key
。如果有的情况下,
将上述标准翻译成代码
const src = Buffer.from(source);
// FIN 只占 1bit,buffer[n]取的是1byte,1byte = 8 bits,所以要和128进行比较,比较第一位
fin: src[0] >= 128,
// opcode 占 4 bits,15等于1111,目的要取后4位,用来判断是结束还是文本还是一些其他信息
opcode: src[0] & 15,
// mask为 1 byte 的第一位
mask: src[1] >= 128
// payloadSize为 1 byte 的后7位,所以要位运算 127 ,取后面7位
payloadSize: src[1] & 127
// metaSize 的大小与 payloadSize 直接相关,多个if else 判断
metaSize
// maskKey 与 mask 和 metaSize 相关
maskKey: masked ? src.subarray(metaSize, metaSize + (masked ? 4 : 0)) : null,
payloadSize,帧大小,metaSize的大小的判断:
if (payloadSize === 127) {
size = src.readUInt32BE(6);
metaSize = 10;
} else if (payloadSize === 126) {
size = src.readUInt16BE(2);
metaSize = 4;
} else {
size = payloadSize;
metaSize = 2;
}
读取元数据,处理帧meta:
const handleFrameMeta = (meta: FrameMeta) => {
const metaLength = meta.metaSize
buf.subarray(0, metaLength)
buf = buf.subarray(metaLength);
return buf
}
成功读取了元数据后,就需要去读取数据的内容,数据的内容就比较简单读取,直接获取帧的大小,从metaSize
到size
的部分就是帧的内容
const handleFrameContent = (meta: FrameMeta) => {
const size = meta.size
const buffer = buf.subarray(0, size)
data = Buffer.concat([data, this.iMask(buffer, meta.maskKey)]);
// 需要判断下是否是最后一帧,这些信息都记录在`meta`中,如果是的话,说明读取完毕,`emit`数据。
if (meta.fin) {
if (meta.opcode === 8) return client.close();
if (meta.opcode === 9) return client.pong();
client.emit("data", data);
data = Buffer.allocUnsafe(0);
}
buf = buf.subarray(size);
}
四.挥手(Closing Handshake)
挥手比握手更加简单,只需要发送或者接收到一个关闭帧即可。比如发送 Opcode
关闭帧。
五.完整代码
代码地址和路径:github.com/chaxus/ran/…