如何用 Node.js 实现一个简单的 Websocket 服务?
▐ 知识储备
熟练掌握 Websocket 的协议,这个需要多读现有的解读类文章;(下面会给出参考文章) 操作二进制数据流,在 Node.js 中需要对 Buffer 这个类稍微熟悉些。
网络编程中使用 大端次序(Big endian)表示大于一字节的数据,称之为 网络字节序 (不晓得大小端的,推荐阅读 什么是大小端?) 了解最高有效位(MSB, Most Significant Bit),不太清楚的,可以参考 LSB最低有效位和MSB最高有效位
学习WebSocket协议—从顶层到底层的实现原理(修订版):作者本身自己就用 Node.js 实现过一遍,知识点讲解挺透彻的,适合前端同学优先阅读 WebSocket详解(一):初步认识WebSocket技术:是一系列的文章,从浅入深,配有丰富的图文 WebSocket:5分钟从入门到精通:全文以 Q&A 的方式组织而成,协议的要点都解读到了,除此之外还很全面, 涉及了WebSocket如何建立连接、交换数据的细节、数据帧的格式以及网络安全等。 MDN - Writing WebSocket servers:MDN 官方教程,读一遍没啥坏处。
nodejs 实现:简化版本的从这儿借鉴过来的 学习WebSocket协议—从顶层到底层的实现原理(修订版) WebSocket协议解析:虽然是 C++ 写的,但不影响代码逻辑的理解
截图来自规范Base Framing Protocol
实战
实现代码放在自己的 demos 仓库的 micro-ws 的目录 了,git clone 后本地运行,执行
node index.js
将会在 http://127.0.0.1:3000 创建服务。运行服务之后,打开控制台就能看到效果:
动图中浏览器 console 所执行的 js 代码步骤如下:
先建立连接:
var ws = new WebSocket("ws://127.0.0.1:3000");
ws.onmessage = function(evt) {
console.log( "Received Message: " + evt.data);
};
ws.send('hello world');
▐ 调用所写的 Websocket 类
在 Node.js 中我们通过 http.createServer 获取 http.server 实例,然后监听 upgrade 事件,在处理这个事件:
// HTTP服务器部分
var server = http.createServer(function(req, res) {
res.end('websocket test\r\n');
});
// Upgrade请求处理
server.on('upgrade', function(req, socket, upgradeHead){
// 初始化 ws
var ws = new WebSocket(req, socket, upgradeHead);
// ... ws 监听 data、error 的逻辑等
});
我们就利用这个 socket 对象上进行 Websocket 类实例的初始化工作;
▐ 构造函数
class WebSocket extends EventEmitter {
constructor(req, socket, upgradeHead){
super(); // 调用 EventEmitter 构造函数
// 1. 构造响应头 resHeaders 部分
// 2. 监听 socket 的 data 事件,以及 error 事件
// 3. 初始化成员属性
}
}
注意,我们需要继承内置的 EventEmitter ,这样生成的实例才能监听、绑定事件;
Node.js 采用事件驱动、异步编程,天生就是为了网络服务而设计的,继承 EventEmitter 就能享受到非阻塞模式的 IO 处理;
讲一下其中 响应头的构造 和 事件监听 部分。
返回响应头(Response Header)
根据协议规范,我们能写出响应头的内容:
将 Sec-WebSocket-Key 跟 258EAFA5-E914-47DA-95CA-C5AB0DC85B11拼接。
通过 SHA1 计算出摘要,并转成 base64 字符串。
具体代码如下:
var resKey = hashWebSocketKey(req.headers['sec-websocket-key']);
// 构造响应头
var resHeaders = [
'HTTP/1.1 101 Switching Protocols',
'Upgrade: websocket',
'Connection: Upgrade',
'Sec-WebSocket-Accept: ' + resKey
]
.concat('', '')
.join('\r\n');
socket.write(resHeaders);
监听事件
还有其他事件,比如 error、end 等,详细参考 net.Socket 文档
socket.on('data', data => {
this.buffer = Buffer.concat([this.buffer, data]);
while (this._processBuffer()) {} // 循环处理返回的 data 数据
});
socket.on('close', had_error => {
if (!this.closed) {
this.emit('close', 1006);
this.closed = true;
}
});
注意该方法是放在 while 循环语句里,处理好边界情况,防止死循环。
▐ Frame 帧数据的处理
这 this._processBuffer() 部分代码逻辑就是用来解析帧数据的,所以它是实现 Websocket 代码的关键;(该方法里面用到了大量的位操作符以及 Buffer 类的操作)
帧数据结构详细定义可参考 RFC6455 5.2节,上面罗列的参考文章都有详细的解读,我在这儿也不啰嗦讲细节了,直接看代码比听我用文字讲要好。
这里就其中两个细节需要铺垫一下,方便更好地理解代码。
操作码(Opcode)
根据 Opcode 我们可以大致将数据帧分成两大类:数据帧 和 控制帧。
数据帧:目前只有 3 种,对应的 opcode 是:
0x0:数据延续帧
0x1:utf-8文本
0x2:二进制数据;
0x3 - 0x7:目前保留,用于后续定义的非控制帧。
控制帧:除了上述 3 种数据帧之外,剩下的都是控制帧
0x8:表示连接断开
0x9:表示 ping 操作
0xA:表示 pong 操作
0xB - 0xF:目前保留,用于后续定义的控制帧
在代码里,我们会先从帧数据中提取操作码:
var opcode = byte1 & 0x0f; //截取第一个字节的后 4 位,即 opcode 码
然后根据协议获取到真正的数据载荷(data payload),然后将这两部分传给 _handleFrame 方法:
this._handleFrame(opcode, payload); // 处理操作码
该方法会根据不同的 opcode 做出不同的操作:
_handleFrame(opcode, buffer) {
var payload;
switch (opcode) {
case OPCODES.TEXT:
payload = buffer.toString('utf8'); //如果是文本需要转化为utf8的编码
this.emit('data', opcode, payload); //Buffer.toString()默认utf8 这里是故意指示的
break;
case OPCODES.BINARY: //二进制文件直接交付
payload = buffer;
this.emit('data', opcode, payload);
break;
case OPCODES.PING: // 发送 pong 做响应
this._doSend(OPCODES.PONG, buffer);
break;
case OPCODES.PONG: //不做处理
console.log('server receive pong');
break;
case OPCODES.CLOSE: // close有很多关闭码
let code, reason; // 用于获取关闭码和关闭原因
if (buffer.length >= 2) {
code = buffer.readUInt16BE(0);
reason = buffer.toString('utf8', 2);
}
this.close(code, reason);
this.emit('close', code, reason);
break;
default:
this.close(1002, 'unhandle opcode:' + opcode);
}
}
分片(Fragment)
规范文档:5.4 - Fragmentation
一旦 WebSocket 客户端、服务端建立连接后,后续的操作都是基于数据帧的传递。理论上来说,每个帧(Frame)的大小是没有限制的。
对于大块的数据,Websocket 协议建议对数据进行分片(Fragment)操作。分片的意义主要是两方面:
主要目的是允许当消息开始但不必缓冲该消息时发送一个未知大小的消息。如果消息不能被分片,那么端点将不得不缓冲整个消息以便在首字节发生之前统计出它的长度。对于分片,服务器或中间件可以选择一个合适大小的缓冲,当缓冲满时,再写一个片段到网络。 另一方面分片传输也能更高效地利用多路复用提高带宽利用率,一个逻辑通道上的一个大消息独占输出通道是不可取的,因此多路复用需要可以分割消息为更小的分段来更好的共享输出通道。参考文档 I/O多路复用技术(multiplexing)是什么?
WebSocket 协议提供的分片方法,是将原本一个大的帧拆分成数个小的帧。下面是把一个大的Frame分片的图示:
`FIN=1` 表示当前数据帧为消息的最后一个数据帧,此时接收方已经收到完整的消息,可以对消息进行处理。 `FIN=0`,则接收方还需要继续监听接收其余的数据帧。 opcode在数据交换的场景下,表示的是数据的类型。 `0x01` 表示文本,永远是 `utf8` 编码的 `0x02` 表示二进制 而 `0x00` 比较特殊,表示 延续帧(continuation frame),顾名思义,就是完整消息对应的数据帧还没接收完。
代码里,我们需要检测 FIN 的值,如果为 0 说明有分片,需要记录第一个 FIN 为 0 时的 opcode 值,缓存到 this.frameOpcode 属性中,将载荷缓存到 this.frames 属性中:
var FIN = byte1 & 0x80; // 如果为0x80,则标志传输结束,获取高位 bit
// 如果是 0 的话,说明是延续帧,需要保存好 opCode
if (!FIN) {
this.frameOpcode = opcode || this.frameOpcode; // 确保不为 0;
}
//....
// 有可能是分帧,需要拼接数据
this.frames = Buffer.concat([this.frames, payload]); // 保存到 frames 中
if (FIN) {
payload = this.frames.slice(0); // 获取所有拼接完整的数据
opcode = opcode || this.frameOpcode; // 如果是 0 ,则保持获取之前保存的 code
this.frames = Buffer.alloc(0); // 清空 frames
this.frameOpcode = 0; // 清空 opcode
this._handleFrame(opcode, payload); // 处理操作码
}
发送数据帧
这部分操作相当于是上述 _processBuffer 方法的逆向操作,在代码里我们使用 encodeMessage 方法(为了简单起见,我们发送给客户端的数据没有经过掩码处理)将发送的数据分装成数据帧的格式,然后调用 socket.write 方法发送给客户端;
_doSend(opcode, payload) {
// 1. 考虑数据分片
this.socket.write(
encodeMessage(count > 0 ? OPCODES.CONTINUE : opcode, payload)
); //编码后直接通过socket发送
// ...
var len = Buffer.byteLength(payload);
// 分片的距离逻辑
var count = 0;
// 这里可以针对 payload 的长度做分片
while (len > MAX_FRAME_SIZE) {
var framePayload = payload.slice(0, MAX_FRAME_SIZE);
payload = payload.slice(MAX_FRAME_SIZE);
this.socket.write(
encodeMessage(
count > 0 ? OPCODES.CONTINUE : opcode,
framePayload,
false
)
); //编码后直接通过socket发送
count++;
len = Buffer.byteLength(payload);
}
// ...
▐ Q&A
字符串 “258EAFA5-E914-47DA-95CA-C5AB0DC85B11” 怎么来的?
这个标志性字符串是专门标示 Websocket 协议的 UUID;UUID 是长度为 16-byte(128-bit)的ID,一般以形如f81d4fae-7dec-11d0-a765-00a0c91e6bf6的字符串作为 URN(Uniform Resource Name,统一资源名称)UUID 可以移步到 UUID原理 和 RFC 4122 获取更多知识
为啥选择这个字符串?
在规范的第七页已经有明确的说明了:
之所以选用这个 UUID ,主要该 ID 极大不太可能被其他不了解 Websocket 协议的网络终端所使用;
我也不晓得该怎么翻译。。。总之就说这个 ID 就相当于 Websocket 协议的 “身份证号” 了。
Websocket 和 HTTP 什么关系?
既然大家都使用 TCP 协议,那么大家的连接和断开,都要遵循 TCP 协议中的三次握手和四次握手 ,只是在连接之后发送的内容不同,或者是断开的时间不同。
对于 WebSocket 来说,它必须依赖 HTTP 协议进行一次握手 ,握手成功后,数据就直接从 TCP 通道传输,与 HTTP 无关了。
浏览器中 Websocket 会自动分片么?
WebSocket是一个 message based 的协议,它可以自动将数据分片,并且自动将分片的数据组装。
每个 message 可以是一个或多个分片。message 不记录长度,分片才记录长度;
根据协议 websocket 协议中帧长度上限为 2^63 byte(为 8388608 TB),可以认为没有限制,很明显按协议的最大上限来传输数据是不靠谱的。所以在实际使用中 websocket 消息长度限制取决于具体的实现。关于哲方面,找了两篇参考文章:
Websocket需要像TCP Socket那样进行逻辑数据包的分包与合包吗?:WebSocket是一个message-based的协议,它可以自动将数据分片,并且自动将分片的数据组装;; websocket长文本问题?:这里给出了长文本 ws 传输实践总结。
在文章 WebSocket探秘 中,作者就做了一个实验,作者发送 27378 个字节,结果被迫分包了;如果是大数据量,就会被socket自动分包发送。
而经过我本人试验,发现 Chrome 浏览器(版本 68.0.3440.106 - 64bit)会针对 131072(=2^17)bytes 大小进行自动分包。我是通过以下测试代码验证:
var ws = new WebSocket("ws://127.0.0.1:3000");
ws.onmessage = function(evt) {
console.log( "Received Message: " + evt.data);
};
var myArray = new ArrayBuffer(131072 * 2 + 1);
ws.send(myArray);
server detect fragment, sizeof payload: 131072
server detect fragment, sizeof payload: 131072
receive data: 2 262145
Received Message: good job
截图如下:
而以同样的方式去测试一些自己机器上的浏览器:
Firefox(62.0,64bit) safari (11.1.2 - 13605.3.8) IE 11
这些客户端上的 Websocket 几乎没有大小的分片(随着数据量增大,发送会减缓,但并没有发现分片现象)。
▐ 总结
感谢文中所提及的参考文献所给予的帮助,让我实现过程中事半功倍。
之所以能够使用较少的代码实现 Websocket,是因为 Node.js 体系本身了很好的基础,比如其所提供的 EventEmitter 类自带事件循环,http 模块让你直接使用封装好的 socket 对象,我们只要按照 Websocket 协议实现 Frame(帧)的解析和组装即可。
在使用 Node.js 实现一遍 Websocket 协议后,就能较为深刻地理解以下知识点(理解起来一切都是那么自然而然):
Websocket 是一种应用层协议,是为了提供 Web 应用程序和服务端全双工通信而专门制定的; WebSocket 和 HTTP 都是基于 TCP 协议实现的; WebSocket和 HTTP 的唯一关联就是 HTTP 服务器需要发送一个 “Upgrade” 请求,即 101 Switching Protocol 到 HTTP 服务器,然后由服务器进行协议转换。 WebSocket使用 HTTP 来建立连接,但是定义了一系列新的 header 域,这些域在 HTTP 中并不会使用; WebSocket 可以和 HTTP Server 共享同一 port WebSocket 的 数据帧有序 …
本文仅仅是协议的简单实现,对于 Websocket 的其实还有很多事情可以做(比如支持 命名空间、流式 API 等),有兴趣的可以参考业界流行的 Websocket 仓库,去练习锻造一个健壮的 Websocket 工具库轮子:
socketio/socket.io:43.5k star,不多说,业界权威龙头老大。(不过这实际上不是一个 WebSocket 库,而是一个实时 pub/sub 框架。简单地说,Socket.IO 只是包含 WebSocket 功能的一个框架,如果要使用该库作为 server 端的服务,则 client 也必须使用该库,因为它不是标准的 WebSocket 协议,而是基于 WebSocket 再包装的消息通信协议) websockets/ws:9k star,强大易用的 websocket 服务端、客户端实现,还有提供很多强大的特性 uNetworking/uWebSockets:9.5k star,小巧高性能的 websocket实现,C++ 写的,想更多了解 Websocket 的底层实现,该库是不错的案例。 theturtle32/WebSocket-Node:2.3k star,大部分使用 JavaScript,性能关键部分使用 C++ node-gyp 实现的库。其所列的 测试用例 有挺好的参考价值
https://tools.ietf.org/html/rfc6455?spm=ata.13261165.0.0.b404272cuT4Xuz
https://wenku.baidu.com/view/2dc93465844769eae009ed86.html?spm=ata.13261165.0.0.b404272cuT4Xuz
http://nodejs.cn/api/buffer.html?spm=ata.13261165.0.0.b404272cuT4Xuz
https://www.bysocket.com/?spm=ata.13261165.0.0.b404272cuT4Xuz&p=615
https://blog.csdn.net/qq_29350001/article/details/51177404?spm=ata.13261165.0.0.b404272cuT4Xuz
https://github.com/abbshr/abbshr.github.io/issues/22?spm=ata.13261165.0.0.b404272cuT4Xuz
https://github.com/abbshr/abbshr.github.io/issues/22?spm=ata.13261165.0.0.b404272cuT4Xuz
https://segmentfault.com/a/1190000012709475?spm=ata.13261165.0.0.b404272cuT4Xuz
https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_servers?spm=ata.13261165.0.0.b404272cuT4Xuz
https://blog.csdn.net/Newpidian/article/details/50850670?spm=ata.13261165.0.0.b404272cuT4Xuz
https://github.com/abbshr/abbshr.github.io/issues/22?spm=ata.13261165.0.0.b404272cuT4Xuz
https://tools.ietf.org/html/rfc6455?spm=ata.13261165.0.0.b404272cuT4Xuz#section-5.2
http://www.52im.net/thread-332-1-1.html?spm=ata.13261165.0.0.b404272cuT4Xuz
https://github.com/abbshr/RocketEngine/blob/master/lib/wsframe/parser.js?spm=ata.13261165.0.0.b404272cuT4Xuz&file=parser.js
http://gitlab.alibaba-inc.com/lastep/micro-ws?spm=ata.13261165.0.0.b404272cuT4Xuz
http://nodejs.cn/api/http.html?spm=ata.13261165.0.0.b404272cuT4Xuz#http_http_createserver_options_requestlistener
http://nodejs.cn/api/http.html?spm=ata.13261165.0.0.b404272cuT4Xuz#http_event_upgrade
http://nodejs.cn/api/net.html?spm=ata.13261165.0.0.b404272cuT4Xuz#net_class_net_socket
http://nodejs.cn/
http://nodejs.cn/api/net.html?spm=ata.13261165.0.0.b404272cuT4Xuz#net_class_net_socket
http://nodejs.cn/api/buffer.html?spm=ata.13261165.0.0.b404272cuT4Xuz
https://tools.ietf.org/html/rfc6455?spm=ata.13261165.0.0.b404272cuT4Xuz#section-5.2
https://tools.ietf.org/html/rfc6455?spm=ata.13261165.0.0.b404272cuT4Xuz#section-5.4
https://www.zhihu.com/question/28594409?spm=ata.13261165.0.0.b404272cuT4Xuz
https://blog.csdn.net/ScrappyShiyu/article/details/79936897?spm=ata.13261165.0.0.b404272cuT4Xuz
https://tools.ietf.org/html/rfc4122?spm=ata.13261165.0.0.b404272cuT4Xuz
https://www.zhihu.com/question/29916578?spm=ata.13261165.0.0.b404272cuT4Xuz
https://segmentfault.com/q/1010000010620489?spm=ata.13261165.0.0.b404272cuT4Xuz
https://segmentfault.com/a/1190000012319848?spm=ata.13261165.0.0.b404272cuT4Xuz#articleHeader8
https://github.com/socketio/socket.io?spm=ata.13261165.0.0.b404272cuT4Xuz&file=socket.io
https://github.com/websockets/ws?spm=ata.13261165.0.0.b404272cuT4Xuz
https://github.com/uNetworking/uWebSockets?spm=ata.13261165.0.0.b404272cuT4Xuz
http://theturtle32.github.io/WebSocket-Node/test-report/servers/?spm=ata.13261165.0.0.b404272cuT4Xuz
在这里你可以接触到淘系全链路技术,主流框架( weex, rax, react )、搭建体系、源码体系、运营中台、工程套件物料体系、前端智能化等前沿技术,还可以与层层选拔的各路优秀同学共同战斗,共同成长!欢迎资深前端工程师/专家加入我们,一起打造全新一代的电商运营操作系统,支撑拍卖创新业务。
简历投递至📮:muqin.lmq@alibaba-inc.com