各种“服务器推”技术原理与实例(Polling/COMET/SSE/WebSocket)

前言

服务器推(Server Push)是一类特定技术的总称。通常状况,客户端与服务器的交互方式是:客户端发起请求,服务器收到请求返回响应结果,客户端接收响应结果进行处理。从上述的交互过程当中能够看出,客户端想要获取数据,须要自主地向服务端发起请求,获取相关数据。javascript

在大多数场景下,客户端的“主动式”行为已经能够知足需求了。然而,在一些场景下,须要服务器“主动”向客户端推送数据。例如:html

  • 聊天室或者对话类应用
  • 实时的数据监控与统计
  • 股票财经类看板等等

这类应用有几个重要特色:要求较高的实时性,同时客户端没法预期数据更新周期,在服务端获取最新数据时,须要将信息同步给客户端。这类应用场景被称为“服务器推”(Server Push)。前端

“服务器推”技术由来已久,从最初的简单轮询,到后来基于长轮询的COMET,到HTML5规范的SSE,以及实现全双工的WebSocket协议,“服务器推”的技术不断发展。本文会介绍这些技术的基本原理以及实现方式,来帮助你们迅速了解与掌握“服务器推”各种技术的基本原理。文末会附上完整的demo地址。java

1. 简易轮询

简易轮询是“解决”该问题最简陋的一个技术方式。node

简易轮询本质上就是在前端建立一个定时器,每隔必定的时间去查询后端服务,若是有数据则进行相应的处理。git

function polling() {
    fetch(url).then(data => {
        process(data);
        return;
    }).catch(err => {
        return;
    }).then(() => {
        setTimeout(polling, 5000);
    });
}

polling();
复制代码

轮询开始时,向后端发送请求,待响应结束后,间隔必定时间再去请求数据,如此循环往复。效果以下:github

这种作法的优势就是很是简单,几乎不须要进行任何额外的配置或开发。web

而与此同时,缺点也十分明显。首先,这种至关于定时轮询的方式在获取数据上存在显而易见的延迟,要想下降延迟,只能缩短轮询间隔;而另外一方面,每次轮询都会进行一次完整的HTTP请求,若是没有数据更新,至关因而一次“浪费”的请求,对服务端资源也是一种浪费。ajax

所以,轮询的时间间隔须要进行仔细考虑。轮询的间隔过长,会致使用户不能及时接收到更新的数据;轮询的间隔太短,会致使查询请求过多,增长服务器端的负担。算法

2. COMET

随着web应用的发展,尤为是基于ajax的web2.0时代中web应用需求与技术的发展,基于纯浏览器的“服务器推”技术开始受到较多关注,Alex Russell(Dojo Toolkit 的项目 Lead)称这种基于HTTP长链接、无须在浏览器端安装插件的“服务器推”技术为“Comet”。

经常使用的COMET分为两种:基于HTTP的长轮询(long-polling)技术,以及基于iframe的长链接流(stream)模式。

2.1 基于HTTP的长轮询(long-polling)

在简单轮询中,咱们会每隔必定的时间向后端请求。这种方式最大的问题之一就是,数据的获取延迟受限于轮询间隔,没法第一时间获取服务想要推送数据。

长轮询是再此基础上的一种改进。客户端发起请求后,服务端会保持住该链接,直到后端有数据更新后,才会将数据返回给客户端;客户端在收到响应结果后再次发送请求,如此循环往复。关于简单轮询与长轮询的区别,一图胜千言:

这样,服务端一旦有数据想要推送,能够及时送达到客户端。

function query() {
    fetchMsg('/longpolling')
        .then(function(data) {
            // 请求结束,触发事件通知eventbus
            eventbus.trigger('fetch-end', {data, status: 0});
        });
}

eventbus.on('fetch-end', function (result) {
    // 处理服务端返回的数据
    process(result);
    // 再次发起请求
    query();
});
复制代码

以上是一段简略版的前端代码,经过eventbus来通知请求结束,收到结束消息后,process(result)处理所需数据,同时再次调用query()发起请求。

而在服务端,以node为例,服务端只须要在监听到有消息/数据更新时,再进行返回便可。

const app = http.createServer((req, res) => {
    // 返回数据的方法
    const longPollingSend = data => {
        res.end(data);
    };

    // 当有数据更新时,服务端“推送”数据给客户端
    EVENT.addListener(MSG_POST, longPollingSend);

    req.socket.on('close', () => {
        console.log('long polling socket close');
        // 注意在链接关闭时移除监听,避免内存泄露
        EVENT.removeListener(MSG_POST, longPollingSend);
    });
});
复制代码

效果以下:

2.2 基于iframe的长链接流(stream)模式

当咱们在页面中嵌入一个iframe并设置其src时,服务端就能够经过长链接“源源不断”地向客户端输出内容。

例如,咱们能够向客户端返回一段script标签包裹的javascript代码,该代码就会在iframe中执行。所以,若是咱们预先在iframe的父页面中定义一个处理函数process(),而在每次有新数据须要推送时,在该链接响应中写入<script>parent.process(${your_data})</script>。那么iframe中的这段代码就会调用父页面中预先定义的process()函数。(是否是有点像JSONP传输数据的方式?)

// 在父页面中定义的数据处理方法
function process(data) {
    // do something
}

// 建立不可见的iframe
var iframe = document.createElement('iframe');
iframe.style = 'display: none';
// src指向后端接口
iframe.src = '/long_iframe';
document.body.appendChild(iframe);
复制代码

后端仍是以node为例

const app = http.createServer((req, res) => {
    // 返回数据的方法,将数据拼装成script脚本返回给iframe
    const iframeSend = data => {
        let script = `<script type="text/javascript"> parent.process(${JSON.stringify(data)}) </script>`;
        res.write(script);
    };

    res.setHeader('connection', 'keep-alive');
    // 注意设置相应头的content-type
    res.setHeader('content-type', 'text/html; charset=utf-8');        
    // 当有数据更新时,服务端“推送”数据给客户端
    EVENT.addListener(MSG_POST, iframeSend);

    req.socket.on('close', () => {
        console.log('iframe socket close');
        // 注意在链接关闭时移除监听,避免内存泄露
        EVENT.removeListener(MSG_POST, iframeSend);
    });
});
复制代码

效果以下:

不过使用iframe有个小瑕疵,所以这个iframe至关于永远也不会加载完成,因此浏览器上会一直有一个loading标志。

总得来讲,长轮询和iframe流这两种COMET技术,具备了不错的实用价值,其特色在于兼容性很是强,不须要客户端或服务端支持某些新的特性。不过,为了便于处理COMET使用时的一些问题,仍是推荐在生产环境中考虑一些成熟的第三方库。值得一提的是,Socket.io在不兼容WebSocket(咱们后面会提到)的浏览器中也会回退到长轮询模式。

然而,COMET技术并非HTML5标准的一部分,从兼容标准的角度出发的话,并不推荐使用。(尤为在咱们有了一些其余技术以后)

3. SSE (Server-Sent Events)

SSE (Server-Sent Events) 是HTML5标准中的一部分。其实现原理相似于咱们在上一节中提到的基于iframe的长链接模式。

HTTP响应内容有一种特殊的content-type —— text/event-stream,该响应头标识了响应内容为事件流,客户端不会关闭链接,而是等待服务端不断得发送响应结果。

SSE规范比较简单,主要分为两个部分:浏览器中的EventSource对象,以及服务器端与浏览器端之间的通信协议。

在浏览器中能够经过EventSource构造函数来建立该对象

var source = new EventSource('/sse');
复制代码

而SSE的响应内容能够当作是一个事件流,由不一样的事件所组成。这些事件会触发前端EventSource对象上的方法。

// 默认的事件
source.addEventListener('message', function (e) {
    console.log(e.data);
}, false);

// 用户自定义的事件名
source.addEventListener('my_msg', function (e) {
    process(e.data);
}, false);

// 监听链接打开
source.addEventListener('open', function (e) {
    console.log('open sse');
}, false);

// 监听错误
source.addEventListener('error', function (e) {
    console.log('error');
});

复制代码

EventSource经过事件监听的方式来工做。注意上面的代码监听了my_msg事件,SSE支持自定义事件,默认事件经过监听message来获取数据。

SSE中,每一个事件由类型和数据两部分组成,同时每一个事件能够有一个可选的标识符。不一样事件的内容之间经过仅包含回车符和换行符的空行("\r\n")来分隔。每一个事件的数据可能由多行组成。

  • 类型为空白,表示该行是注释,会在处理时被忽略。
  • 类型为 data,表示该行包含的是数据。以 data 开头的行能够出现屡次。全部这些行都是该事件的数据。
  • 类型为 event,表示该行用来声明事件的类型。浏览器在收到数据时,会产生对应类型的事件。例如我在上面自定义的my_msg事件。
  • 类型为 id,表示该行用来声明事件的标识符。
  • 类型为 retry,表示该行用来声明浏览器在链接断开以后进行再次链接以前的等待时间。

能够看到,SSE确实是一个比较简单的协议规范,服务端实现也比较简单:

const app = http.createServer((req, res) => {
    const sseSend = data => {
        res.write('retry:10000\n');            
        res.write('event:my_msg\n');
        // 注意文本数据传输
        res.write(`data:${JSON.stringify(data)}\n\n`);
    };

    // 注意设置响应头的content-type
    res.setHeader('content-type', 'text/event-stream');
    // 通常不会缓存SSE数据
    res.setHeader('cache-control', 'no-cache');
    res.setHeader('connection', 'keep-alive');
    res.statusCode = 200;

    res.write('retry:10000\n');
    res.write('event:my_msg\n\n');

    EVENT.addListener(MSG_POST, sseSend);

    req.socket.on('close', () => {
        console.log('sse socket close');
        EVENT.removeListener(MSG_POST, sseSend);
    });
});
复制代码

效果以下:

此外,咱们还能够考虑结合HTTP/2的优点来使用SSE。然而,一个可能不太好的消息是,IE/Edge并不兼容。

固然,你能够经过一些手段来写一个兼容IE的polyfill。不过,因为IE上的XMLHttpRequest对象并不支持获取部分的响应内容,所以只能使用XDomainRequest来替代,固然,这也致使了一些小问题。若是你们对具体的实现细节感兴趣,能够看一下这个polyfill库Yaffle/EventSource

4. WebSocket

WebSocket与http协议同样都是基于TCP的。WebSocket其实不只仅限于“服务器推”了,它是一个全双工的协议,适用于须要进行复杂双向数据通信的场景。所以也有着更复杂的规范。

当客户端要和服务端创建WebSocket链接时,在客户端和服务器的握手过程当中,客户端首先会向服务端发送一个HTTP请求,包含一个Upgrade请求头来告知服务端客户端想要创建一个WebSocket链接。

在客户端创建一个WebSocket链接很是简单:

var ws = new WebSocket('ws://127.0.0.1:8080');
复制代码

固然,相似于HTTPHTTPSws相对应的也有wss用以创建安全链接。

这时的请求头以下:(注意其中的Upgrade字段)

Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Cache-Control: no-cache
Connection: Upgrade
Cookie: Hm_lvt_4e63388c959125038aabaceb227cea91=1527001174
Host: 127.0.0.1:8080
Origin: http://127.0.0.1:8080
Pragma: no-cache
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
Sec-WebSocket-Key: 0lUPSzKT2YoUlxtmXvdp+w==
Sec-WebSocket-Version: 13
Upgrade: websocket
复制代码

而服务器在收到请求后进行处理,响应头以下

Connection: Upgrade
Origin: http://127.0.0.1:8080
Sec-WebSocket-Accept: 3NOOJEzyscVfEf0q14gkMrpV20Q=
Upgrade: websocket
复制代码

表示升级到了WebSocket协议。

注意,上面的请求头中有一个Sec-WebSocket-Key,这其实和加密、安全性关系不大,最主要的做用是来验证服务器是否真的正确“理解”了WebSocket、该WebSocket链接是否有效。服务器会使用Sec-WebSocket-Key,并根据一个固定的算法

mask = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";  // 一个规定的字符串
accept = base64(sha1(key + mask));
复制代码

生成Sec-WebSocket-Accept响应头字段,交由浏览器验证。

接下来,浏览器与服务器之间就能够愉快地进行双向通讯了。

鉴于篇幅,关于WebSocket协议的具体规范与细节(例如数据帧格式、心跳检查等)就不在这里深刻了,网络上也有不少这类的不错的文章能够阅读,感兴趣的读者也能够阅读本文最后的参考资料。

下面简单介绍一下WebSocket的使用。

在浏览器端,创建WebSocket链接后,能够经过onmessage来监听数据信息。

var ws = new WebSocket('ws://127.0.0.1:8080');

ws.onopen = function () {
    console.log('open websocket');
};

ws.onmessage = function (e) {
    var data = JSON.parse(e.data);
    process(data);
};
复制代码

在服务器端,因为WebSocket协议具备较多的规范与细节须要处理,所以建议使用一些封装较完备的第三方库。例如node中的websocket-node和著名的socket.io。固然,其余语言也有许多开源实现。node部分代码以下:

const http = require('http');
const WebSocketServer = require('websocket').server;

const app = http.createServer((req, res) => {
    // ...
});
app.listen(process.env.PORT || 8080);

const ws = new WebSocketServer({
    httpServer: app
});

ws.on('request', req => {
    let connection = req.accept(null, req.origin);
    let wsSend = data => {
        connection.send(JSON.stringify(data));
    };
    // 接收客户端发送的数据
    connection.on('message', msg => {
        console.log(msg);
    });
    connection.on('close', con => {
        console.log('websocket close');
        EVENT.removeListener(MSG_POST, wsSend);
    });
    // 当有数据更新时,使用WebSocket链接来向客户端发送数据
    EVENT.addListener(MSG_POST, wsSend);
});
复制代码

效果以下:

写在最后

服务器推(Server Push)做为一类特定的技术,在一些业务场景中起到了重要的做用,了解各种技术实现的原理与特色,有利于在实际的业务场景中帮助咱们作出必定的选择与判断。

为了便于理解文中的内容,我把全部代码整理在了一个demo里,感兴趣的朋友能够在这里下载,并在本地运行查看。

参考资料

相关文章
相关标签/搜索