SSE概述
传统的网页都是浏览器向服务器“查询”数据,但是很多场合,最有效的方式是服务器向浏览器“发送”数据。这要比浏览器按时向服务器查询(polling)更有效率。
服务器发送事件(Server-Sent Events,简称SSE)就是为了解决这个问题,而提出的一种新API,部署在EventSource对象上。目前,除了IE,其他主流浏览器都支持。
简单说,所谓SSE,就是浏览器向服务器发送一个HTTP请求,然后服务器不断单向地向浏览器推送“信息”(message)。这种信息在格式上很简单,就是“信息”加上前缀“data: ”,然后以“\n\n”结尾。
$ curl http://localhost/stream
data: 1394572346452
data: 1394572347457
data: 1394572348463
^C
SSE特点
SSE与WebSocket有相似功能,都是用来建立浏览器与服务器之间的通信渠道。两者的区别在于:
- WebSocket是全双工通道,可以双向通信,功能更强;SSE是单向通道,只能服务器向浏览器端发送,因为流信息本质上就是下载。
- WebSocket是一个新的协议,需要服务器端支持;SSE则是部署在HTTP协议之上的,现有的服务器软件都支持。
- SSE是一个轻量级协议,相对简单;WebSocket是一种较重的协议,相对复杂。
- SSE 一般只用来传送文本,二进制数据需要编码后传送;WebSocket 默认支持传送二进制数据。
- SSE默认支持断线重连;WebSocket则需要自己实现。
- SSE支持自定义发送的数据类型。
客户端代码
EventSource
SSE 的客户端 API 部署在EventSource对象上。下面的代码可以检测浏览器是否支持 SSE。
if (!!window.EventSource) {
// ...
}
//或者
if ('EventSource' in window) {
// ...
}
使用 SSE 时,浏览器首先生成一个EventSource实例,向服务器发起连接。
var source = new EventSource(url);
上面的url可以与当前网址同域,也可以跨域。跨域时,可以指定第二个参数,打开withCredentials属性,表示是否一起发送 Cookie。
var source = new EventSource(url, { withCredentials: true });
EventSource实例的readyState属性(source.readyState
),表明连接的当前状态。该属性只读,可以取以下值。
- 0:相当于常量EventSource.CONNECTING,表示连接还未建立,或者断线正在重连。
- 1:相当于常量EventSource.OPEN,表示连接已经建立,可以接受数据。
- 2:相当于常量EventSource.CLOSED,表示连接已断,且不会重连。
基本用法
open事件
连接一旦建立,就会触发open事件,可以定义相应的回调函数。
source.onopen = function(event) {
// handle open event
};
// 或者
source.addEventListener("open", function(event) {
// handle open event
}, false);
message事件
收到数据就会触发message事件。
source.onmessage = function(event) {
var data = event.data;
var origin = event.origin;
var lastEventId = event.lastEventId;
// handle message
};
// 或者
source.addEventListener("message", function(event) {
var data = event.data;
var origin = event.origin;
var lastEventId = event.lastEventId;
// handle message
}, false);
参数对象event有如下属性:
data:服务器端传回的数据(文本格式)。
origin: 服务器端URL的域名部分,即协议、域名和端口。
lastEventId:数据的编号,由服务器端发送。如果没有编号,这个属性为空。
error事件
如果发生通信错误(比如连接中断),就会触发error事件。
source.onerror = function(event) {
// handle error event
};
// 或者
source.addEventListener("error", function(event) {
// handle error event
}, false);
自定义事件
服务器可以与浏览器约定自定义事件。这种情况下,发送回来的数据不会触发message事件。
source.addEventListener("foo", function(event) {
var data = event.data;
var origin = event.origin;
var lastEventId = event.lastEventId;
// handle message
}, false);
上面代码表示,浏览器对foo事件进行监听。
close方法
close方法用于关闭连接。
source.close();
客户端实现
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title>JS Bin</title>
</head>
<body>
<div id="example"></div>
<script>
var source = new EventSource('http://127.0.0.1/stream');
var div = document.getElementById('example');
source.onopen = function (event) {
div.innerHTML += '<p>Connection open ...</p>';
};
source.onerror = function (event) {
div.innerHTML += '<p>Connection close.</p>';
};
source.addEventListener('connecttime', function (event) {
div.innerHTML += ('<p>Start time: ' + event.data + '</p>');
}, false);
source.addEventListener('myevent', function (event) {
div.innerHTML += ('<p>MyEvent: ' + event.data + '</p>');
}, false);
source.onmessage = function (event) {
div.innerHTML += ('<p>Ping: ' + event.data + '</p>');
div.innerHTML +=('<p>Message: ' + event.mymessage + '</p>');
};
</script>
</body>
</html>
服务端实现
数据格式
服务器端发送的数据的HTTP头信息如下:
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
后面的行都是如下格式:
field: value\n
field可以取四个值:“data”, “event”, “id”, or “retry”,也就是说有四类头信息。每次HTTP通信可以包含这四类头信息中的一类或多类。\n代表换行符。
以冒号开头的行,表示注释。通常,服务器每隔一段时间就会向浏览器发送一个注释,保持连接不中断。
: This is a comment
下面是一些例子。
: this is a test stream\n\n
data: some text\n\n
data: another message\n
data: with two lines \n\n
data:数据栏
数据内容用data表示,可以占用一行或多行。如果数据只有一行,则像下面这样,以“\n\n”结尾。
data: message\n\n
如果数据有多行,则最后一行用“\n\n”结尾,前面行都用“\n”结尾。
data: begin message\n
data: continue message\n\n
总之,最后一行的data,结尾要用两个换行符号,表示数据结束。以发送JSON格式的数据为例。
data: {\n
data: "foo": "bar",\n
data: "baz", 555\n
data: }\n\n
id:数据标识符
数据标识符用id表示,相当于每一条数据的编号。
id: msg1\n
data: message\n\n
浏览器用lastEventId属性读取这个值。一旦连接断线,浏览器会发送一个HTTP头,里面包含一个特殊的“Last-Event-ID”头信息,将这个值发送回来,用来帮助服务器端重建连接。因此,这个头信息可以被视为一种同步机制。
event栏:自定义信息类型
event头信息表示自定义的数据类型,或者说数据的名字。
event: foo\n
data: a foo event\n\n
data: an unnamed event\n\n
event: bar\n
data: a bar event\n\n
上面的代码创造了三条信息。第一条是foo,触发浏览器端的foo事件;第二条未取名,表示默认类型,触发浏览器端的message事件;第三条是bar,触发浏览器端的bar事件。下面是另一个例子:
event: userconnect
data: {"username": "bobby", "time": "02:33:48"}
event: usermessage
data: {"username": "bobby", "time": "02:34:11", "text": "Hi everyone."}
event: userdisconnect
data: {"username": "bobby", "time": "02:34:23"}
event: usermessage
data: {"username": "sean", "time": "02:34:36", "text": "Bye, bobby."}
retry:最大间隔时间
浏览器默认的是,如果服务器端三秒内没有发送任何信息,则开始重连。服务器端可以用retry头信息,指定通信的最大间隔时间。
retry: 10000\n
NodeJS实现
SSE 要求服务器与浏览器保持连接。对于不同的服务器软件来说,所消耗的资源是不一样的。Apache 服务器,每个连接就是一个线程,如果要维持大量连接,势必要消耗大量资源。Node 则是所有连接都使用同一个线程,因此消耗的资源会小得多,但是这要求每个连接不能包含很耗时的操作,比如磁盘的 IO 读写。
var http = require("http");
http.createServer(function (req, res) {
var fileName = "." + req.url;
if (fileName === "./stream") {
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("event: connecttime\n");
res.write("data: " + (new Date()) + "\n\n");
res.write("data: " + (new Date()) + "\n\n");
interval = setInterval(function() {
res.write("data: " + (new Date()) + "\n\n");
}, 1000);
/*
interval2 = setInterval(function() {
res.write("event: myevent\n");
res.write("data: " + (new Date()) + "\n\n");
}, 2000);
*/
req.connection.addListener("close", function () {
clearInterval(interval);
// clearInterval(interval2);
}, false);
}
}).listen(80, "127.0.0.1");
PHP代码实现
<?php
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache'); // 建议不要缓存SSE数据
/**
* Constructs the SSE data format and flushes that data to the client.
*
* @param string $id Timestamp/id of this connection.
* @param string $msg Line of text that should be transmitted.
*/
function sendMsg($id, $msg) {
echo "id: $id" . PHP_EOL;
echo "data: $msg" . PHP_EOL;
echo PHP_EOL;
ob_flush();
flush();
}
$serverTime = time();
sendMsg($serverTime, 'server time: ' . date("h:i:s", time()));
?>
Java代码实现
实现由两部分组成:一部分是用来产生数据的 org.eclipse.jetty.servlets.EventSource 接口的实现,另一部分是作为浏览器访问端点的继承自 org.eclipse.jetty.servlets.EventSourceServlet 类的 servlet 实现。
需要实现 EventSource 接口的 onOpen、onResume 和 onClose 方法,其中 onOpen 方法在浏览器端的连接打开的时候被调用,onResume 方法在浏览器端重新建立连接时被调用,onClose 方法则在浏览器关闭连接的时候被调用。onOpen 和 onResume 方法都有一个 EventSource.Emitter 接口类型的参数,可以用来发送数据。EventSource.Emitter 接口中包含的方法包括 data、event、comment、id 和 close 等,分别对应于通讯协议中各种不同类型的事件。而 onResume 方法还额外包含一个参数 lastEventId,表示通过 Last-Event-ID 头发送过来的最近一次事件的标识符。
EventSource 类对应的 servlet 实现比较简单,只需要继承自 EventSourceServlet 类并覆写 newEventSource 方法即可。在 newEventSource 方法的实现中,需要返回一个 MovementEventSource 类的对象
IE 支持
使用浏览器原生的 EventSource 对象的一个比较大的问题是 IE 并不提供支持。为了在 IE 上提供同样的支持,一般有两种办法。第一种办法是在其他浏览器上使用原生 EventSource 对象,而在 IE 上则使用简易轮询或 COMET 技术来实现;另外一种做法是使用 polyfill 技术,即使用第三方提供的 JavaScript 库来屏蔽浏览器的不同。本文使用的是 polyfill 技术,只需要在页面中加载第三方 JavaScript 库即可。应用本身的浏览器端代码并不需要进行改动。一般推荐使用第二种做法,因为这样的话,在服务器端只需要使用一种实现技术即可。
在 IE 上提供类似原生 EventSource 对象的实现并不简单。理论上来说,只需要通过 XMLHttpRequest 对象来获取服务器端的响应内容,并通过文本解析,就可以提取出相应的事件,并触发对应的事件处理方法。不过问题在于 IE 上的 XMLHttpRequest 对象并不支持获取部分的响应内容。只有在响应完成之后,才能获取其内容。由于服务器端推送事件使用的是一个长连接。当连接一直处于打开状态时,通过 XMLHttpRequest 对象并不能获取响应的内容,也就无法触发对应的事件。更具体的来说,当 XMLHttpRequest 对象的 readyState 为 3(READYSTATE_INTERACTIVE)时,其 responseText 属性是无法获取的。
为了解决 IE 上 XMLHttpRequest 对象的问题,就需要使用 IE 8 中引入的 XDomainRequest 对象。XDomainRequest 对象的作用是发出跨域的 AJAX 请求。XDomainRequest 对象提供了 onprogress 事件。当 onprogress 事件发生时,可以通过 responseText 属性来获取到响应的部分内容。这是 XDomainRequest 对象和 XMLHttpRequest 对象的最大不同,也是使用 XDomainRequest 对象来实现类似原生 EventSource 对象的基础。在使用 XDomainRequest 对象打开与服务器端的连接之后,当服务器端有新的数据产生时,可以通过 XDomainRequest 对象的 onprogress 事件的处理方法来进行处理,对接收到的数据进行解析,根据数据的内容触发相应的事件。
不过由于 XDomainRequest 对象本来的目的是发出跨域 AJAX 请求,考虑到跨域访问的安全性问题,XDomainRequest 对象在使用时的限制也比较严格。这些限制会影响到其作为 EventSource 对象的实现方式。具体的限制和解决办法如下所示:
- 服务器端的响应需要包含 Access-Control-Allow-Origin 头,用来声明允许从哪些域访问该 URL。“*”表示允许来自任何域的访问,不推荐使用该值。一般使用与当前应用相同的域,限制只允许来自当前域的访问。
- XDomainRequest 对象发出的请求不能包含自定义的 HTTP 头,这就限制了不能使用 Last-Event-ID 头来声明浏览器端最近一次接收到的事件的标识符。只能通过 HTTP 请求的其他方式来传递该标识符,如 GET 请求的参数或 POST 请求的内容体。
- XDomainRequest 对象的请求的内容类型(Content-Type)只能是“text/plain”。这就意味着,当使用 POST 请求时,服务器端使用的框架,如 servlet,不会对 POST 请求的内容进行自动解析,无法使用 HttpServletRequest 类的 getParameter 方法来获取 POST 请求的内容。只能在服务器端对原始的请求内容进行解析,获取到其中的参数的值。
- XDomainRequest 对象发出的请求中不包含任何与用户认证相关的信息,包括 cookie 等。这就意味着,如果服务器端需要认证,则需要通过 HTTP 请求的其他方式来传递用户的认证信息,比如 session 的 ID 等。
由于 XDomainRequest 对象的这些限制,服务器端的实现也需要作出相应的改动。这些改动包括返回 Access-Control-Allow-Origin 头;对于浏览器端发送的“text/plain”类型的参数进行解析;处理请求中包含的用户认证相关的信息。
本文的示例使用的 polyfill 库是 GitHub 上的 Yaffle 开发的 EventSource 项目,具体的地址见参考资源。在使用该 polyfill 库,并对服务器端的实现进行修改之后,就可以在 IE 8 及以上的浏览器中使用服务器推送事件。如果需要支持 IE 7,则只能使用简易轮询或 COMET 技术。本文的示例代码见参考资源。