WebSocket硬核入门:200行代码,教你徒手撸一个WebSocket服务器

647 阅读19分钟

1、引言

最近正在研究 WebSocket 相关的知识,想着如何能自己实现 WebSocket 协议。到网上搜罗了一番资料后用 Node.js 实现了一个WebSocket协议服务器,倒也没有想象中那么复杂,除去注释语句和 console 语句后,大约 200 行代码左右。

本文分享了自已开发一个WebSocket服务端实现过程中需要的知识储备,以及具体的代码实现含义等,非常适合想在短时间内对WebSocket协议从入门到精通的Web端即时通讯开发者阅读。

如果你想要写一个WebSocket 服务器,首先需要读懂对应的网络协议 RFC6455,不过这对于一般人来说有些 “晦涩”,英文且不说,还得咬文嚼字理解 网络编程 含义。

好在 WebSocket 技术出现比较早,所以早就有人翻译了完整的 RFC6455中文版,网上也有很多针对该协议的剖析文章,很多文章里还有现成的实现代码可以参考,所以说实现一个简单的 WebSocket 服务并非难事。

本文更偏向实战(in action),会从知识储备、具体代码分析以及注意事项角度去讲解如何用 Node.js 实现一个简单的 WebSocket 服务,至于 WebSocket 概念、定义、解释和用途等基础知识不会涉及,因为这些知识在本文所列的参考文章中轻松找到。

2、关于作者

3、基本常识

在学习本文内容之前,我认为很有必要简单了解一下Web端即时通讯技术的“过去”和“现在”,因为新时代的开发者(没有经历过短轮询、长轮询、Comet技术的这波人),很难理解WebSocket对于Web端的即时通讯技术来说,意味着什么。

所谓“忆苦思甜”,了解了Web端即时通讯技术的过去,方知WebSocket这种技术的珍贵。。。

3.1 旧时代的Web端即时通讯技术

自从Web端即时通讯的概念提出后,“实时”性便成为了Web开发者们津津乐道的话题。实时化的Web应用,凭借其响应迅速、无需刷新、节省网络流量的特性,不仅让开发者们眼前一亮,更是为用户带来绝佳的网络体验。

但很多开发者可能并不清楚,旧时代的Web端“实时”通信,主要基于 Ajax的拉取和Comet的推送

大家都知道Ajax,这是一种借助浏览器端JavaScript实现的异步无刷新请求功能:要客户端按需向服务器发出请求,并异步获取来自服务器的响应,然后按照逻辑更新当前页面的相应内容。

但是这仅仅是拉取啊,这并不是真正的“实时”:缺少服务器端的自动推送!

因此,我们不得不使用另一种略复杂的技术 Comet,只有当这两者配合起来,这个Web应用才勉强算是个“实时”的Web端应用!

![](https://img2020.cnblogs.com/blog/1834368/202010/1834368-20201021142049228-798086126.jpg)

▲ Ajax和Comet技术原理(图片引用自《Web端即时通讯技术盘点》)

3.2 WebSocket协议出现

![](https://img2020.cnblogs.com/blog/1834368/202010/1834368-20201021142057847-250101251.jpg)

随着HTML5标准的出现,WebSocket技术横空出世,随着HTML5标准的广泛普及,越来越多的现代浏览器开始全面支持WebSocket技术了。

至于WebSocket,我想大家或多或少都听说过。

WebSocket是一种全新的协议。它将TCP的Socket(套接字)应用在了web page上,从而使通信双方建立起一个保持在活动状态连接通道,并且属于全双工(双方同时进行双向通信)。

**事实是:**WebSocket协议是借用HTTP协议的

101 switch protocol

来达到协议转换的,从HTTP协议切换成WebSocket通信协议。

再简单点来说,它就好像将 Ajax 和 Comet 技术的特点结合到了一起,只不过性能要高并且使用起来要方便的多(方便当然是之指在客户端方面了)。

4、WebSocket知识储备

如果要自己写一个 WebSocket 服务,主要有两个难点:

  • 1)熟练掌握 WebSocket 的协议,这个需要多读现有的解读类文章(下面会给出参考文章);
  • 2)操作二进制数据流,在 Node.js 中需要对 Buffer 这个类稍微熟悉些。

同时还需要具备两个基础知识点:

具体的做法如下,推荐先阅读以下几篇参考文章:

然后开始写代码。

在实现过程中的大部分代码可以从下面几篇文章中找到并借鉴(copy):

阅读完上面的文章,你会有发现一个共同点,就是在实现 WebSockets 过程中,最最核心的部分就是 解析 或者 生成 Frame(帧)。

就是下面这结构:

![](https://upload-images.jianshu.io/upload_images/1500839-9e371a8e65b5f4b7.png)

▲ 截图来自《rfc6455 - Base Framing Protocol

想要理解 frame 各个字段的含义,可参考《WebSocket详解(三):深入WebSocket通信协议细节》,文中作者绘制了一副图来解释这个 frame 结构。

而在代码层面,frame 的解析或生成可以在 RocketEngine - parser 或者_processBuffer中找到。

在完成上面几个方面的知识储备之后,而且大多有现成的代码,所以自己边抄边写一个 Websocket 服务端实现并不算太难。

对于 WebSocket 初学者,请务必阅读以上参考文章,对 Websocket 协议有大概的了解之后再继续本文剩下部分的阅读,否则很有可能会觉得我写得云里雾里,不知所云。

5、实战效果预览

本次的实现代码可以从文末“11、代码下载”章节下载到:

(请从原文链接下载:www.52im.net/thread-3175…

下载后本地运行即可,执行:

node index.js

运行成功后,将会在 http://127.0.0.1:3000 创建服务。

运行服务之后,打开控制台就能看到效果:

动图中浏览器 console 所执行的 js 代码步骤如下:

1)先建立连接:

var ws = new WebSocket("ws://127.0.0.1:3000");

ws.onmessage = function(evt) {

console.log( "Received Message: "+ evt.data);

};

2)然后发送消息:

(注意一定要在建立连接之后再执行该语句,否则发不出消息的)

ws.send('hello world');

从效果可见,我们已经实现 WebSocket 最基本的通讯功能了。

接下来我们详细看一下具体实现的细节。

6、代码解读1:调用所写的 WebSocket 类

站在使用者的角度,假设我们已经完成 WebSocket 类了,那么应该怎么使用?

客户端通过 HTTP Upgrade 请求,即

101 Switching Protocol

到 HTTP 服务器,然后由服务器进行协议转换。

在 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 的逻辑等

});

这里监听 upgrade 事件的回调函数中第二个参数socket net.Socket实例,这个类是 TCP 或 UNIX Socket 的抽象,同时一个 net.Socket 也是一个 duplex stream,所以它能被读或写,并且它也是一个 EventEmitter

我们就利用这个 socket 对象上进行 Websocket 类实例的初始化工作;

7、代码解读2:构造函数

所以不难理解 Websocket 的构造函数就是下面这个样子:

class WebSocket extends EventEmitter {

constructor(req, socket, upgradeHead){

super(); // 调用 EventEmitter 构造函数

// 1. 构造响应头 resHeaders 部分

// 2. 监听 socket 的 data 事件,以及 error 事件

// 3. 初始化成员属性

}

}

注意:我们需要继承内置的 EventEmitter ,这样生成的实例才能监听、绑定事件。

Node.js 采用事件驱动、异步编程,天生就是为了网络服务而设计的,继承 EventEmitter 就能享受到非阻塞模式的 IO 处理。

这里特别讲一下其中 响应头的构造 和 事件监听 部分。

7.1 返回响应头(Response Header)

根据协议规范,我们能写出响应头的内容:

  • 1)将 Sec-WebSocket-Key 跟 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 拼接;
  • 2)通过 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);

当执行

socket.write(resHeaders);

到后就和客户端建立起 WebSocket 连接了,剩下去就是数据的处理。

7.2 监听事件

socket

就是 TCP 协议的抽象,直接在上面监听已有的

data

事件和

close

事件这两个事件。

还有其他事件,比如 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;

}

});

close的事件逻辑比较简单,比较重要的是data的事件监听部分。核心就是this._processBuffer()

这个方法,用于处理客户端传送过来的数据(即 Frame 数据)。

**注意:**该方法是放在while循环语句里,处理好边界情况,防止死循环。

8、代码解读3:Frame 帧数据的处理

WebSocket 客户端、服务端通信的最小单位是帧(frame),由1个或多个帧组成一条完整的消息(message)。

这 this._processBuffer() 部分代码逻辑就是用来解析帧数据的,所以它是实现 WebSocket 代码的关键;(该方法里面用到了大量的位操作符以及 Buffer 类的操作)

帧数据结构详细定义可参考 RFC6455 5.2节(英文不好的话,去下载中文翻译版《WebSocket标准协议手册(稀缺中文版+英文原版)》),上面罗列的参考文章都有详细的解读,我在这儿也不啰嗦讲细节了,直接看代码比听我用文字讲要好。

这里就其中两个细节需要铺垫一下,方便更好地理解代码。

8.1 操作码(Opcode)

Opcode 即 操作代码,Opcode 的值决定了应该如何解析后续的数据载荷(data payload)

根据 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);

}

}

8.2 分片(Fragment)

本节代码对应的标准文档:5.4 - Fragmentation(英文不好的话,去下载中文翻译版《WebSocket标准协议手册(稀缺中文版+英文原版)》)。

一旦 WebSocket 客户端、服务端建立连接后,后续的操作都是基于数据帧的传递。理论上来说,每个帧(Frame)的大小是没有限制的。

对于大块的数据,WebSocket 协议建议对数据进行分片(Fragment)操作。

分片的意义主要是两方面:

  • 1)主要目的是允许当消息开始但不必缓冲该消息时发送一个未知大小的消息。如果消息不能被分片,那么端点将不得不缓冲整个消息以便在首字节发生之前统计出它的长度。对于分片,服务器或中间件可以选择一个合适大小的缓冲,当缓冲满时,再写一个片段到网络;
  • 2)另一方面分片传输也能更高效地利用多路复用提高带宽利用率,一个逻辑通道上的一个大消息独占输出通道是不可取的,因此多路复用需要可以分割消息为更小的分段来更好的共享输出通道。参考文档《I/O多路复用(multiplexing)是什么?》。

WebSocket 协议提供的分片方法,是将原本一个大的帧拆分成数个小的帧。

下面是把一个大的Frame分片的图示:

![](https://img2020.cnblogs.com/blog/1834368/202010/1834368-20201021142329408-1771814988.jpg)

由图可知,第一个分片的

FIN

为 0,Opcode 为非0值(0x1 或 0x2),最后一个分片的FIN为1,Opcode为 0。中间分片的

FIN

opcode

二者均为 0。

根据 FIN 的值来判断,是否已经收到消息的最后一个数据帧:

  • 1)FIN=1 表示当前数据帧为消息的最后一个数据帧,此时接收方已经收到完整的消息,可以对消息进行处理;
  • 2)FIN=0,则接收方还需要继续监听接收其余的数据帧。

opcode在数据交换的场景下,表示的是数据的类型:

  • 1)0x01 表示文本,永远是 utf8 编码的;
  • 2)0x02 表示二进制;
  • 3)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 中

当接收到最后一个 FIN 帧的时候,就可以组装后给 _handleFrame 方法:

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); // 处理操作码

}

8.3 发送数据帧

上面讲的都是接收并解析来自客户端的数据帧,当我们想给客户端发送数据帧的时候,也得按协议来。

这部分操作相当于是上述

_processBuffer

方法的逆向操作,在代码里我们使用

encodeMessage

方法(为了简单起见,我们发送给客户端的数据没有经过掩码处理)将发送的数据分装成数据帧的格式,然后调用

socket.write

方法发送给客户端。

如下所示:

_doSend(opcode, payload) {

// 1. 考虑数据分片

this.socket.write(

encodeMessage(count > 0 ? OPCODES.CONTINUE : opcode, payload)

); //编码后直接通过socket发送

为了考虑分片场景,特意设置 MAX_FRAME_SIZE 来对每次发送的数据长度做截断做分片:

// ...

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);

}

// ...

至此已经实现 WebSocket 协议的关键部分,所组装起来的代码就能和客户端建立 WebSocket 连接并进行数据交互了。

9、有关WebSocket的常见疑问

9.1 字符串 “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 获取更多知识。

为啥选择这个字符串?

在WebSocket标准协议文档的第七页已经有明确的说明了:

![](https://img2020.cnblogs.com/blog/1834368/202010/1834368-20201021142504971-1944673186.png)

(英文不好的话,见中文翻译版《WebSocket标准协议手册(稀缺中文版+英文原版)》)

之所以选用这个 UUID ,主要该 ID 极大不太可能被其他不了解 WebSocket 协议的网络终端所使用。

我也不晓得该怎么翻译。总之,就说这个 ID 就相当于

WebSocket

协议的 “身份证号” 了。

9.2 Websocket 和 HTTP 什么关系?

HTTP、WebSocket 等应用层协议,都是基于 TCP 协议来传输数据的,我们可以把这些高级协议理解成对 TCP 的封装。

既然大家都使用 TCP 协议,那么大家的连接和断开,都要遵循 TCP 协议中的三次握手和四次握手 ,只是在连接之后发送的内容不同,或者是断开的时间不同。

对于 WebSocket 来说,它必须依赖 HTTP 协议进行一次握手 ,握手成功后,数据就直接从 TCP 通道传输,与 HTTP 无关了。

更详细的解释,可以移步:

9.3 浏览器中 Websocket 会自动分片么?

**答案是:**看具体浏览器的实现。

WebSocket是一个 message based 的协议,它可以自动将数据分片,并且自动将分片的数据组装。每个 message 可以是一个或多个分片。message 不记录长度,分片才记录长度。

根据协议 websocket 协议中帧长度上限为 2^63 byte(为 8388608 TB),可以认为没有限制,很明显按协议的最大上限来传输数据是不靠谱的。所以在实际使用中

websocket

消息长度限制取决于具体的实现。

关于这方面,找了两篇参考文章:

在文章《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

截图如下:

而以同样的方式去测试一些自己机器上的浏览器:

  • 1)Firefox(62.0,64bit);
  • 2)safari (11.1.2 - 13605.3.8);
  • 3)IE 11。

这些客户端上的 WebSocket 几乎没有大小的分片(随着数据量增大,发送会减缓,但并没有发现分片现象)。

10、本文小结

从刚开始决定阅读 WebSocket 协议,到自己使用 Node.js 实现一套简单的 WebSocket 协议,到这篇文章的产出,前后耗费大约 1 个月时间(拖延症。。。)。

感谢文中所提及的参考文献所给予的帮助,让我实现过程中事半功倍。

之所以能够使用较少的代码实现 WebSocket,是因为 Node.js 体系本身了很好的基础,比如其所提供的

EventEmitter

类自带事件循环,http 模块让你直接使用封装好的

socket

对象,我们只要按照 WebSocket 协议实现 Frame(帧)的解析和组装即可。

在实现一遍 WebSocket 协议后,就能较为深刻地理解以下知识点(一切都是那么自然而然):

  • 1)Websocket 是一种应用层协议,是为了提供 Web 应用程序和服务端全双工通信而专门制定的;
  • 2)WebSocket 和 HTTP 都是基于 TCP 协议实现的;
  • 3)WebSocket和 HTTP 的唯一关联就是 HTTP 服务器需要发送一个 “Upgrade” 请求,即 101 Switching Protocol 到 HTTP 服务器,然后由服务器进行协议转换。
  • 4)WebSocket使用 HTTP 来建立连接,但是定义了一系列新的 header 域,这些域在 HTTP 中并不会使用;
  • 5)WebSocket 可以和 HTTP Server 共享同一 port
  • 6)WebSocket 的 数据帧有序
  • ...

本文仅仅是协议的简单实现,对于 WebSocket 的其实还有很多事情可以做(比如支持 命名空间、流式 API 等),有兴趣的可以参考业界流行的 WebSocket 仓库,去练习锻造一个健壮的 WebSocket 工具库轮子。

比如下面这些:

  • 1)socketio/socket.io:43.5k star,不多说,业界权威龙头老大。(不过这实际上不是一个 WebSocket 库,而是一个实时 pub/sub 框架。简单地说,Socket.IO 只是包含 WebSocket 功能的一个框架,如果要使用该库作为 server 端的服务,则 client 也必须使用该库,因为它不是标准的 WebSocket 协议,而是基于 WebSocket 再包装的消息通信协议)
  • 2)websockets/ws:9k star,强大易用的 websocket 服务端、客户端实现,还有提供很多强大的特性
  • 3)uNetworking/uWebSockets:9.5k star,小巧高性能的 WebSocket实现,C++ 写的,想更多了解 WebSocket 的底层实现,该库是不错的案例。
  • 4)theturtle32/WebSocket-Node:2.3k star,大部分使用 JavaScript,性能关键部分使用 C++ node-gyp 实现的库。其所列的 测试用例 有挺好的参考价值。

(本文同步发布于:www.52im.net/thread-3175…