【Node.js】论一个低配版Web实时通讯库是如何实现的1( WebSocket篇)

引论html

simple-socket是我写的一个"低配版"的Web实时通讯工具(相对于Socket.io),在参考了相关源码和资料的基础上,实现了先后端实时互通的基本功能前端

选用了WebSocket ->server-sent-event -> AJAX轮询这三种方式作降级兼容,分为simple-socket-client和simple-socket-server两套代码,node

并实现了最简化的API:git

  • 先后端各自经过connect事件触发,获取各自的socket对象github

  • 前端经过socket.emit('message', "data")发送消息; 服务端经过socket.on('message', function (data) { //... })接收web

  • 服务端经过socket.emit('message', "data")发送消息; 前端经过socket.on('message', function (data) { //... })接收ajax

为方便细节的理解,未直接引用ws,eventsource,sockjs,等已有的工具库npm

下面把编码的过程和细节,以及代码予以记录后端

github仓库地址跨域

 

https://github.com/penghuwan/simple-socket

 

npm命令

npm i simple-socket-serve   (服务端npm包)
npm i simple-socket-client   (客户端npm包)

使用方式模仿Socket.io

前端

var client = require('simple-socket-client');
var client = new Client();
client.on('connect', socket => {
    socket.on('reply', function (data) {
        console.log(data)
    })
    socket.emit('message', "pppppppp");
})

 

服务端

const SocketServer = require('simple-socket-serve');
const http = require('http');

const server = http.createServer(function (request, response) {
    // 你的其余代码~~
})

// Usage start
const ss = new SocketServer({
    httpSrv: server, // 需传入Server对象
});
ss.on('connect', socket => {
    socket.on('message', data => {
        console.log(data);
    });
    setTimeout(() => {
        socket.emit('reply', "aaaa");
    }, 3000);
});
// Usage end

server.listen(3000);

 

Output

前端: 约3秒后输出aaaa
服务端:输出pppppp

 

下面梳理了我在编码过程当中的思路,其中有些是借鉴于已有的工具库(如Socket.io)源码,有些则是本身的思考所得。若有错漏之处请多指点

须要思考的问题

  1. 咱们须要编写哪些通讯方式?这些通讯方式的上到下的兼容顺序是什么?

  2. 浏览器怎么选择最优的通讯方式呢?

  3. 服务端怎么知道当前发出请求的浏览器,它最高支持哪种通讯方式?

  4. 编写的服务端代码怎么和当前的业务代码衔接?

  5. 如何使用WebSocket实现通信?

Q1. 咱们须要编写哪些通讯方式?这些通讯方式的上到下的兼容顺序是什么?

 

首先要先梳理一下可供选择的实现双向通讯的方式,以及它们的浏览器兼容性 (兼容性数据来源于 can i use)

  • WebSocket: IE10以上才支持,Chrome16, FireFox11,Safari7以及Opera12以上彻底支持,移动端形势大

  • event-source: IE彻底不支持(注意是任何版本都不支持),Edge76,Chrome6,Firefox6,Safari5和Opera以上支持, 移动端形势大好

  • AJAX轮询: 用于兼容低版本的浏览器

  • 永久帧( forever iframe)可用于兼容低版本的浏览器

  • flash socket 可用于兼容低版本的浏览器

 

那么它们的优缺点各是怎样的呢?

1.WebSocket

  • 优势:WebSocket 是 HTML5 开始提供的一种在单个 TCP 链接上进行全双工通信的协议,可从HTTP升级而来,浏览器和服务器只须要一次握手,就能够进行持续的,双向的数据传输,所以能显著节约资源和带宽

  • 缺点:1. 兼容性问题:不支持较低版本的IE浏览器(IE9及如下)2.不支持断线重连,须要手写心跳链接的逻辑 3.通讯机制相对复杂

2. server-sent-event(event-source)

  • 优势:(1)只需一次请求,即可以stream的方式屡次传送数据,节约资源和带宽 (2)相对WebSocket来讲简单易用 (3)内置断线重连功能(retry)

  • 缺点: (1)是单向的,只支持服务端->客户端的数据传送,客户端到服务端的通讯仍然依靠AJAX,没有”一家人整整齐齐“的感受(2)兼容性使人担心,IE浏览器彻底不支持

3. AJAX轮询

  • 优势:兼容性良好,对标低版本IE

  • 缺点:请求中有大半是无用的请求,浪费资源

4.Flash Socket(这个感受得先说缺点2333)

  • 缺点:(1)浏览器开启时flash须要用户确认,(2)加载时间长,用户体验较差 (3)大多数移动端浏览器不支持flash,为重灾区

  • 优势: 兼容低版本浏览器

          

 

5. 永久帧( forever iframe)

  • 缺点: iframe会产生进度条一直存在的问题,用户体验差

  • 优势:兼容低版本IE浏览器

 

综上,综合兼容性和用户体验的问题,我在项目中选用了WebSocket ->server-sent-event -> AJAX轮询这三种方式作从上到下的兼容

 

Q2: 浏览器端怎么选择最优的通讯方式呢?

很简单,作一下能力检测就能够了,对于支持WebSocket的浏览器,window顶层对象能够检测到WebSocket属性,而支持SSE的浏览器,则能够检测到window.EventSource属性,这即可以做为判断依据。对三种方式作从上到下的判断便可。

 

// 备注: 此为前端代码
function Client() { 
    this.ws = null;
    this.es = null;
    init.call(this);
}
function init() {
    // 采用WebSocket做为通讯方式
    if (window.WebSocket) {
        this.type = 'websocket';
        this.ws = new WebSocket(`ws://${url}`);
        return;
    }
   // 采用server-sent-event做为通讯方式
    if (window.EventSource) {
        this.type = 'eventsource';
        this.es = new EventSource(`http://${url}/eventsource?connection=true`)
        return;
    }
   // 采用Ajax轮询做为通讯方式
    this.type = 'polling';
}

 

Q3.服务端怎么知道当前发出请求的浏览器,它最高支持哪种通讯方式?

由于服务端须要处理不一样的浏览器发出的请求,这些请求的方式多是不同的。

个人思路是:

  1. 对于websocket请求,可经过检测connection首部字段是否包含'upgrade',同时upgrade首部字段是否为 'websocket'这两个条件进行判断

  2. 对于event-source和AJAX轮询,让前端选择方式后,传URL路径过去告知后端就能够了,路径分别为host:/eventsource和host:/polling

  3. event-source我以为也能够在前端设置accept:'text/event-stream'的方式告知后端,这个待会改改

 

// 备注:Node.js服务端代码
var url = require('url');
module.exports = {
    // 判断请求的浏览器是否选择了websocket进行通讯
    isWebSocket(req) {
        var connection = req.headers.connection || '';
        var upgrade = req.headers.upgrade || '';
        return connection.toLowerCase().indexOf('upgrade') >= 0 &&
            upgrade.toLowerCase() === 'websocket';
    },
    // 判断请求的浏览器是否选择了event-source(SSE)进行通讯
    isEventSource(req) {
        var pathname = url.parse(req.url).pathname;
        return pathname === '/eventsource';
    },
    // 判断请求的浏览器是否选择了AJAX轮询进行通讯
    isPolling(req) {
        var pathname = url.parse(req.url).pathname;
        return pathname === '/polling';
    },
}

 

Q4. 编写的服务端代码怎么和当前的业务代码衔接?

咱们定义一个SocketServer类,并在contructor中接收业务代码中已有的server实例,并监听其request事件去处理请求和响应。以下所示

 

// 备注: Node.js服务端代码
class SocketServer {
  constructor (opt) {
    super();
    // 以构造函数参数的方式接收业务代码里面已有的Server实例
    this.httpSrv = opt.httpSrv;
    this._initHttp(); 
  }
  _initHttp() {
    // 监听外部Server实例的request事件,并处理请求和响应
    this.httpSrv.on('request', (req,res) => {
      // ...
    } );
  }
}

使用方式

const server = http.createServer(function (request, response) {    }) // 原有的业务代码
const ss = new SocketServer({
    httpSrv: server, // 需传入Server对象
});
ss.on('connect', socket => {   });

 

这样作有两个好处:

  • 一方面,对原有的代码没有过多的侵入性

  • 避免了建立新的server实例或监听不一样的端口,保持和原server同域,避免了先后端代码产生跨域的问题

先后端组织逻辑概述

 

前端

1.定义构造函数Client

 

function Client(host) {
    this.type = null; // 通讯方式
    this.ws = null; // WebSocket对象
    this.es = null; // EventSource对象
    this.ajax = null;
    init.call(this); // 经过能力检测, 设置this.type,初始化相关API对象
    listen.call(this); // 监听相关链接打开或消息接收的事件(例如ws.onpen/ws.onmessage;
}
Client.prototype.on  = function (event,cb){
        emitter.on(event, cb)
}

 

2.在链接打开时触发connect事件,把client对象自身给传进去

this.ws.onopen = function () {
  emitter.emit('connect', this);
}

var client = new Client();
// 下面的写法中,socket和client实际上是同一个对象
client.on('connect', socket => {
    socket.on('reply', function (data) {
        console.log(data)
    })
    socket.emit('message', "pppppppp");
})

 

 

 

 后端
 
定义一个Socket类,每一个请求会对应建立一个Socket对象(对于AJAX轮询时候考虑复用Socket对象)
class Socket extends events.EventEmitter {
 constructor(socketId) {
   super();
   this.transport = null;  // 标记通讯方式 
   this.id = socketId;     // SocketId
   this.netSocket = null // updrage时获取的net.socket的实例,供WebSocket通讯使用
   this.eventStream = null // Stream.readable实例,供Event-Source通讯使用
   this.toSendMes = [];    // 待发送的信息,HTTP轮询时使用
  }
  // 其余代码 ...
  on (event,cb) {
    // 接收前端传送的信息
  }
  emit (event,data) {
    // 发送信息给前端
  }
}

 

而且定义Server类以下:
class Server extends events.EventEmitter {
 constructor(opt) {
   super();
   this.httpSrv = opt.httpSrv;
   // ...
  }
  // 其余代码 ...
}

//  使用Server对象
const ss = new Server({
    httpSrv: server, // 需传入Server对象
});
ss.on('connect', socket => {
    socket.on('message', data => {
        console.log(data);
    });
    socket.emit('reply', "aaaa");
});

 

Server对象会根据每请求建立相应Socket对象(AJAX轮询中Socket对象可能持久化并复用
而且是继承自events.EventEmitter,它会在适当的时刻触发connect事件,而且把请求对应的Socket对象传过去

Q5.如何实现WebSocket实时通讯?

关于如何在前端利用WS发送和接收消息,MDN文档里说得很详细了 请看  这里再也不赘述,主要是用了这几个API:
  • 建立websocket对象:var ws = new WebSocket(url);
  • 发送消息 ws.send("XXXX");
  • 接收消息:ws.onmessage = function (payload) { console.log(payload.data) };

 

WebSocket前端代码

前端接收消息

// 一开始能力检测的时候判断过通讯类型并初始化
this.ws = new WebSocket(`ws://${url}`); 
// ... 中间隔了其余代码
this.ws.onmessage = function (payload) {
  var  dataObj = JSON.parse(payload.data);
  emitter.emit(dataObj.event, dataObj.data);  // 触发事件
}

前端发送消息

// 一开始能力检测的时候判断过通讯类型并初始化
this.ws = new WebSocket(`ws://${url}`); 
// ... 中间隔了其余代码
this.ws.send(JSON.stringify({
  event: event,
  data: data
}));

 

WebSocket服务端代码(Node.js)

WebSocket的报文结构

接下来要讲的是后端怎么进行websocket消息的发送和接收。 这首先要先从websocet请求报文和响应报文开始提及
1.这是个人ws请求报文
Connection: Upgrade  // 表示请求从HTTP升级为其余协议
Upgrade: websocket  // 表示升级的协议是webSocket
Sec-WebSocket-Key: VCKjclrCsM3LpMkEngmVhA== // 这个参数须要在服务端拼接后返回
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits //  WebSocket的扩展字段
Sec-WebSocket-Version: 13 // WebSocket版本
Sec-websocket-protocol //这个字段个人报文里没有,它是前端webSocket构造函数指定的第二个参数(new WebSocket(url,[protocol]))

 

2.这是个人ws响应报文
HTTP/1.1 101 Switching Protocols
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Accept: WLZzo5hbAQgXJ24D0mE3u3nj1Fo=

...

WebSocket的握手流程和代码

 

要在后端完成基本的握手,你须要作这三件事情:
1.监听server对象的upgrade方法,从回调中接收请求对象req和socket对象,接下来经过req判断是否该请求是不是一个webSocket请求,若是是则进行下一步处理
2. 把下面这三行字段原封不动地写入响应报文里,准备返回去给前端~~
HTTP/1.1 101 Switching Protocols
Connection: Upgrade
Upgrade: websocket

3. 从前端请求报文中获取Sec-WebSocket-Key,拼接上服务端本身定义的ID字符串,而后用sha1加密,再而后转为base64编码格式。最后放在Sec-WebSocket-Accept这个响应报文字段中返回给前端。返回数据的方法是调用socket.write方法
上面三件事完成了,基本的握手流程就能够跑通了
若是你想进一步知道怎么对Sec-WebSocket-Extensions,Sec-websocket-protocol这几个请求字段作处理,你能够看看这里,这个是ws模块的代码  ,对,就是这个文件
 
下面是握手流程具体代码
class SocketServer {
  constructor (opt) {
    super();
    // 以构造函数参数的方式接收业务代码里面已有的Server实例
    this.httpSrv = opt.httpSrv;
    this._initWebSocket();
  }

  _initWebSocket() {
      // 监听upgrade事件,判断是否请求是websocket,如果则进行握手
      this.httpSrv.on('upgrade', (req, netSocket) => {
        // ... other code
        this._handleWShandShake(req, netSocket, () => {
          const socket = new Socket(null);
          // 握手成功后触发onConnection事件, 同时传递socket对象进去
          this.emit('connect', socket);
        })
      });
  }
}

 

上面的_handleWShandShake方法代码以下:

handleWShandShake(req, netSocket, cb) {
    if (!detect.isWebSocket(req)) {
        return;
    }
    const key =
        req.headers['sec-websocket-key'] !== undefined
            ? req.headers['sec-websocket-key'].trim()
            : '';
    const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
    const digest = createHash('sha1')
        .update(key + GUID)
        .digest('base64');
    const headers = [
        'HTTP/1.1 101 Switching Protocols',
        'Upgrade: websocket',
        'Connection: Upgrade',
        `Sec-WebSocket-Accept: ${digest}`
    ];

    netSocket.write(headers.concat('\r\n').join('\r\n'));
    cb();
}

 

上面讲了websocket的握手过程,下面讲一下怎么进行server端消息的发送和接收

服务端接收消息

咱们上回说到,监听server对象的upgrade事件能够获取socket对象,咱们能够经过监听socket对象的data方法,获取前端经过websocket.send传来的数据 。

 

可是这里有一个坑!上面data的回调里接收的payload是一个Buffer类型的对象,那咱们可否经过Buffer.string去得到前端传来的JSON字符串呢?

答案是

 

 

由于传来的—— 是一个封装好的帧的数据,你须要把它手动解析出来,才能取出咱们想要的那部分数据

(若是你发现报了failed: One or more reserved bits are on: reserved1 = 1, reserved2 = 1, reserved3 = 1 这个错误,恭喜你!踩中坑了)

WebSocket帧的编码和解码

在介绍帧的编码和解码以前,让咱们先看看WebSocket的帧的格式是怎样的

WebSocket的帧格式

 

 

详细介绍参考Websocket的RFC文档: (在page27处)

 

了解了websocket帧的格式后,这里介绍一下几个非(jin)常(chang)有(keng)用(ren)的字段

  • FIN: 表示是不是最后一个帧,1表明是,0不是 // 返回数据帧给前端的时候FIN必定要为1,否则前端收不到

  • Opcode:帧类型,1表明文本数据,2表明二进制数据 // 这个影响前端onmessage接收的数据类型究竟是String仍是Blob

  • RSV 1 RSV2 RSV3 留之后备用 //也就是。。如今尚未卵用,若是控制台报了这个有错八成是没有解析帧数据

其余一些字段

  • Mask :1bit 掩码,是否加密数据,默认必须置为1

  • Payload len : 7bit,表示数据的长度

  • Payload data :为数据内容

解析数据帧的代码

OK!介绍完了帧的格式,下面show一下(别人的)解析帧的代码

 

// 解析Socket数据帧的方法
// 做者:龙恩0707 
// 参考地址: https://www.cnblogs.com/tugenhua0707/p/8542890.html
function decodeFrame(e) {
    var i = 0, j, s, arrs = [],
        frame = {
            // 解析前两个字节的基本数据
            FIN: e[i] >> 7,
            Opcode: e[i++] & 15,
            Mask: e[i] >> 7,
            PayloadLength: e[i++] & 0x7F
        };

    // 处理特殊长度126和127
    if (frame.PayloadLength === 126) {
        frame.PayloadLength = (e[i++] << 8) + e[i++];
    }
    if (frame.PayloadLength === 127) {
        i += 4; // 长度通常用4个字节的整型,前四个字节通常为长整型留空的。
        frame.PayloadLength = (e[i++] << 24) + (e[i++] << 16) + (e[i++] << 8) + e[i++];
    }
    // 判断是否使用掩码
    if (frame.Mask) {
        // 获取掩码实体
        frame.MaskingKey = [e[i++], e[i++], e[i++], e[i++]];
        // 对数据和掩码作异或运算
        for (j = 0, arrs = []; j < frame.PayloadLength; j++) {
            arrs.push(e[i + j] ^ frame.MaskingKey[j % 4]);
        }
    } else {
        // 不然的话 直接使用数据
        arrs = e.slice(i, i + frame.PayloadLength);
    }
    // 数组转换成缓冲区来使用
    arrs = new Buffer.from(arrs);
    // 若是有必要则把缓冲区转换成字符串来使用
    if (frame.Opcode === 1) {
        arrs = arrs.toString();
    }
    // 设置上数据部分
    frame.PayloadLength = arrs;
    // 返回数据帧
    return frame;
}

 

帧解码后接收前端传来的消息

帧解码

借助于上面的decodeFrame方法,咱们就能够愉快地经过WebSocket从前端接收消息啦!

 

this.httpSrv.on('upgrade', (req, netSocket) => {
        // ... other code
       netSocket.on('data', payload => {
              // 对接收的WebSocket帧数据进行解析,对应前端调用ws.send方法发来的数据
              const str = decodeFrame(payload).PayloadLength;
        });
});

 

经过WebSocket向前端发送消息

根据上文容易联想,既然接收消息要解析帧,那么发送消息也确定要把数据封装成帧再发送对不对~~ 看代码

WebSocket帧的封装

// 接收数据并返回Socket数据帧的方法
// 做者:小胡子哥
// 参考地址: https://www.cnblogs.com/hustskyking/p/websocket-with-node.html
function encodeFrame(e) {
    var s = [], o = Buffer.from(e.PayloadData), l = o.length;
    //输入第一个字节
    s.push((e.FIN << 7) + e.Opcode);
    //输入第二个字节,判断它的长度并放入相应的后续长度消息
    //永远不使用掩码
    if (l < 126) s.push(l);
    else if (l < 0x10000) s.push(126, (l & 0xFF00) >> 8, l & 0xFF);
    else s.push(
        127, 0, 0, 0, 0, //8字节数据,前4字节通常没用留空
        (l & 0xFF000000) >> 6, (l & 0xFF0000) >> 4, (l & 0xFF00) >> 8, l & 0xFF
    );
    //返回头部分和数据部分的合并缓冲区
    return Buffer.concat([new Buffer(s), o]);
}

 

好的大伙,故事到这里就讲完了,祝你们 。。。

等等!!

好像还有什么重要的事情要说。

WebSocket编码的技术总结

下面开始WebSocket编码的技术总结~(美食做家王刚的口音)

 

 

「Node篇」

  1. httpServer的Upgrade事件并非Upgrade成功时触发的,而是包含Upgrade首部的请求报文到达服务端时触发的,也即每次服务器响应升级请求时发出。咱们能够在这里确认请求是否为Websocket升级请求并进行握手

  2. 在simple-socket-server中,是将其附加到已有的server实例中根据其自有的请求和响应进行处理,而不是另外启动一个server,这样是为了不产生跨域的问题,由于simple-socket-client的JS代码和项目自己的服务端代码是同域的,simple-socket-server天然也要和原有的服务端代码同域

  3. 能够经过httpserver对象的request事件监听请求和响应,从外部附加socket-server的业务代码

「WebSocket篇」

 

  1. websocket不是永久链接的。一段时间就会断开,websocket须要手写定时心跳链接的代码(待会填上去)

  2. 服务端接收Websocket数据需手动解析WebSocket帧。当你尝试接收前端的数据时,即在服务端获取到链接的socket后,经过socket.on('data', payload => { ... })获取的payload。这个payload是一个Buffer类型, 然而蛋疼的是你也不能直接经过Buffer.toString拿到这个字符串数据,若是直接toString输出将会获得一串乱码!!由于收到的这个Buffer是一个被封装后的帧,须要进行解析

  3. 服务端发送Websocket数据需手动封装WebSocket帧。 正如上一条所示,在websocket的服务端,你不能直接经过socket.write(String)或者socket.write(Buffer)去写数据,而是要手动先把数据封装成帧,才能发送过去

  4. 在服务端发送websocket数据帧时,要确保FIN为1(表示最后一个帧)。前端onmessage才能收到响应!不然没法响应。

  5. WebSocket的onmessage = (event) =>{ event.data }中前端接收的event.data的类型取决于服务端返回的数据帧的opcode这一字段, event.data可能为Blob (opcode = 2,表明发送过去的是二进制数据) 或者字符串(opcode = 1,表示字符串数据)

本文完,完整代码请参考

 

github仓库地址

 

https://github.com/penghuwan/simple-socket
相关文章
相关标签/搜索