V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
V2EX 提问指南
boizz
V2EX  ›  问与答

SSE:使用 HTTP 做数据推送应用

  •  
  •   boizz · 2017-08-01 16:45:50 +08:00 · 7067 次点击
    这是一个创建于 2677 天前的主题,其中的信息可能已经有所发展或是发生改变。

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

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

    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

    9 条回复    2017-08-02 11:14:48 +08:00
    lyhiving
        1
    lyhiving  
       2017-08-01 17:12:48 +08:00 via Android
    没发现跟 ws 有什么大的优势,属于又一个轮子?
    hjc4869
        2
    hjc4869  
       2017-08-01 17:19:42 +08:00
    又一个让人误解的简称……
    ericFork
        3
    ericFork  
       2017-08-01 18:19:42 +08:00
    用过,比 WS 用起来简单些,当时用来做终端内容往网页上显示
    yyfearth
        4
    yyfearth  
       2017-08-01 19:10:01 +08:00
    @lyhiving 这个已经是标准的一部分了 好像是和 WS 同时间标准化的 所以不能说是又一个轮子
    和 WS 其实完全不是一个东西 SSE 基本上就是改进版的长轮询 其实 HTTP2 才是一个大轮子

    WS 是用来做“实时双向通讯”的 但是大部分人只用 WS 做服务器端 Push 其实是大材小用了 因为 WS 是一个全新的协议
    而 SSE 基于 HTTP 1.1 实现了服务器端 Push 比 WS 简单很多 而且有 Polyfill 可以支持几乎所有主流浏览器

    对于后端语言 SSE 也比 WS 容易实现很多 比如老版本的 PHP 就没办法直接实现 WS 但是可以实现 SSE
    如果没有形成 WS 库的后端语言 要实现一个 WS 协议很难 但是实现一个 SSE 基本上没什么问题

    不过不管是 WS 还是 SSE 对于 HTTP2 而言 都是要被淘汰的东西 因为 HTTP2 实现了 WS 和 SSE 需要的功能 而且开销要小很多 目前只是没有很好的 API 来操作罢了
    可能以后会出来 WS 和 SSE 的 HTTP2 版本 API 基本不变 但是底层换成 HTTP2 实现
    Septembers
        5
    Septembers  
       2017-08-01 20:44:19 +08:00
    实际上有个类似的标准化协议
    mdn.io/EventSource
    Septembers
        6
    Septembers  
       2017-08-01 20:45:04 +08:00
    呃呃呃(没看清
    yyfearth
        7
    yyfearth  
       2017-08-02 04:45:27 +08:00
    @Septembers EventSource 就是 SSE 的 API interface
    boizz
        8
    boizz  
    OP
       2017-08-02 11:13:02 +08:00
    @Septembers 实际上这个就是使用 EventSource 来实现的
    boizz
        9
    boizz  
    OP
       2017-08-02 11:14:48 +08:00
    @yyfearth 学习了
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2633 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 23ms · UTC 01:57 · PVG 09:57 · LAX 17:57 · JFK 20:57
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.