轮询、SSE、Web Socket

2,062 阅读8分钟

在阅读组内编码规范时,遇到了一个陌生的概念:SSE 。于是去查了下,它的全称是 Server-sent events 。这是一种实现服务端向客户端(Browser)“主动推送” 的技术,类似的技术还有轮询和 Web Socket。当然 SSE 和轮询一样它们实质上并不是主动推送,只是使用一种很糙的方式实现了 “等价” 的效果。本文将几种技术作简单介绍和总结,重在介绍思想,API的细节还是参考相关文档比较好。

轮询

短轮询

短轮询的实现思路很简单,即每隔一段时间就向服务器端发送 Http 请求,我看到网上的文章基本都会给短轮询加上一条特性:服务端不管数据有没有更新都会立即返回响应。对于这条特性,我个人认为是不太准确的。我以为短轮询的诞生与 Ajax 技术是强相关的,它是远古时代人们对网站实时性开始有了越来越高的要求后开始出现的一种通过牺牲服务端和客户端性能来提升实时性的方式。简单来说就是重复发请求,至于服务端要不要先判断数据有没有更新,那是服务端的事情了,当然,也有可能是我对短轮询的理解有偏差。

短轮询的客户端简单实现如下:

var xhr = new XMLHttpRequest();
    setInterval(function(){
        xhr.open('GET','/user');
        xhr.onreadystatechange = function(){

        };
        xhr.send();
    },1000)

短轮询的实现很简单,也好理解,可缺点也是显而易见的,对于一个基于短轮询的应用,如果同时有较大数量级的人在线,每个用户的客户端都会不断的向服务器发送 http 请求,会给服务器带来巨大并发压力。

因此,短轮询是难以控制的。它的实时性是由发送请求的间隔时间来控制的,这导致我们很难在实时性与良好性能间达到可以接受的平衡,一般情况下只能将其用于对实时性要求不高,同时连接数较少的应用。

长轮询

至于长轮询,和短轮询放在一起就很好理解,短轮询收到请求后返回响应、关闭连接。而我们知道 TCP 协议是支持长连接的,在此之上的 HTTP1.1 支持持久连接。因此我们可以在收到请求后 hold 住连接,等到服务端有消息推送时再返回响应关闭连接,这就是长轮询。

也就是说当服务器收到客户端发来的请求后,服务器端不会直接进行响应,而是先将这个请求挂起,然后判断服务器端数据是否有更新。如果有更新,则进行响应,如果一直没有数据,则到达一定的时间限制才返回。 客户端脚本中的响应处理函数会在处理完服务器返回的信息后,再次发出请求,重新建立连接。

长轮询和短轮询比起来,明显减少了很多不必要的http请求次数,相比之下节约了资源。长轮询的缺点在于,连接挂起也会导致资源的浪费。

客户端示例:

async function subscribe() {
  let response = await fetch("/subscribe");

  if (response.status == 502) {
    // 状态 502 是连接超时错误,
    // 连接挂起时间过长时可能会发生,
    // 远程服务器或代理会关闭它
    // 让我们重新连接
    await subscribe();
  } else if (response.status != 200) {
    // 一个 error —— 让我们显示它
    showMessage(response.statusText);
    // 一秒后重新连接
    await new Promise(resolve => setTimeout(resolve, 1000));
    await subscribe();
  } else {
    // 获取并显示消息
    let message = await response.text();
    showMessage(message);
    // 再次调用 subscribe() 以获取下一条消息
    await subscribe();
  }
}

subscribe();

服务端示例:

const Koa = require('koa');
const app = new Koa();

// response
app.use(async ctx => {
    let rel = await Promise.race([delay(1000 * 10), getRel(1000 * 5)]);
    ctx.body = rel;
});

function delay(ms) {
    return new Promise(resolve => {
        setTimeout(() => {
            resolve('delayed');
        }, ms);
    });
}

function getRel(ms) {
    return new Promise(resolve => {
        let time = new Date();
        let it = setInterval(() => {
            if (Date.now() - time > ms) {
                clearInterval(it);
                resolve('gotRel');
            }
        }, 10);
    });
}

const port = 3000;

app.listen(port, err => {
    if (err) {
        console.error(`err: ${err}`);
    }
    console.log(`server start listening ${port}`);
});

SSE

前面我们已经知道 Http 协议无法做到服务端主动推送消息,但是有一种取巧的办法,就是让服务端向客户端发送的是流信息(Streaming)。即:

Content-Type: text/event-stream
Cache-Control: no-cache, no-transform
Connection: keep-alive
X-Accel-Buffering: no

使用 Cache-Control:no-transform 这一行是因为如果你用了create-react-app等工具来转发你的请求,那么你的数据流很可能被压缩,造成你怎么也收不到响应。

而加上 X-Accel-Buffering: no 这一行是因为如果网站使用Nginx 方向代理,默认会对应用的响应做缓冲(buffering),导致应用返回的消息不会立马发出去。这点在Ngnix 官网中也是有说明的:

Sets the proxy buffering for this connection. Setting this to “no” will allow unbuffered responses suitable for Comet and HTTP streaming applications. Setting this to “yes” will allow the response to be cached

同样,我们将SSE 和 前面说的长轮询进行对比,SSE 的实现和长轮询是比较相似的,不同之处在于 SSE 中 每个连接不只发送一个消息。客户端发送一个请求后,服务端会保持这个连接,即使有新消息发送回客户端,我们仍然可以保持着连接,这样连接就可以消息的再次发送,由服务器单向发送给客户端。因为发送的不是一次性的数据包,而是一个数据流,会连续不断地发送过来,就像视频播放的数据流一样。

基本用法:

var evtSource = new EventSource(url);

如果发送事件的脚本不同源,应该创建一个新的包含URL和options参数的EventSource对象。例如,假设客户端脚本在example.com上:

const evtSource = new EventSource("//api.example.com/ssedemo.php", { withCredentials: true } );

ps:在我实际测试中,浏览器对 EventSource 的跨域支持似乎不是太好,不过也没继续深究了。

成功初始化一个事件源后,我们就可以通过 addeventlistener 为不同类型的事件添加监听函数或者将**事件分发(attach)**到对应属性上来监听从服务器发出的消息。

客户端示例:

<body>
    <button id="btn">建立连接</button>
    <button id="btn2">关闭连接</button>
    <div id="result"></div>
    <script>
        var btn=document.querySelector("#btn");
        var btn2=document.querySelector("#btn2");
        var result=document.querySelector("#result");
        var source;
        btn.onclick=function () {
            source=new EventSource("http://localhost:8088/sse");
            source.addEventListener("open",function () {
                result.innerHTML+="建立连接<br/>";
            },false);
            source.addEventListener("connecttime",function (e) {
                result.innerHTML+="连接已建立:"+e.data+"<br/>";
            },false);
            source.addEventListener("message",function (e) {
                result.innerHTML+="接受更新时间:"+e.data+"<br/>";
            },false)
        };
        btn2.onclick=function () {
            if(source){
                source.close();
                result.innerHTML+="关闭连接<br/>";
            }
        }
    </script>
</body>

服务端示例:

const onEvent = function(data) {
    res.write(`event: message\n`);
    res.write(`data: ${JSON.stringify(data)}\n\n`);
};

emitter.on('message', onEvent);

我们用\n来分隔每一行数据,用\n\n来分隔每一个事件。每一个事件中包含事件的type和事件的data,分别用两行来描述。比如上面是返回来一个message事件(若不指定事件类型,则默认message)。

而Koa 官网给出的示例是这样的:

var Transform = require('stream').Transform;
var inherits = require('util').inherits;

module.exports = SSE;

inherits(SSE, Transform);

function SSE(options) {
  if (!(this instanceof SSE)) return new SSE(options);

  options = options || {};
  Transform.call(this, options);
}

SSE.prototype._transform = function(data, enc, cb) {
  this.push('data: ' + data.toString('utf8') + '\n\n');
  cb();
};

然后我们将 SSE() 赋给 ctx.body 即可,要注意的一点是:

所有转换流的实现都必须提供 _transform() 方法来接收输入并生产输出。 transform._transform() 的实现会处理写入的字节,进行一些计算操作,然后使用 transform.push() 输出到可读流。

Web Socket

不同与上面几种,WebSocket是一种在单个 TCP 连接上进行全双工通信的协议,是 Http 协议的一个补充(借用 Http 协议来完成一部分握手)。Web Socket 通信协议于2011年被IETF定为标准RFC 6455,并由 RFC7936 补充规范。WebSocket API也被 W3C 定为标准。

WebSocket使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在WebSocket API中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。

客户端示例:

const socket = new WebSocket('ws://localhost:8080');

// Connection opened
socket.addEventListener('open', function (event) {
    socket.send('Hello Server!');
});

// Listen for messages
socket.addEventListener('message', function (event) {
    console.log('Message from server ', event.data);
});

Web Socket 服务端的实现就没 SSE 那么好搞了。。。不过还是有相关框架帮我们做了些封装:Socket.IO ,这是一个基于 Web Socket 的 Node.js 实时应用程序框架,并且对不支持 Web Socket 的浏览器降级成 comet / ajax 轮询。

另外 Web Socket 是二进制协议,通用性更好,而 SSE 是文本协议(通常使用UTF-8编码),当然了,你也可以通过转码使其能传输二进制数据。

其他

iframe 永久帧

iframe永久帧也是一种实现服务端推送的方式,非常 hack,也非常不实用。其做法就是在页面嵌入一个专用来接受数据的 iframe 页面,该页面由服务器注入相关信息,如 <script>parent.utils.exec("response")</script>

服务器不停的向iframe中写入类似的 script 标签和数据,实现另一种形式的服务端推送。不过永久帧的技术会导致主页面的加载条始终处于加载状态,体验很差。

主动推送的困境

当然,实际的主动推送场景也许要复杂的多,比如以下两个问题:

  • 移动端如何维持稳定的长连接?
  • 多端场景如何保证推送同步?

实在知识盲区,不过看到了一些解决方案。比如这个:百度云推送

参考