/ protocol

20 行代码写一个数据推送服务

前端同学都知道,HTTP 协议是没有办法做服务器推送的,但是当服务器向客户端声明接下来要发送流信息时,客户端就会保持连接打开,SSE 使用的就是这种原理。

SSE(Server-sent Events)是 WebSocket 的一种轻量代替方案,使用 HTTP 协议。

SSE 能做什么

理论上,SSE 和 WebSocket 做的是同一件事情。当你需要用新数据局部更新网络应用时,SSE 可以做到不需要用户执行任何操作,便可以完成。

举例我们要做一个统计系统的管理后台,我们想知道统计数据的实时情况。类似这种更新频繁、低延迟的场景,SSE 可以完全满足。

其他一些应用场景:例如邮箱服务的新邮件提醒,微博的新消息推送、管理后台的一些操作实时同步等,SSE 都是不错的选择。

SSE vs. WebSocket

SSE 是单向通道,只能服务器向客户端发送消息,如果客户端需要向服务器发送消息,则需要一个新的 HTTP 请求。这对比 WebSocket 的双工通道来说,会有更大的开销。这么一来的话就会存在一个「什么时候才需要关心这个差异?」的问题,如果平均每秒会向服务器发送一次消息的话,那应该选择 WebSocket。如果一分钟仅 5 - 6 次的话,其实这个差异并不大。

在浏览器兼容方面,两者差不多。在较早之前,每当需要建立双向 Socket 时就会使用 Flash,在移动浏览器不支持 Flash 的情况下,WebSocket 的兼容是比较难做的。

SSE 我认为最大的优势是便利:

  • 实现一个完整的服务仅需要少量的代码;
  • 可以在现有的服务中使用,不需要启动一个新的服务;
  • 可以用任何一种服务端语言中使用;
  • 基于 HTTP/HTTPS 协议,可以直接运行于现有的代理服务器和认证技术。

有了这些优势,在选择使用 SSE 时就已经为自己的项目节约了不少成本。

简单示例

下面是一个简单的示例,实现一个 SSE 服务。

服务器

'use strict';

const http = require('http');

http.createServer((req, res) => {

  // 服务器声明接下来发送的是事件流
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
    'Access-Control-Allow-Origin': '*',
  });

  // 发送消息
  setInterval(() => {
    res.write('event: slide\n'); // 事件类型
    res.write(`id: ${+new Date()}\n`); // 消息ID
    res.write('data: 7\n'); // 消息数据
    res.write('retry: 10000\n'); // 重连时间
    res.write('\n\n'); // 消息结束
  }, 3000);

  // 发送注释保持长连接
  setInterval(() => {
    res.write(': \n\n');
  }, 12000);
}).listen(2000);

服务器首先向客户端声明接下来发送的是事件流(text/event-stream)类型的数据,然后就可以向客户端多次发送消息。

事件流是一个简单的文本流,仅支持 UTF-8 格式的编码。每条消息以一个空行作为分隔符。

在规范中为消息定义了 4 个字段:

event 消息的事件类型。客户端收到消息时,会在当前的 EventSource 对象上触发一个事件,这个事件的名称就是这个字段的值,如果消息没有这个字段,客户端的 EventSource 对象就会触发默认的 message 事件。

id 这条消息的 ID。客户端接收到消息后,会把这个 ID 作为内部属性 Last-Event-ID,在断开重连成功后,会把 Last-Event-ID 发送给服务器。

data 消息的数据字段。客户端会把这个字段解析为字符串,如果一条消息有多个 data 字段,客户端会自动用换行符连接成一个字符串。

retry 指定客户端重连的时间。只接受整数,单位是毫秒。如果这个值不是整数则会被自动忽略。

一个很有意思的地方是,规范中规定以冒号开头的消息都会被当作注释,一条普通的注释(:\n\n)对于服务器来说只占 5 个字符,但是发送到客户端上的时候不会触发任何事件,这对客户端来说是非常友好的。所以注释一般被用于维持服务器和客户端的长连接。

客户端

我们创建了一个 EventSource 对象,传入参数:url。并且根据服务器的状态和发送的信息作出响应。

'use strict';

if (window.EventSource) {

  // 创建 EventSource 对象连接服务器
  const source = new EventSource('http://localhost:2000');

  // 连接成功后会触发open事件
  source.addEventListener('open', () => {
    console.log('Connected');
  }, false);

  // 服务器发送信息到客户端时,如果没有event字段,默认会触发message事件
  source.addEventListener('message', e => {
    console.log(`data: ${e.data}`);
  }, false);

  // 自定义EventHandler,在收到event字段为slide的消息时触发
  source.addEventListener('slide', e => {
    console.log(`data: ${e.data}`); // => data: 7
  }, false);

  // 连接异常时会触发error事件并自动重连
  source.addEventListener('error', e => {
    if (e.target.readyState === EventSource.CLOSED) {
      console.log('Disconnected');
    } else if (e.target.readyState === EventSource.CONNECTING) {
      console.log('Connecting...');
    }
  }, false);
} else {
  console.error('Your browser doesn\'t support SSE');
}

EventSource从父接口 EventTarget 中继承了属性和方法,其内置了 3EventHandler 属性、2 个只读属性和 1 个方法:

EventHandler 属性

EventSource.onopen 在连接打开时被调用。

EventSource.onmessage 在收到一个没有 event 属性的消息时被调用。

EventSource.onerror 在连接异常时被调用。

只读属性

EventSource.readyState 一个 unsigned short 值,代表连接状态。可能值是CONNECTING (0), OPEN (1), 或者 CLOSED (2)。

EventSource.url 连接的 URL。

方法

EventSource.close() 关闭连接。

SSE 如何保证数据完整性

客户端在每次接收到消息时,会把消息的 id 字段作为内部属性 Last-Event-ID 储存起来。

SSE 默认支持断线重连机制,在连接断开时会触发EventSource 的 error 事件,同时自动重连。再次连接成功时 EventSource 会把 Last-Event-ID 属性作为请求头发送给服务器,这样服务器就可以根据这个 Last-Event-ID 作出相应的处理。

这里需要注意的是,id 字段不是必须的,服务器有可能不会在消息中带上 id 字段,这样子客户端就不会存在 Last-Event-ID 这个属性。所以为了保证数据可靠,我们需要在每条消息上带上 id 字段。

减少开销

在 SSE 的草案中提到,"text/event-stream" 的 MIME 类型传输应当在静置 15 秒后自动断开。在实际的项目中也会有这个机制,但是断开的时间没有被列入标准中。

为了减少服务器的开销,我们也可以有目的的断开和重连。

简单的办法是服务器发送一个关闭消息并指定一个重连的时间戳,客户端在触发关闭事件时关闭当前连接并创建一个计时器,在重连时把计时器销毁。

'use strict';

function connectSSE() {
  if (window.EventSource) {
    const source = new EventSource('http://localhost:2000');
    let reconnectTimeout;

    source.addEventListener('open', () => {
      console.log('Connected');
      clearTimeout(reconnectTimeout);
    }, false);

    source.addEventListener('pause', e => {
      source.close();
      const reconnectTime = +e.data;
      const currentTime = +new Date();
      reconnectTimeout = setTimeout(() => {
        connectSSE();
      }, reconnectTime - currentTime);
    }, false);
  } else {
    console.error('Your browser doesn\'t support SSE');
  }
}

connectSSE();

浏览器兼容

EventSource 浏览器兼容
Broswer support of EventSource from Can I Use...

向下兼容

早些时候,为了实现数据实时更新最常见的方法就是轮询。

轮询是以一个固定频率向服务器发送请求,服务器在有数据更新时返回新的数据,以此来管理数据的更新。这种轮询的方式不但开销大,而且更新的效率和频率有关,也不能达到及时更新的目的。

接着便出现了长轮询的方式:客户端向服务器发送请求之后,服务器会暂时把请求挂起,等到有数据更新时再返回最新的数据给客户端,客户端在接收到新的消息后再向服务器发送请求。与常规轮询的不同之处是:数据可以做到实时更新,可以减少不必要的开销。

这里有一个「选择长轮询还是常规轮询?」的命题,长轮询是不是总比常规轮询占有优势?我们可以从带宽占用的角度分析,如果一个程序数据更新太过频繁,假设每秒 2 次更新,如果使用长轮询的话每分钟要发送 120 次 HTTP 请求。如果使用常规轮询,每 5 秒发送一次请求的话,一分钟才 20 次,从这里看,常规轮询更占有优势。

长轮询和 SSE 最关键的区别在于,每一次数据更新都需要一次 HTTP 请求。和 WebSocket 还有 SSE 一样,长轮询也会占用一个 socket。在数据更新效率上和 SSE 差不多,一有数据更新就能检测到。加上所有浏览器都支持,是一个不错的 SSE 替代方案。

结尾

文章介绍了 SSE 的用法及使用过程中的一些技巧。对比 WebSocket,SSE 在开发时间和成本上占有较大的优势。做数据推送服务,除了 WebSocket,SSE 也是一个不错的选择,希望对大家有所帮助。

参考

Server-Sent Events
EventSource - Web APIs | MDN
Using server-sent events - Web APIs | MDN

版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证) 转载请注明出处

何启邦

何启邦

Only when you plant the flowers can you really smell their fragrance.

Read More