nodejs webSocket 实现原理浅析

1,955 阅读7分钟

前言

客户端与服务器端实时通讯的三种模式:

短轮询: 控制客户端的页面不断的进行ajax请求,应该很好实现吧。js定时器就可以实现,每次请求如果服务器端有更新数据则响应到客户端。但是这会造成服务器的严重压力,如果在线用户数量过多的话,每隔个一两秒请求一次,哪个服务器能受得了,这种肯定不太现实,或者是最无奈的实现方法。

comet: comet技术是服务器推技术的一个总称,但不是具体实现方式。目前常见的实现方式有两种:长轮询和流方式

长轮询(long-polling): 长轮询就是页面向服务器发起一个请求,服务器一直保持tcp连接打开,知道有数据可发送。发送完数据后,页面关闭该连接,随即又发起一个新的服务器请求,在这一过程中循环费。 流方式: http流不同于上述两种轮询,因为它在页面整个生命周期内只使用一个HTTP连接,具体使用方法即页面向浏览器发送一个请求,而服务器保持tcp连接打开,然后不断向浏览器发送数据。

WebSocket: 长连接和长轮询都比较消耗服务器资源,在这种情况下HTML5定义了webSocket协议,能更好的节省服务器资源和宽带,并且能够更实时地进行通讯。WebSocket是一种在单个TCP连接上进行全双工通信的协议。WebSocket使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在WebSocket API中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输.

websocket 的优点

较少的控制开销: 在连接创建后,服务器和客户端之间交换数据时,用于协议控制的数据包头部相对较小。在不包含扩展的情况下,对于服务器到客户端的内容,此头部大小只有2至10字节(和数据包长度有关);对于客户端到服务器的内容,此头部还需要加上额外的4字节的掩码。相对于HTTP请求每次都要携带完整的头部,此项开销显著减少了。 更强的实时性: 由于协议是全双工的,所以服务器可以随时主动给客户端下发数据。相对于HTTP请求需要等待客户端发起请求服务端才能响应,延迟明显更少;即使是和Comet等类似的长轮询比较,其也能在短时间内更多次地传递数据。 保持连接状态。与HTTP不同的是,Websocket需要先创建连接,这就使得其成为一种有状态的协议,之后通信时可以省略部分状态信息。而HTTP请求可能需要在每个请求都携带状态信息(如身份认证等)。 更好的二进制支持。Websocket定义了二进制帧,相对HTTP,可以更轻松地处理二进制内容。 可以支持扩展。Websocket定义了扩展,用户可以扩展协议、实现部分自定义的子协议。如部分浏览器支持压缩等。 更好的压缩效果。相对于HTTP压缩,Websocket在适当的扩展支持下,可以沿用之前内容的上下文,在传递类似的数据时,可以显著地提高压缩率。

websocket 原理

websocket 握手

客户端建立连接时,通过 HTTP 发起请求报文,在浏览器中截图如下:

与普通的 HTTP 请求,区别的部分主要在于这些协议头:

  • 升级协议

Upgrade: websocket Connection: Upgrade

  • 安全校验

Sec-WebSocket-Key: vU2rG6eLiq52k4TmJV79Ew==

  • 指定版本号

Sec-WebSocket-Version: 13

经过协议的升级,服务器返回:

Status Code: 101 Switching Protocols Connection: Upgrade Sec-WebSocket-Accept: 4e3/lpKUuT5lm8lvdbacFLcr0Sw= Upgrade: websocket

经过上述过程,握手完成。

websocket 报文组成

  • FIN: 占 1bit

0:不是消息的最后一个分片 1:是消息的最后一个分片

  • RSV1, RSV2, RSV3:各占 1bit

一般情况下全为 0。当客户端、服务端协商采用 WebSocket 扩展时,这三个标志位可以非 0,且值的含义由扩展进行定义。如果出现非零的值,且并没有采用 WebSocket 扩展,连接出错。

  • Opcode: 4bit

%x0:表示一个延续帧。当 Opcode 为 0 时,表示本次数据传输采用了数据分片,当前收到的数据帧为其中一个数据分片; %x1:表示这是一个文本帧(text frame); %x2:表示这是一个二进制帧(binary frame); %x3-7:保留的操作代码,用于后续定义的非控制帧; %x8:表示连接断开; %x9:表示这是一个心跳请求(ping); %xA:表示这是一个心跳响应(pong); %xB-F:保留的操作代码,用于后续定义的控制帧。

  • Mask: 1bit

表示是否要对数据载荷进行掩码异或操作。 0:否 1:是

  • Payload length: 7bit or (7 + 16)bit or (7 + 64)bit

表示数据载荷的长度 0~126:数据的长度等于该值; 126:后续 2 个字节代表一个 16 位的无符号整数,该无符号整数的值为数据的长度; 127:后续 8 个字节代表一个 64 位的无符号整数(最高位为 0),该无符号整数的值为数据的长度。

  • Masking-key: 0 or 4bytes

当 Mask 为 1,则携带了 4 字节的 Masking-key; 当 Mask 为 0,则没有 Masking-key。 掩码算法:按位做循环异或运算,先对该位的索引取模来获得 Masking-key 中对应的值 x,然后对该位与 x 做异或,从而得到真实的 byte 数据。 注意:掩码的作用并不是为了防止数据泄密,而是为了防止早期版本的协议中存在的代理缓存污染攻击(proxy cache poisoning attacks)等问题。

  • Payload Data: 载荷数据

实现

客户端 javascript 实现

var websocket = new WebSocket("ws://"+host+"/");
websocket.onopen = function(){
    console.log("open");
    isOK = true;
};

websocket.onmessage = function(e) {
  var data = e.data
  // do something
};

websocket.onclose = function(){
    console.log("close");
    isOK = false;

    // 断开重试
    setTimeout(function(){
      ws = createWebsocket();
    } , 2000);
};

服务器端 nodejs 实现

const EventEmitter = require("events").EventEmitter;

var webSocketCollector = [];

// 创建WebSocket类并继承事件类
class WebSocket extends EventEmitter {
  constructor(socket) {
    super();

    this.state = "OPEN";

    this.pingTimes = 0;

    this.socket = socket;

    this.receiver = null;

    this.bind();

    this.checkHeartBeat();

    Object.defineProperty(this, 'connectLength', {
      get: function() {
        return webSocketCollector.length;
      }
    });

    webSocketCollector.push(this);
  }

  /**
   * 关闭链接
   * @return {[type]}
   */
  close(reason) {
    var index;

    if (this.state === "CLOSE") return;

    if ((index = webSocketCollector.indexOf(this)) + 1) {
      webSocketCollector.splice(index, 1);
    }

    this.emit('close', reason);

    this.state = "CLOSE";

    this.socket.destroy();
  }

  /**
   * 广播信息
   * @param  {String} message
   */
  brocast(message) {
    webSocketCollector.forEach(function(ws) {
      ws.send(message);
    });
  }

  /**
   * 对socket进行事件绑定
   */
  bind() {
    var that = this;

    this.socket.on('data', function(data) {
      that.dataHandle(data);
    });

    this.socket.on('close', function(e) {
      that.close(e);
    });

    this.socket.on('error', function(e) {
      that.close(e);
    });
  }

  /**
   * socket有数据过来的处理
   * @return {[type]}
   */
  dataHandle(data) {
    var receiver = this.receiver;

    if (!receiver) {
      receiver = decodeFrame(data);

      if (receiver.opcode === 8) { // 关闭码
        this.close(new Error("client closed"));
        return;
      } else if (receiver.opcode === 9) { // ping码
        this.sendPong();
        return;
      } else if (receiver.opcode === 10) { // pong码
        this.pingTimes = 0;
        return;
      }

      this.receiver = receiver;

    } else {
      // 将新来的数据跟此前的数据合并
      receiver.payloadData = Buffer.concat(
        [receiver.payloadData, data],
        receiver.payloadData.length + data.length
      );

      // 更新数据剩余数
      receiver.remains -= data.length;
    }

    // 如果无剩余数据,则将receiver置为空
    if (receiver.remains <= 0) {
      receiver = parseData(this.receiver);

      this.emit('message', receiver);

      this.receiver = null;
    }
  }

  /**
   * 发送数据
   * @param  {String} message 发送的信息
   * @return {[type]}
   */
  send(message) {
    if (this.state !== "OPEN" && this.socket.writable) return;

    this.socket.write(encodeFrame(message));
  }

  /**
   * 心跳检测
   */
  checkHeartBeat() {
    var that = this;
    setTimeout(function() {
      if (that.state !== "OPEN") return;

      // 如果连续3次未收到pong回应,则关闭连接
      if (that.pingTimes >= 3) {
        that.close("time out");
        return;
      }

      //记录心跳次数
      that.pingTimes++;
      that.sendPing();

      that.checkHeartBeat();
    }, 20000);
  }

  /**
   * 发送ping
   */
  sendPing() {
    this.socket.write(new Buffer(['0x89', '0x0']))
  }

  /**
   * 发送pnong
   */
  sendPong() {
    this.socket.write(new Buffer(['0x8A', '0x0']))
  }
}

具体实现请参考源文件 socket.js 或 socket_v2.js

demo 运行方式

  • clone 项目
git clone https://github.com/aiqinhaian/jsTest.git
  • 进入目录
cd websocket
  • 启动服务器端
node start.js
  • 在浏览器端打开 client.html 文件,即可看到客户端和服务器端简历了 socket 了解,服务器端可定时给客户端发送消息 具体实现请参考源文件 socket.js 或 socket_v2.js