分不清轮询、长轮询?不知道什么时候该用websocket还是SSE,看这篇就够了。

所谓的“实时推送”,从表面意思上来看是,客户端订阅的内容在发生改变时,服务器能够实时地通知客户端,进而客户端进行相应地反应。客户端不需要主观地发送请求去获取自己关心的内容,而是由服务器端进行“推送”。

注意上面的推送二字打了引号,这就意味着在现有的几种实现方式中,并不是服务器端主动地推送,而是通过一定的手段营造了一种实时的假象。就目前现有的几种技术而言,主要有以下几类:

文中会以一个简易聊天室的例子来分别通过上述的四种方式实现,代码地址mini-chatroom(存在些许bug,主要是为了做演示用)

overview

轮询(Short Polling)

轮询的实现原理:客户端向服务器端发送一个请求,服务器返回数据,然后客户端根据服务器端返回的数据进行处理;然后客户端继续向服务器端发送请求,继续重复以上的步骤,如果不想给服务器端太大的压力,一般情况下会设置一个请求的时间间隔。

shortPolling

使用轮询明显的优点是基础不需要额外的开发成本,请求数据,解析数据,作出响应,仅此而已,然后不断重复。缺点也显而易见:

代码实现

var ShortPollingNotification = {
  datasInterval: null,
  subscribe: function() {
    this.datasInterval = setInterval(function() {
      Request.getDatas().then(function(res) {
        window.ChatroomDOM.renderData(res);
      });
    }, TIMEOUT);
    return this.unsubscribe;
  },
  unsubscribe: function() {
    this.datasInterval && clearInterval(this.datasInterval);
  }
}

shortPolling

下面是对应的请求,注意左下角的请求数量一直在变化

shortNetwork

在上图中,每隔1s就会发送一个请求,看起来效果还不错,但是如果将timeout的值设置成5s,效果将大打折扣,如图:

shortPolling5s

长轮询(Long Polling)

长轮询的基本原理:客户端发送一个请求,服务器会hold住这个请求,直到监听的内容有改变,才会返回数据,断开连接,客户端继续发送请求,重复以上步骤。或者在一定的时间内,请求还得不到返回,就会因为超时自动断开连接。

longPolling

长轮询是基于轮询上的改进版本,主要是减少了客户端发起Http连接的开销,改成了在服务器端主动地去判断所关心的内容是否变化,所以其实轮询的本质并没有多大变化,变化的点在于:

代码实现

// 客户端
var LongPollingNotification = {
    // ....
    subscribe: function() {
      var that = this;

      // 设置超时时间
      Request.getV2Datas(this.getKey(),{ timeout: 10000 }).then(function(res) {
        var data = res.data;
        window.ChatroomDOM.renderData(res);
        // 成功获取数据后会再次发送请求
        that.subscribe();
      }).catch(function (error) {
        // timeout 之后也会再次发送请求
        that.subscribe();
      });
      return this.unsubscribe;
    }

    // ....
}

笔者采用的是express,默认不支持hold住请求,因此用了一个express-longpoll的库来实现。

下面是一个原生不用库的实现(这里只是介绍原理),整体的思路是:如果服务器端支持hold住请求的话,那么在一定的时间内会自轮询,然后期间通过比较key值,判断是否返回新数据

// 服务器端

router.get('/v2/datas', function(req, res) {
  const key = _.get(req.query, 'key', '');
  let contentKey = chatRoom.getContentKey();

  while (key === contentKey) {
    sleep.sleep(5);
    contentKey = chatRoom.getContentKey();
  }

  const connectors = chatRoom.getConnectors();
  const messages = chatRoom.getMessages();
  res.json({
    code: 200,
    data: { connectors: connectors, messages: messages, key: contentKey },
  });
});

以下是用 express-longpoll 的实现片段

// mini-chatroom/public/javascripts/server/longPolling.js

function pushDataToClient(key, longpoll) {
  var contentKey = chatRoom.getContentKey();

  if (key !== contentKey) {
    var connectors = chatRoom.getConnectors();
    var messages = chatRoom.getMessages();

    longpoll.publish(
      '/v2/datas',
      {
        code: 200,
        data: {connectors: connectors, messages: messages, key: contentKey},
      }
    );
  }
}

longpoll.create("/v2/datas", function(req, res, next) {
  key = _.get(req.query, 'key', '');
  pushDataToClient(key, longpoll);
  next();
});

intervalId = setInterval(function() {
  pushDataToClient(key, longpoll);
}, LONG_POLLING_TIMEOUT);

为了方便演示,我将客户端发起请求的timeout改成了4s,注意观察下面的截图:

longPollingNetwork

可以看到,断开连接的两种方式,要么是超时,要么是请求有数据返回。

基于iframe的长轮询模式

这种模式的具体的原理为:

具体可以参看这里

Websocket

The WebSocket Protocol enables two-way communication between a client running untrusted code in a controlled environment to a remote host that has opted-in to communications from that code.

The protocol consists of an opening handshake followed by basic message framing, layered over TCP.

The goal of this technology is to provide a mechanism for browser-based applications that need two-way communication with servers that does not rely on opening multiple HTTP connections (e.g., using XMLHttpRequest or iframe and long polling).

The WebSocket Protocol attempts to address the goals of existing bidirectional HTTP technologies in the context of the existing HTTP infrastructure; as such, it is designed to work over HTTP ports 80 and 443 as well as to support HTTP proxies and intermediaries, even if this implies some complexity specific to the current environment.

特征

websocket

关于Websocket API方面的知识,这里不再作讲解,可以自己查阅Websocket API MDN

兼容性

websocket兼容性良好,基本支持所有现代浏览器

websocket1

代码实现

笔者这里采用的是socket.io,是基于websocket的封装,提供了客户端以及服务器端的支持

// 客户端
var WebsocketNotification = {
  // ...
  subscribe: function(args) {
    var connector = args[1];
    this.socket = io();

    this.socket.emit('register', connector);

    this.socket.on('register done', function() {
      window.ChatroomDOM.renderAfterRegister();
    });

    this.socket.on('data', function(res) {
      window.ChatroomDOM.renderData(res);
    });

    this.socket.on('disconnect', function() {
      window.ChatroomDOM.renderAfterLogout();
    });
  }
  // ...
}

// 服务器端
var io = socketIo(httpServer);

io.on('connection', (socket) => {
  socket.on('register', function(connector) {
    chatRoom.onConnect(connector);

    io.emit('register done');

    var data = chatRoom.getDatas();
    io.emit('data', { data });
  });

  socket.on('chat', function(message) {
    chatRoom.receive(message);

    var data = chatRoom.getDatas();
    io.emit('data', { data });
  });
});

响应格式如下:

websocket-request-response

Server-Sent Events(SSE)

传统意义上服务器端不会主动推送给客户端消息,一般都是客户端主动去请求服务器端获取最新的数据。SSE就是一种可以主动从服务端推送消息的技术。

SSE的本质其实就是一个HTTP的长连接,只不过它给客户端发送的不是一次性的数据包,而是一个stream流,格式为text/event-stream,所以客户端不会关闭连接,会一直等着服务器发过来的新的数据流,视频播放就是这样的例子。

  • SSE 使用 HTTP 协议,现有的服务器软件都支持。WebSocket 是一个独立协议。
  • SSE 属于轻量级,使用简单;WebSocket 协议相对复杂。
  • SSE 默认支持断线重连,WebSocket 需要自己实现。
  • SSE 一般只用来传送文本,二进制数据需要编码后传送,WebSocket 默认支持传送二进制数据。
  • SSE 支持自定义发送的消息类型。

基本的使用方法,参看SSE API

sse

兼容性

目前除了IE以及低版本的浏览器不支持,基本支持绝大多数的现代浏览器。

sse2

代码实现

// 客户端
var SSENotification = {
  source: null,
  subscribe: function() {
    if ('EventSource' in window) {
      this.source = new EventSource('/sse');

      this.source.addEventListener('message', function(res) {
        const d = res.data;
        window.ChatroomDOM.renderData(JSON.parse(d));
      });
    }
    return this.unsubscribe;
  },
  unsubscribe: function () {
    this.source && this.source.close();
  }
}

// 服务器端
router.get('/sse', function(req, res) {
  const connectors = chatRoom.getConnectors();
  const messages = chatRoom.getMessages();
  const response = { code: 200, data: { connectors: connectors, messages: messages } };

  res.writeHead(200, {
    "Content-Type":"text/event-stream",
    "Cache-Control":"no-cache",
    "Connection":"keep-alive",
    "Access-Control-Allow-Origin": '*',
  });

  res.write("retry: 10000\n");
  res.write("data: " + JSON.stringify(response) + "\n\n");

  var unsubscribe = Event.subscribe(function() {
    const connectors = chatRoom.getConnectors();
    const messages = chatRoom.getMessages();
    const response = { code: 200, data: { connectors: connectors, messages: messages } };
    res.write("data: " + JSON.stringify(response) + "\n\n");
  });

  req.connection.addListener("close", function () {
    unsubscribe();
  }, false);
});

下面是控制台的情况,注意观察响应类型

sse-type

详情中注意查看请求类型,以及EventStream消息类型

sse

总结

参考连接