Server-sent Events学习记录

服务器推送事件(Server-sent Events,简称SSE,下同)是HTML5规范中的一个组成部分,可以用来从服务端实时推送数据到浏览器端。相对于WebSocket技术来说,SSE只是单向通信(只能实现服务器向浏览器推送消息,而浏览器不能通过sse向服务器主动发送消息),使用起来也更加简单,对服务器端的改动也比较小,特别适合于诸如监控数据、消息推送等应用场景。

SSE简述

Server-sent Events比较简单,主要由两个部分组成:

  1. 第一个部分是服务器端与浏览器端之间的通讯协议(基于纯文本);
  2. 第二部分则是在浏览器端可供 JavaScript 使用的 EventSource 对象。

通信协议

这里详细介绍SSE的通信协议。

  • SSE的通讯协议是基于纯文本的简单协议,即服务端和浏览器之间采用纯文本进行通信。
  • 服务器端响应的头部信息(内容类型)Content-Type必须是text/event-stream
  • 响应文本的内容可以看成是一个事件流(Event stream),由不同的事件所组成。
  • 事件流(Event stream)强制使用UTF8编码,且无法修改编码方式;
  • 每个事件由类型(event)数据(data)两部分组成,同时每个事件可以有一个可选的标识符(id)
  • 事件流中每行的结尾可以是CRLFLFCR三者中的任意一个。(CRLF是Carriage-Return Line-Feed的缩写,意思是回车换行,就是回车(CR, ASCII 13, \r) 与换行(LF, ASCII 10, \n))
  • 每个事件的数据可能由多行组成,每个事件之间通过额外的空行(CRLFLFCR三者中的任意一个)来分隔。
  • 对于每一行来说,冒号(:)前面表示的是该行的类型,冒号后面则是对应的值(可以为空)。其事件类型如下:

事件类型

SSE的事件类型可分为五类。

  1. 类型为 空白,表示该行是注释,会在处理时被忽略。举例如下:

    • 空白注释

      :

    • 带描述的注释

      :this is a commont

  2. 类型为 data,表示该行包含的是数据。以data开头的行可以连续出现多次,所有这些行都是该事件的数据。多行data最终的数据每行与每行中间都有一个\n,但最后没有\n。举例如下:

    • 单行data,最终data为:”sse event”

      data:sse event

    • 多行data,最终data为:”AAAA\nBBBB”

      data: AAAA
      data:BBBB

  3. 类型为 event,表示该行用来声明事件的类型。浏览器在收到数据时,会产生对应类型的事件。举例如下:

    • 自定义myevnet,可触发source.addEventListener('myevent', (event) => {console.log(event.data)})。其中,event.data==='my event data\ncontinue'

      event: myevent
      data: my event data
      data:continue

  4. 类型为 id,表示该行用来声明事件的标识符(整数字符串)。标识符id主要用在尝试重连的请求头Last-Event-ID字段中,给服务器提供信息。举例如下:

    • 指定标识符id,如果此时断开连接,下次重连时的请求头中会自动将’30’放在Last-Event-ID字段中,服务器可以根据这个请求头字段做特定的处理。

      id: 30
      data: dddd

  5. 类型为 retry,表示该行用来声明浏览器在连接断开之后进行再次连接之前的等待时间(毫秒数),服务器可以动态调节推送频率。

    • 指定下次重连时间为3000ms,可动态变化

      retry: 3000

事件数据

服务器端响应内容的示例

1
2
3
4
5
6
7
8
9
10
11
12
13
data: first event
data: second event
id: 100
event: myevent
data: third event
retry: 3000
id: 101
: this is a comment,注意最后一行的多余空行
data: fourth event
data: fourth event continue

相关HTTP header

1
2
3
4
5
// 响应头
Content-Type:text/event-stream;
Cache-Control:no-cache
// 请求头(只有在SSE重连时,浏览器端才会找到上一个合法的id,并通过以下请求头字段发送给服务器)
Last-Event-ID:2

特别注意

  • Event stream请求可以通过HTTP状态码301和307进行重定向;
  • 当连接关闭时,浏览器会尝试自动重连,除非收到HTTP状态吗204(No Content);
  • Event stream中,冒号(:)可以在行首表示改行是注释;
  • Event stream中,非注释行冒号(:)后可以有一个空格,该空格不会计入data buffer中;
  • Event stream中,最后一行如果没有额外的空行,会导致最后一个事件推送不成功;
  • 对于代理服务器,因其在特定情况下会在短暂的延时后断开HTTP连接,设计者可以考虑每隔15s推送一条注释消息;
  • 在SSE重连时,浏览器端会找到上一个合法的id,跳过那些没有设置id的事件,并通过Last-Event-ID请求头字段发送给服务器;如果上一条id为空,则表示清空last event ID string,这种情况下,浏览器重连时并不会发送Last-Event-ID请求头字段。

Event Source对象

Event Source对象有4个要点,其中后3个都不暴露在Event Source对象上:

  1. url;
  2. 请求;
  3. 重连事件;
  4. last event ID string;

Event Source对象有如下API接口:

  • url (read-only);
  • withCredentials (read-only);
  • readyState // CONNECTING (0), OPEN (1), CLOSED (2)
  • EventHandler // onopen, onmessage, onerror
  • close (void)

具体实现方案

Browser端

目前,除IE外,几乎所有的浏览器都支持sse,即window下有EventSource属性(对象)。对于IE可以使用简易轮询或COMET技术来实现,也可以使用polyfill

下图是我在Can I use上针对SSE于2017年10月26日的查询结果:
Can I use sse

  • 具体实现
1
2
3
4
5
6
7
8
9
10
11
// constructor. When invoked, must init env & fetch request
var source = new EventSource('http://localhost/sse.php');
// onmessage. Also: 'onopen', 'onerror'
source.onmessage = function (event)
{
console.log(event.data);
};
// custom event listener. Also: 'open', 'message', 'error'
source.addEventListener('event1', event => {
console.log(event.data);
}, false);

Server端

PHP实现

1. 简单版

鄙人常用的后台语言是PHP,网上对PHP实现SSE的大部分实现方法如下(来自w3cshool)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
/**
* 简单版
* http://www.w3school.com.cn/html5/html_5_serversentevents.asp
*/
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
date_default_timezone_set('UTC');
$time = date('r');
echo "event: event1\n";
echo "data: The server time is: {$time}\n\n";
flush();
?>

经测试,上述简单版的PHP代码虽然能实现推送的功能,但这种写法实际上的效果其实跟轮询差不多。之所以这么讲,是因为从chrome开发调试工具的Network里可以看到间歇性的多条类型为eventsource的请求,如下图示。

这是因为浏览器上的EventSource实例默认会自动重连。上述PHP代码实际上只是一个HTTP短连接,只提供一次简短的sse推送。正是由于自动重连的存在,所以每次短暂的sse推送之后,每隔一段时间便会有一次重连再获取一次新的简短的sse推送。

2. while(true)版

另一种实现方法是while(true)的写法,虽然又会造成服务器端资源的浪费(例如HTTP长连接等)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
/**
* while(true)版
*/
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
date_default_timezone_set('UTC');
while (true) {
$time = date('r');
echo "data: The server time is: {$time}\n\n";
ob_flush();
flush();
sleep(1);
}
?>
3. 终极版

改进的方法是定时或定次(推送事件的次数)服务器端主动断开HTTP长连接,并根据每次推送事件的id(last-evnet-id)来在再次重连的时候恢复推送记录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/**
* 改进版,会定时断开长连接,并能根据last event id恢复
* https://github.com/Yaffle/EventSource
*/
<?php
header("Content-Type: text/event-stream");
header("Cache-Control: no-store");
header("Access-Control-Allow-Origin: *");
$lastEventId = floatval(isset($_SERVER["HTTP_LAST_EVENT_ID"]) ? $_SERVER["HTTP_LAST_EVENT_ID"] : 0);
if ($lastEventId == 0) {
$lastEventId = floatval(isset($_GET["lastEventId"]) ? $_GET["lastEventId"] : 0);
}
echo "retry: 2000\n";
// event-stream
$i = $lastEventId;
$c = $i + 100;
while (++$i < $c) {
echo "id: " . $i . "\n";
echo "data: " . $i . ";\n\n";
ob_flush();
flush();
sleep(1);
}
?>

nodejs实现

这里使用node自带的http库来实现,简单添加了允许所有域跨域请求的请求头,随机推送一个事件,并且会定次(100次)断开长连接,并能根据last event id恢复。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
const http = require('http');
// options
const options = {
port: 3003,
intervalTime: 3000,
interval: null,
events: ['connected', 'event1', 'event2', 'event3']
};
/**
* 会定次断开长连接,并能根据last event id恢复
*/
const server = http.createServer((request, response) => {
// headers of SSE(Server-sent Events) & CORS(Cross Origin Resources Sharing)
response.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Access-Control-Allow-Origin': '*'
});
// SSE stream
response.write(`retry: ${options.interValTime}\n`);
response.write(`event: ${options.events[0]}\n`);
response.write(`data: ${new Date().toISOString()}\n\n`);
// last event id
let lastEventId = 0;
if (request.headers["last-event-id"] !== undefined) {
lastEventId = Number(request.headers["last-event-id"]);
}
let i = lastEventId;
let c = i + 100;
// timeout function
const fun = function () {
if (++i < c) {
// send events randomly
const index = Math.floor(Math.random() * 3) + 1;
response.write(`id: ${i}\n`);
response.write(`event: ${options.events[index]}\n`);
response.write(`data: ${new Date().toISOString()}\n\n`);
options.interval = setTimeout(fun, options.intervalTime);
} else {
response.end();
}
};
fun();
// close
request.connection.addListener('close', function () {
console.log('sse server closing...[browser side close]');
clearInterval(options.interval);
response.end();
}, false);
})
server.listen(options.port);

以上。

参考资料