面试官:除了 WebSocket 你还知道哪些实现双向即时通信的方法?(实现一个自定义的双工通信)

99 阅读12分钟

春天来了,又到了 万物复苏 程序员跑路的季节。

笔者在很早之前某度的一场面试中被问到过一个问题,“别人给你发消息了,怎么在不刷新页面的情况下能得到新消息提示?”。翻译一下就是“怎么实现双向通信”,当时小弟我不假思索地回答 “WebSocket!”,紧接着面试官就问“还有别的方法么?”,“”长......长轮询?”,说到这儿其实我已经有点慌了,他要再往下问的话,我肯定就要凉在这儿了。不过当时运气好,这位大哥没有继续往下问,侥幸水过去了。大家可以想一想,如果现在问你“如何实现双向通信”的话,你能想到几种方法呢?

当然,使用长轮询和 WebSocket 绝对都是正确答案,但是这两种方案都存在着自己的问题。长轮询的话实时性不高,WebSocket 则需要环境支持。那么有没有一些方案,既能做到高实时性,又对环境兼容要求没那么高呢?下面就来给大家介绍一种方案。

曾经做 k8s 相关开发的时候了解到 k8s 中间各个组件使用的通信机制名为 list-watch,对于该机制的介绍随便搜搜可以搜到一大堆,在这里给大家简单介绍说明一下:

list-watch 机制主要实现了两个 Restful API,一个 API 叫 list,一个 API 叫 watch,客户端首先通过 list API 一次性把存在于服务端的所有资源都捞下来缓存在本地,同时服务端一起返回一个叫做 “ResourceVersion” 的标志,该标志全局单调递增,客户端也会将该标志缓存在本地。

接下来客户端通过 watch API 和服务端建立一条不会断开的长连接,每当服务端的资源发生改变之后,就会把该资源以及对该资源的 增/删/改 之类的操作通过该条长连接发送给客户端,同时把 ResourceVersion + 1,一并发送给客户端,客户端拿到该资源以及该资源对应的操作之后,就要根据对应的操作更新本地的对应资源的缓存以及 ResourceVersion。

那怎么保证消息的可靠性呢?譬如中间发生了断网之类的情况,此时 ResourceVersion 就派上了用场,客户端通过每次 watch 返回的 ResourceVersion 和本地上一轮缓存的 ResourceVersion 作对比,如果发现差值大于 1,则认为中间可能发生了数据丢失,此时会再进行一次 list API 的操作,重新将服务端的全量数据资源捞下来缓存在本地,这样既保证了消息的实时性,也保证了消息的可靠性。

总结成图片的话,大概如下:

到这里你可能会问,上面提到的 watch API 是通过长连接来保证消息的实时性的,那是怎么创建的长连接呢?

这里我们就要说一下,在 http 协议中一个不常用的东西了~

http 协议作为一种普及程度相当高的应用层协议,当前已经应用在了世界上大部分的网络环境中,其中在 http1.1 版本中所提出的一个新的响应头叫: Chunked transfer encoding(分块传输编码),通过该响应头的设置,就可以做到 list-watch 机制中的 watch 机制了~

下面我们将通过 “分块传输编码” 方案,使用 NodeJS 来实现一个自己的双向通信协议,并且通过该协议实现一个最简单的聊天室,我们就暂且叫他 list-watch 吧~

废话不多说,上效果图:

动图封面

好吧,UI 丑是丑了点,但是效果还成哈。可以看到,左侧的地址是 client1,右侧是 client2,想自己测试的朋友可以在该 gayhub 上 clone 然后玩一下试试看: github.com/y805939188/…

我们先看一下使用方法,然后根据使用方法去设计代码。

首先想客户端和服务端双向通信,则两方都必须遵守同一协议,所以在我们的代码中需要同时有客户端和服务端的代码,两端的使用方式如下:

使用方法非常简单,左侧为客户端使用方法,右侧为服务端使用方法。绿圈圈里为主要使用的流程,其他代码都是控制 UI 的,可以不必过分关注。

服务端:

  1. 引入 list-watch 的服务端

  2. 初始化 ListWatch 对象

  3. 调用 onopen 方法注册一个路由

  4. 调用 listen 方法监听一个端口

客户端:

  1. 引入 list-watch 的客户端

  2. 初始化 ListWatch 对象,同时传入服务端地址

  3. 调用 onopen 方法注册路由,注意此路由需要和服务端的 onopen 注册的一样

  4. 当 onopen 方法返回的 msg 是 ok 时表示服务端准备好了,此时可以在客户端注册 onmessage 方法了

  5. 客户端调用 send 方法,可以发送一条 message 到服务端,然后服务端就会将该 msg 即时推送到所有 onopen 注册路由相同的客户端

看完使用方式我们就来一点点地实现代码吧。


首先来看客户端,客户端需要满足 onopen、onmessage、send 三个方法,所以我们可以有如下代码:

class ListWatcher {
  constructor(baseUrl) {}
  onopen = (serverId, callback) => {}
  onmessage = (callback) => {}
  send = (msg) => {}
}

export default ListWatcher;

服务端需要满足 onopen、listen 方法,这里的 listen 方法使用方法和 express 完全一致,所以可以直接使用 express 的 listen 方法,同时为了解决前端跨域的问题和请求体的问题,我们再引入 cors 和 body-parser,初始代码如下:

const express = require('express')
const cors = require('cors');
const bodyParser = require('body-parser');

class ListWatch {

  constructor() {
    this.app = express();
    this.app.use(cors());
    this.app.use(bodyParser.urlencoded({ extended: false }));
    this.app.use(bodyParser.json());
  }

  onopen = (watchId, callback) => {}

  listen = (...args) => this.app.listen(...args);
};

module.exports = ListWatch;

初始化好代码之后,我们考虑如下问题,假设现在有 X、Y、Z 三个客户端想要和一个叫做 S 的服务端进行即时通信。

现在如果 X 向 server 端发送了一条消息,此时 server 需要将该条消息同步推送到 Y 和 Z 而不会往 X 发送,因为 X 自己就是发送端,所以我们就需要有个办法能够让 server 判断出到底哪个是发送端,哪些是接收端。我们可以采用在真正的长连接建立之前,先通过一条短连接让每个 client 端在 server 端注册自己:

服务端:

// 省略一些代码......

class ListWatch {
  
  // 增加如下代码:
  watcherList = [];
  __MAGIC_PRIVATE_STRING__ = 'magic-private-list-watch';
  CLIENT_REGISTER_SUFFIX = `${this.__MAGIC_PRIVATE_STRING__}_client_register_route_suffix`;
  
  onopen = (watchId, callback) => {
    this.app.post(`${watchId}/${this.CLIENT_REGISTER_SUFFIX}`, (req, res) => {
      const { clientId } = req.body;
      this.watcherList.push({ clientId, response: null });
      res.send('ok');
    });
  }
  
  // 省略其他代码......
};

// 省略其他代码......

客户端:

// 增加如下代码......
import ajax from '/list-watch/client/ajax.js';

class ListWatcher {
  __MAGIC_PRIVATE_STRING__ = 'magic-private-list-watch';
  CLIENT_REGISTER_SUFFIX = `${this.__MAGIC_PRIVATE_STRING__}_client_register_route_suffix`;
  
  constructor(baseUrl) {
    this.baseUrl = baseUrl;
    this.ajax = ajax;
    this.clientId = `${+new Date()}_${(Math.random() * 10000) | 0}`;
  }

  onopen = (serverId, callback) => {
    this.serverId = serverId;
    this
      .ajax()
      .post(`${this.baseUrl}${serverId}/${this.CLIENT_REGISTER_SUFFIX}`)
      .data({ clientId: this.clientId })

      .send();
  }
}

// 省略一些其他代码......

其中我们需要和服务端先建立一条短链接以便让客户端向服务端注册自己,所以服务端需要建立一条 post 路由以供前端请求,我们通过设置约定好的比较 magic 的字符串,前端和服务端都以该字符串来设置路由。当然更好的方法是由服务端随机生成一个串儿,然后前端先去 get 一下这个串儿,之后前后端都使用该串儿作为请求路由。这里为了简化我们直接约定好一个 magic 的路由就好~

其中 ajax 方法是我们自己简单封装了一下,代码如下:

const ajax = () => {
  return {
    _method: null,
    _data: null,
    _xhr: new XMLHttpRequest(),
    _callback: [],
    _getQuery: (data) => Object.entries(data).reduce((a, b) => (b ? (a ? `${a}&${b[0]}=${JSON.stringify(b[1])}` : `${b[0]}=${JSON.stringify(b[1])}`) : a  ), ''),

    send: function() {
      this._method === 'GET' && this._xhr.open(this._method, this._url, true);
      this._method === 'GET' ? this._xhr.send() : this._xhr.send(this._getQuery(this._data));
      this._xhr.onreadystatechange = () => {
        if (this._xhr.readyState === 3 || this._xhr.readyState === 4) {
          if ( this._xhr.status == 200 ) {
            this._callback.length && this._callback.forEach((cb) => cb(this._xhr.responseText));
          }
        }
      }
    },

    data: function(data) {
      this._data = data;
      if (this._method && this._method === 'GET') {
        this._url = `${this._url}?${this._getQuery(data)}`;
      }
      return this;
    },

    get: function(url) {
      this._url = url;
      this._method = 'GET';
      return this;
    },
    post: function(url) {
      this._url = url;
      this._method = 'POST';
      this._xhr.open(this._method, this._url, true);
      this._xhr.setRequestHeader('content-type', 'application/x-www-form-urlencoded');
      return this;
    },
    callback: function(cb) {
      this._callback.push(cb);
      return this;
    }
  }
};

export default ajax;

该 ajax 的封装中有个小地儿和平常咱们封装稍微有一点点小区别,大家可以先尝试自己找找看,找不到也没事儿,一会儿咱再说它~

简单来说注册的过程就是 client 生成一条随机的串儿作为 clientId,然后向 server 端发送一条 post 请求,server 端将其保存下来,在保存的时候我们预留出一个 response: null,之后有用~

注册好客户端后,接下来我们就可以开始创建长连接了,在刚才的基础之上我们添加如下代码:

服务端:

// 省略一些代码......

class ListWatch {
  // 省略一些代码.....
  
  // 增加如下代码:
  headers = {
    'Transfer-Encoding': 'chunked',
    'Content-Type':'text/plain; charset=UTF-8',
    'x-content-type-options':'nosniff',
  };

  SUCCESS_CODE = 200;
  OPEN_TAG = `${this.__MAGIC_PRIVATE_STRING__}_on_open_tag`;

  onopen = (watchId, callback) => {
    this._watchId = watchId;
    this.app.get(watchId, (req, res) => {
      const resourceVersion = { current: 0 };
      res.writeHead(this.SUCCESS_CODE, this.headers);
      res.write(`${this.OPEN_TAG}/${resourceVersion.current}\r\n`);

      const _origin_write = res.write;      
      res.write = (...args) => {
        resourceVersion.current++;
        args[0] = `${this.OPEN_TAG}/${resourceVersion.current}/${args[0]}\r\n`;
        _origin_write.apply(res, args);
      }

      const { clientId } = req.query;
      const currentClient = this.watcherList.find((watcher) => (watcher.clientId === clientId));
      currentClient.response = res;
      callback('ok'); 
    });
    
    // 省略一些代码......
  }
  
  // 省略其他代码......
};

// 省略其他代码......

客户端:

// 增加如下代码......
import ajax from '/list-watch/client/ajax.js';

class ListWatcher {
  // 省略一些代码......
  
  onopen = (serverId, callback) => {
    this.serverId = serverId;
    this
      .ajax()
      .post(`${this.baseUrl}${serverId}/${this.CLIENT_REGISTER_SUFFIX}`)
      .data({ clientId: this.clientId })
      // 注意刚刚上面已经创建过 onopen 方法
      // 这里新增加一个 callback
      // 并在 callback 中调用 this._connect
      .callback((msg) => (msg === 'ok' && this._connect(callback)))
      .send();
  }

  // 增加如下代码......
  _connect = (callback) => {
    this
      .ajax()
      .get(`${this.baseUrl}${this.serverId}`)
      .data({ clientId: this.clientId })
      .callback((msg) => {
        if (msg) {
          const _msg = msg.replace(this.cacheResponseText, '').replace('\r\n', '').split('/');
          this.cacheResponseText = msg;
          const [ _magic_tag_, resourceVersion, currentMsg ] = _msg;
          if (_magic_tag_ !== this.OPEN_TAG) return console.log('response msg err, invaild list watch format');
          if (callback) {
            callback('ok');
            callback = null;
            this.opened = true;
          } else {
            this._onmessage && this._onmessage(currentMsg.replaceAll('"', ''));
          }
        }
      })
      .send()
  }
  
  onmessage = (callback) => {
    this._onmessage = callback;
  }
}

// 省略一些其他代码......

我们在客户端的 onopen 方法是用来向服务端发送 clientId 注册自己的,现在我们给他新增一个 callback 回调函数,并让该 callback 触发我们新增加的一个 this._connect 函数。

this._connect 方法就是客户端和服务端建立长连接的方法,this.baseUrl 是服务端监听的地址,这里的 demo 中是 " localhost:3000 ",this.serverId 就是客户端和服务端都注册过的长连接的路由地址,这里的 demo 中是 " /ding/test "。

这里要注意我们的服务端中有很重要的响应头:

headers = {
    'Transfer-Encoding': 'chunked',
    'Content-Type':'text/plain; charset=UTF-8',
    'x-content-type-options':'nosniff',
  };

第一个 transfer-encoding: chunked 就是我们最开始介绍过的用来控制分块传输的响应头,当响应头的报文中加入了该字段之后,实现了该功能的两端将不会立即断开连接。

至于后面的 content-type,大家应该都知道是干啥的,但是至于后面的 x-content-type-options,一会儿我们再说它hhhhhhhh~

至于服务端的其他代码啥意思,咱们一行行解释:

onopen = (watchId, callback) => {
    // 用来记录长连接的路由
    this._watchId = watchId;
    this.app.get(watchId, (req, res) => {
      // 由于是参考的 k8s 的 list-watch
      // 所以这里可以设置一个 resourceVersion
      // 用于未来做前后端通信可靠性的提升
      // 当前可以先不用关注
      const resourceVersion = { current: 0 };
      // 设置响应头和状态码
      res.writeHead(this.SUCCESS_CODE, this.headers);
      // 由于其他客户端发来的请求会在之后发送给其他客户端
      // 所以这里可以先“同步地(不是真的同步, 你懂我的意思吧哈哈哈)”返回
      // 一条消息,该消息可以用于在客户端判断这条长连接是否创建成功
      // 这里就回复一个约定好的魔法字符串就行, 当然也可以自己定义
      res.write(`${this.OPEN_TAG}/${resourceVersion.current}\r\n`);

      // 这里对 res.write 方法做个 hack
      // 为的就是每次调用 res.write 的时候都让 resourceVersion 自动 +1
      // 当然,现在可以先不用关注,有个念想就好~~
      const _origin_write = res.write;
      res.write = (...args) => {
        resourceVersion.current++;
        args[0] = `${this.OPEN_TAG}/${resourceVersion.current}/${args[0]}\r\n`;
        _origin_write.apply(res, args);
      }

      // 由于客户端在发送这条 get 请求的时候
      // 其实是把 clientId 作为 query 放到了 url 后面
      const { clientId } = req.query;
      // 然后找到该 clientId 对应的之前注册过的那个 watcher
      const currentClient = this.watcherList.find((watcher) => (watcher.clientId === clientId));
      // 将该 clientId 对应的 res 赋值给 response
      // 这样的话之后任何时候其他地方都可以拿到该 clientId 对应的 res
      // 也就可以拿到 res.write 返回任何东西~~
      currentClient.response = res;
      callback('ok'); 
    });
    
    // 省略一些代码......
  }

客户端:

_connect = (callback) => {
    this
      .ajax()
      // 发请求建立长连接
      .get(`${this.baseUrl}${this.serverId}`)
      // 把自己的 clientId 带过去
      .data({ clientId: this.clientId })
      .callback((msg) => {
        // 这里的 callback 就是用来
        // 接收每次服务端通过长连接传过来的数据的
        if (msg) {
          // 这里设置一个 cacheResponseText
          // 因为服务端回送的消息在客户端是每次都会累加的
          // 假如服务端有如下代码:
          // let index = 1;
          // setTimeout(() => {
          //   res.write(index++);
          // }, 1000);
          // 每隔 1s 回送一个数字, 第一次 1, 第二次 2, 第三次 3, 第四次 4
          // 但是实际在客户端得到的是累加之后的结果:
          // 第一次 1, 第二次 12, 第三次 123, 第四次 1234
          // 所以这里为了让每次客户端拿到的是不累加的值
          // 就需要特殊处理一下
          // 当然处理方法可以用任何你觉得优雅的方法
          // 这里只是粗糙地使用 replace 处理一下
          const _msg = msg.replace(this.cacheResponseText, '').replace('\r\n', '').split('/');
          this.cacheResponseText = msg;
          const [ _magic_tag_, resourceVersion, currentMsg ] = _msg;
          if (_magic_tag_ !== this.OPEN_TAG) return console.log('response msg err, invaild list watch format');
          if (callback) {
            callback('ok');
            callback = null;
            this.opened = true;
          } else {
            // 第一次回送的消息是我们内部的消息
            // 从第二次开始才是我们真正从别的客户端同步过来的消息
            // 此时才应该回传给 onmessage 方法
            this._onmessage && this._onmessage(currentMsg.replaceAll('"', ''));
          }
        }
      })
      .send()
  }

好,现在我们的长连接方法也搞定了,还差啥,还差发送消息给服务端。我们添加如下代码:

服务端:

// 省略一些代码......

class ListWatch {
  // 省略一些代码......
  
  // 添加如下代码......
  CLIENT_MESSAGE_RECEIVE = `${this.__MAGIC_PRIVATE_STRING__}_client_message_receive`;
  
  // 省略一些代码......

  onopen = (watchId, callback) => {
    // 省略一些代码......
   
    // 添加如下代码......
    this.app.post(`${watchId}/${this.CLIENT_MESSAGE_RECEIVE}`, (req, res) => {
      const { clientId, message } = req.body;
      console.log(clientId, ":", message)
      const otherWatchers = this.watcherList.filter((watcher) => (watcher.clientId !== clientId));
      otherWatchers.forEach((watcher) => (watcher.response.write(message)));
      res.end();
    });
  }

  // 省略一些代码......
};

// 省略一些代码......

客户端:

// 省略一些代码......

class ListWatcher {
  // 省略一些代码......
  
  // 添加如下代码

  CLIENT_MESSAGE_RECEIVE = `${this.__MAGIC_PRIVATE_STRING__}_client_message_receive`;

  // 省略一些代码......
  
  // 添加如下代码......
  send = (msg) => {
    this
      .ajax()
      .post(`${this.baseUrl}${this.serverId}/${this.CLIENT_MESSAGE_RECEIVE}`)
      .data({ clientId: this.clientId, message: msg })
      .send()
  }
}

// 省略如下代码......

参考 onopen 中客户端向服务端注册自己的方法,这个客户端向服务端发送消息的方法也需要提前约定好一个路由,当然也可以通过不约定,由服务端随机生成之后通过长连接第一次推送过来,这里为了简化我们就约定好就可以了~

简单来讲,发送消息的方法就是:客户端将用户输入的 message 连带自己的 clientId 放进 body 体中带在 post 请求中,之后服务端收到请求后,可以根据带过来的 clinetId,从 watcherList 中把其他客户端 filter 出来最后将该 clinet 发过来的 message 通过调用过滤出来的其他 client 的 res.write 方法发送过去就 ojk 了~

最后回到上面我们说到的刚刚那个在 ajax 中留下的一个小坑儿,在这里:

const ajax = () => {
  return {
    // 省略一些代码......
    
    send: function() {
      this._method === 'GET' && this._xhr.open(this._method, this._url, true);
      this._method === 'GET' ? this._xhr.send() : this._xhr.send(this._getQuery(this._data));
      this._xhr.onreadystatechange = () => {
        if (this._xhr.readyState === 3 || this._xhr.readyState === 4) {
          if ( this._xhr.status == 200 ) {
            this._callback.length && this._callback.forEach((cb) => cb(this._xhr.responseText));
          }
        }
      }
    },

    // 省略一些代码......
  }
};

// 省略一些代码......

其实就在 send 方法中,不知道大家有没有发现,平时咱们使用 XMLHttpRequest 方法调用了 send() 之后都会监听 onreadystatechange 方法,在该方法中通常会判断 readyState === 4,只有为 4 的时候才说明客户端已经收到了全部的响应。但是在咱们的代码中却判断了 readyState === 3,其实是因为使用长连接的时候,每次服务端回送消息的时候由于浏览器还无法判断服务端是否已经将全部的响应发送完毕,所以此时的 readyState 将会一直是 3,所以这里也要将状态 3 也作为响应成功~

另外上面还有一个没有介绍到的响应头:

headers = {
    'Transfer-Encoding': 'chunked',
    'Content-Type':'text/plain; charset=UTF-8',
    'x-content-type-options':'nosniff',
  };

该响应头中,content-type 中的 charset=UTF-8 和 x-content-type-options: nosniff 一起使用的。其实由于每个浏览器的实现各不相同,有些浏览器就无法在每次长连接回送消息的时候及时触发 onreadystatechange 方法,对于 Firefox 浏览器来讲,可以不用设置什么特殊的响应头依旧可以每次触发,但是对于 Chrome 浏览器来讲,只有当响应中加上了如上两个响应头的时候,浏览器才会在每次长连接回送消息的时候触发 onreadystatechange 方法。具体的介绍可以参考如下两个链接:

  1. stackoverflow.com/questions/6…

  2. bugs.chromium.org/p/chromium/…

(注: 这里我们强调一下,关于上面服务端代码中的 'x-content-type-options':'nosniff',以及前端 ajax 代码中的 'this._xhr.readyState === 3' 的部分,这些部分均属于代码运行平台的“特性”代码,也就是说无论是处理 nosniff 这个响应头的行为,还是 readyStaye 行为的处理,都属于不同浏览器环境的自发行为,大家可以不用过分关注这些东西所以然的原理,而只需要重点关注 “http 协议中所要求的客服端应该对 Chunked 这个响应头做如何处理”这个所有平台都应该遵守的“共性”就够了。本文只是选择了浏览器这个平台,而这个平台最方便创建 http 链接的方式恰好就是 ajax,所以需要在这个平台下遵守 ajax 的使用规范,希望大家在看文章的时候可千万别把这种通信的方式当成是 ajax 的特性)


到这里,基本上我们就在不使用轮询以及 WebSocket 的情况下仅通过 http 协议实现了一个我们自己的双向即时通信系统。

该系统其实只是个初版,对于最开始我们简单介绍过的 k8s 中使用的 list-watch 机制来讲,还缺少很多东西,比如代码中的 resourceVersion 目前其实根本没有用上,感兴趣的同学可以之后自己实现一下~

最最后,该代码的仓库地址如下,同学们可以自己去 clone 玩玩看,或者完善一下,顺便求个星星,谢谢~

github.com/y805939188/…