Server-Sent Events / EventSource

背景

公司的 OA 项目中,往往需要接入很多业务系统,体现在 App UI 上就类似支付宝首页的九宫格。这些业务系统呢又不一定来自同一家厂商,当 App 需要和他们做一些交互的时候(例如获取每个业务的待办数目,展示在首页图标的角标上),手机端开发者就很头疼了。

就以待办数目为例,接口不统一、参数不统一、响应速度不统一、随时接入新的系统,交互过程如果写在 App 中,那就需要频繁的更新 App,这对用户来说,是非常不友好的。这个时候通常遵循的一个原则就是,将变化放在 OA-Server,OA-App 只需要与 OA-Server 约定好接口规范,只访问自己的后台即可,与各个业务的交互交给后台。那 OA-App 发送请求到 OA-Server,OA-Server 收到多个业务响应的待办数目以后怎么及时的通知 OA-App 呢?

提到服务端数据推送,可能一下子容易想到 WebSocket。WebSocket 是一种全新的协议,随着 HTML5 草案的不断完善,越来越多的现代浏览器开始全面支持 WebSocket 技术了,它将 TCP 的 Socket(套接字)应用在了web page 上,从而使通信双方建立起一个保持在活动状态连接通道。它是一种全双工通信,而我们前面提到的获取待办数目的场景,App 发送一次请求就可以,server 拿到各个业务的数据后再分别实时返回给 App ,更像是一种单向通信,使用 WebSocket 岂不是杀鸡用牛刀?那轮询呢?耗费 server 资源不说,不一定达到实时的效果。(PS:这里吐槽下客户,待办数有的 999 多,也不处理,居然只会嫌弃我们数据获取的不及时🤓)……

好巧不巧,看到了另一个轻量级的方案:Server-Sent Events。

Server-Sent Events

HTML5 中有一个轻量的替代 WebSocket的方案:Server-Sent Events,以下简称 SSE。

SSE 本质

严格地说,HTTP 协议无法做到服务器主动推送信息。但是,有一种变通方法,就是服务器向客户端声明,接下来要发送的是流信息(streaming)。也就是说,发送的不是一次性的数据包,而是一个数据流,会连续不断地发送过来。这时,客户端不会关闭连接,会一直等着服务器发过来的新的数据流,视频播放就是这样的例子。本质上,这种通信就是以流信息的方式,完成一次用时很长的下载。SSE 就是利用这种机制,使用流信息向浏览器推送信息。

SSE 特点

WebSocket 和 SSE 都是传统请求-响应 Web 架构的替代方案,但它们不是完全冲突的技术。

  • SSE 使用 HTTP 协议,现有的服务器软件都支持。WebSocket 是一个独立协议。
  • SSE 属于轻量级,使用简单;WebSocket 协议相对复杂。
  • SSE 默认支持断线重连,WebSocket 需要自己实现。
  • SSE 一般只用来传送文本,二进制数据需要编码后传送,WebSocket 默认支持传送二进制数据。
  • SSE 支持自定义发送的消息类型。

显然,我们上面提到的 待办数目 场景,很适合使用 SSE。

EventSource

EventSource 是 SSE 对应的客户端 API,大部分浏览器都是默认支持的。网上的很多资料也都是从浏览器的角度来讲的。那这里我们就从 HTTP 协议的角度讲一下交互过程吧。🙄,毕竟我不懂 web 前端。

API 介绍:EventSource

应用

  • 首先 Client 发送一个普普通通的 GET 请求到 SSE-Server。
  • SSE-Server 无论需要返回几次数据,数据格式必须是 UTF-8 编码的文本,HTTP 头部信息需要类似如下设置:
1
2
3
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

上面三行之中,第一行的 Content-Type 必须指定 MIME 类型为 event-steam

  • SSE-Server 每一次发送的信息,由若干个 message 组成,每个 message 之间用 \n\n 分隔。每个 message 内部由若干行组成,每一行都是如下格式:
1
[field]: value\n

上面的 field 可以取四个值:

1
2
3
4
data
event
id
retry

此外,还可以有冒号开头的行,表示注释。通常,服务器每隔一段时间就会向浏览器发送一个注释,保持连接不中断。

1
: This is a comment

下面是一个例子。

1
2
3
4
5
6
: this is a test stream\n\n

data: some text\n\n

data: another message\n
data: with two lines \n\n
  • Client 拿到 message 以后自己解析。

message

前面提到的 field 有四个值,可以分别用作不同的用途。

data 数据内容

如果数据很长,可以分成多行,最后一行用 \n\n 结尾,前面行都用 \n 结尾。

1
2
3
4
5
6
7
8
9
10
data:  message\n\n

data: begin message\n
data: continue message\n\n

//发送json
data: {\n
data: "foo": "bar",\n
data: "baz", 555\n
data: }\n\n
id 数据标识

相当于每条数据的编号。

浏览器用 lastEventId 属性读取这个值。一旦连接断线,浏览器会发送一个 HTTP 头,里面包含一个特殊的 Last-Event-ID 头信息,将这个值发送回去,用来帮助服务器端重建连接。因此,这个头信息可以被视为一种同步机制。

1
2
id: msg1\n
data: message\n\n
event 自定义的事件类型

默认是message事件。浏览器可以用addEventListener()监听该事件。

1
2
3
4
5
6
7
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 事件。

下面是另一个例子。

1
2
3
4
5
6
7
8
9
10
11
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 字段,指定浏览器重新发起连接的时间间隔。

1
retry: 10000\n

两种情况会导致浏览器重新发起连接:一种是时间间隔到期,二是由于网络错误等原因,导致连接出错。

iOS 实现

从上面的内容我们可以知道,既然 EventSource 客户端与 server 建立连接是基于标准的 HTTP 协议,那我们想要实现一套自己的 EventSource API ,只需要按规则解析 message 即可。

大致的思路:

  • 使用 NSURLSession 来发起请求以及处理服务器的响应。
  • NSURLSession 的代理方法中解析得到的文本信息,使用自定义的 Event 对象接收。
  • 通过回调将 Event 传递给调用者。

代码是在下面这份上做的改动,添加了一些线程安全的处理,以及 session 的释放,不然会造成内存泄漏。

引用的OC代码 Github

改动后的源码:ATommyGirl/YYEventSource

模拟测试

启动服务

1
node sse-server.js

会在本机 http://127.0.0.1:8844/stream 启动一个 SSE 服务。

使用浏览器或者 OC 代码访问这个地址就会看到信息输出了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#import "EventSource.h"
//...

NSString *url = @"http://127.0.0.1:8844/stream";
EventSource *eventSource = [EventSource eventSourceWithURL:[NSURL URLWithString:url]];

[eventSource onMessage:^(Event *e) {
dispatch_async(dispatch_get_main_queue(), ^{
NSLog(@"%@", e);
});
}];

[eventSource onError:^(Event *event) {
NSLog(@"error:%@", event.error);
}];

学习资料

阮一峰