Node.js - 200 多行代码实现 Websocket 协议

2019-09-29 15:39:52 浏览数 (1)

温馨提示:因微信中外链都无法点击,请通过文末的” “阅读原文” 到技术博客中完整查阅版;(本文整理自技术博客)

A、预备工作

1、序

最近正在研究 Websocket 相关的知识,想着如何能自己实现 Websocket 协议。到网上搜罗了一番资料后用 Node.js 实现该协议,倒也没有想象中那么复杂,除去注释语句和 console 语句后,大约 200 行代码左右。本文记录了实现过程中的经验和总结。

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

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

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

2、知识储备

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

  1. 熟练掌握 Websocket 的协议,这个需要多读现有的解读类文章;(下面会给出参考文章)
  2. 操作二进制数据流,在 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 官方教程,读一遍没啥坏处。

然后开始写代码,在实现过程中的大部分代码可以从下面 3 篇文章中找到并借鉴(copy):

  • nodejs 实现:简化版本的从这儿借鉴过来的
  • 学习WebSocket协议—从顶层到底层的实现原理(修订版)
  • WebSocket协议解析:虽然是 C 写的,但不影响代码逻辑的理解

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

帧结构标准

截图来自规范Base Framing Protocol

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

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

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

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

B、 实战

实现代码放在自己的 demos 仓库的 micro-ws 的目录 了,git clone 后本地运行,执行

代码语言:javascript复制
node index.js

将会在 http://127.0.0.1:3000 创建服务。运行服务之后,打开控制台就能看到效果:

将会在 http://127.0.0.1:3000 创建服务。运行服务之后,打开控制台就能看到效果:

实战效果图

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

1.先建立连接

代码语言:javascript复制
var ws = new WebSocket("ws://127.0.0.1:3000");
ws.onmessage = function(evt) {
  console.log( "Received Message: "   evt.data);
};

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

代码语言:javascript复制
ws.send('hello world'); 

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

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

1、调用所写的 Websocket 类

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

客户端通过 HTTP Upgrade 请求,即 101 Switching Protocol 到 HTTP 服务器,然后由服务器进行协议转换。

在 Node.js 中我们通过 http.createServer 获取 http.server 实例,然后监听 upgrade 事件,在处理这个事件:

代码语言:javascript复制
// HTTP服务器部分
var server = http.createServer(function(req, res) {
  res.end('websocket testrn');
});

// 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 类实例的初始化工作;

2、构造函数

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

代码语言:javascript复制
class WebSocket extends EventEmitter {
  constructor(req, socket, upgradeHead){
    super(); // 调用 EventEmitter 构造函数

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

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

    // 3. 初始化成员属性

  }
}

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

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

讲一下其中 响应头的构造事件监听 部分。

2.1、返回响应头(Response Header)

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

  1. Sec-WebSocket-Key258EAFA5-E914-47DA-95CA-C5AB0DC85B11拼接。
  2. 通过 SHA1 计算出摘要,并转成 base64 字符串。

具体代码如下:

代码语言:javascript复制
    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('rn');
    socket.write(resHeaders);

当执行 socket.write(resHeaders); 到后就和客户端建立起 WebSocket 连接了,剩下去就是数据的处理。

2.2、监听事件

socket 就是 TCP 协议的抽象,直接在上面监听已有的 data 事件和 close 事件这两个事件。

还有其他事件,比如 errorend 等,详细参考 net.Socket 文档

代码语言:javascript复制
    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 循环语句里,处理好边界情况,防止死循环。

3、Frame 帧数据的处理

WebSocket 客户端、服务端通信的最小单位是帧(frame),由1个或多个帧组成一条完整的消息(message)。 这 this._processBuffer() 部分代码逻辑就是用来解析帧数据的,所以它是实现 Websocket 代码的关键;(该方法里面用到了大量的位操作符以及 Buffer 类的操作)

帧数据结构详细定义可参考 RFC6455 5.2节,上面罗列的参考文章都有详细的解读,我在这儿也不啰嗦讲细节了,直接看代码比听我用文字讲要好。

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

3.1、操作码(Opcode)

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

根据 Opcode 我们可以大致将数据帧分成两大类:数据帧控制帧

  • 数据帧:目前只有 3 种,对应的 opcode 是:
  • 0x0:数据延续帧
  • 0x1:utf-8文本
  • 0x2:二进制数据;
  • 0x3 - 0x7:目前保留,用于后续定义的非控制帧。
  • 控制帧:除了上述 3 种数据帧之外,剩下的都是控制帧
  • 0x8:表示连接断开
  • 0x9:表示 ping 操作
  • 0xA:表示 pong 操作
  • 0xB - 0xF:目前保留,用于后续定义的控制帧

在代码里,我们会先从帧数据中提取操作码:

代码语言:javascript复制
var opcode = byte1 & 0x0f; //截取第一个字节的后 4 位,即 opcode 码

然后根据协议获取到真正的数据载荷(data payload),然后将这两部分传给 _handleFrame 方法:

代码语言:javascript复制
this._handleFrame(opcode, payload); // 处理操作码

该方法会根据不同的 opcode 做出不同的操作:

代码语言:javascript复制
_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);
    }
  }

3.2、分片(Fragment)

规范文档:5.4 - Fragmentation

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

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

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

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

WebSocket 协议提供的分片方法,是将原本一个大的帧拆分成数个小的帧。下面是把一个大的Frame分片的图示:

分片图示

根据 FIN 的值来判断,是否已经收到消息的最后一个数据帧。由图可知,第一个分片的 FIN 为 0,Opcode 为非0值(0x1 或 0x2),最后一个分片的FIN为1,Opcode为 0。中间分片的 FINopcode 二者均为 0。

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

  • opcode在数据交换的场景下,表示的是数据的类型。
    • `0x01` 表示文本,永远是 `utf8` 编码的
    • `0x02` 表示二进制
    • 而 `0x00` 比较特殊,表示 延续帧(continuation frame),顾名思义,就是完整消息对应的数据帧还没接收完。

代码里,我们需要检测 FIN 的值,如果为 0 说明有分片,需要记录第一个 FIN 为 0 时的 opcode 值,缓存到 this.frameOpcode 属性中,将载荷缓存到 this.frames 属性中:

代码语言:javascript复制
    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 方法:

代码语言:javascript复制
    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); // 处理操作码
    }

3.3、发送数据帧

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

这部分操作相当于是上述 _processBuffer 方法的逆向操作,在代码里我们使用 encodeMessage 方法(为了简单起见,我们发送给客户端的数据没有经过掩码处理)将发送的数据分装成数据帧的格式,然后调用 socket.write 方法发送给客户端;

代码语言:javascript复制
  _doSend(opcode, payload) {
    // 1. 考虑数据分片
    this.socket.write(
      encodeMessage(count > 0 ? OPCODES.CONTINUE : opcode, payload)
    ); //编码后直接通过socket发送

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

代码语言:javascript复制
    // ...
    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 连接并进行数据交互了。

4、Q&A

4.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 的 uuid 标识符

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

我也不晓得该怎么翻译。。。总之就说这个 ID 就相当于 Websocket 协议的 “身份证号” 了。

4.2、Websocket 和 HTTP 什么关系?

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

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

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

4.3、浏览器中 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 大小进行自动分包。我是通过以下测试代码验证:

代码语言:javascript复制
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);

服务端日志:

代码语言:javascript复制
server detect fragment, sizeof payload: 131072
server detect fragment, sizeof payload: 131072
receive data: 2 262145

客户端日志:

代码语言:javascript复制
Received Message: good job

截图如下:

chrome 浏览器会自动分片

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

  • Firefox(62.0,64bit)
  • safari (11.1.2 - 13605.3.8)
  • IE 11

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

5、总结

从刚开始决定阅读 Websocket 协议,到自己使用 Node.js 实现一套简单的 Websocket 协议,到这篇文章的产出,前后耗费大约 1 个月时间(拖延症。。。)。 感谢文中所提及的参考文献所给予的帮助,让我实现过程中事半功倍。

之所以能够使用较少的代码实现 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 实现的库。其所列的 测试用例 有挺好的参考价值

0 人点赞