前段时间,为客户定制了一个类似看板的东西,用户可以通过看板了解任务的处理情况,通过APP扫面页面上的二维码就可以领取任务,而当任务被领取以后需要通知当前页面刷新。原本这是一个相对简单的需求,可是因为APP端和PC端是两个不同的Team在维护,换句话说,两个Team各自有一套自己的API接口,前端页面永远无法知道APP到底什么时候扫描了二维码,为此前端页面不得不通过轮询的方式去判断状态是否发生了变化。这种方式会发送大量无用的HTTP请求,因此在最初的版本里,无论是效率还是性能都不能满足业务要求,最终博主采用一种称为服务器推送事件(Server-Sent Events)的技术,所以,在今天这篇文章里,博主相和大家分享下关于服务器推送事件(Server-Sent Events)相关的内容。

什么是Server-Sent Events

我们知道,严格地来讲,HTTP协议是无法做到服务端主动推送消息的,因为HTTP协议是一种请求-响应模型,这意味着在服务器返回响应信息以后,本次请求就已经结束了。可是,我们有一种变通的做法,即首先是服务器端向客户端声明,然后接下来发送的是流信息。换句话说,此时发送的不是一个一次性的数据包,而是以数据流的形式不断地发送过来,在这种情况下,客户端不会关闭连接,会一直等着服务器端发送新的数据过来,一个非常相似而直观的例子是视频播放,它其实就是在利用流信息完成一次长时间的下载。那么,Server-Sent Events(以下简称SSE),就是利用这种机制,使用流信息像客户端推送信息。

说到这里,可能大家会感到疑惑:WebSocket不是同样可以实现服务端向客户端推送信息吗?那么这两种技术有什么不一样呢?首先,WebSocket和SSE都是在建立一种浏览器与服务器间的通信通道,然后由服务器向浏览器推送信息。两者最为不同的地方在于,WebSocket建立的是一个全双工通道,而SSE建立的是一个单工通道。所谓单工和双工,是指数据流动的方向上的不同,对WebSocket而言,客户端和服务端都可以发送信息,所以它是双向通信;而对于SSE而言,只有服务端可以发送消息,故而它是单向通信。从下面的图中我们可以看得更为直观,在WebSocket中数据”有来有往”,客户端既可以接受信息亦可发送信息,而在SSE中数据是单向的,客户端只能被动地接收来自服务器的信息。所以,这两者在通信机制上不同到这里已经非常清晰啦!

WebSocket与SSE对比
WebSocket与SSE对比

SSE服务端

下面我们来看看SSE是如何通信的,因为它是一个单工通道的协议,所以协议定义的都是在服务端完成的,我们就从服务端开始吧!协议规定,服务器向客户端发送的消息,必须是UTF-8编码的,并且提供如下的HTTP头部信息:

1
2
3
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

这里出现了一个一种新的MIME类型,text/event-stream。协议规定,第一行的Content-Type必须是text/event-stream,这表示服务端的数据是以信息流的方式返回的,Cache-Control和Connection两个字段和常规的HTTP一致,这里就不再展开说啦!OK,现在客户端知道这是一个SSE信息流啦,那么客户端怎么知道服务端发送了什么消息呢?这就要说到SSE的消息格式,在SSE中消息的基本格式是:

1
[field]: value\n

其中,field可以取四个值,它们分别是:dataeventidretry,我们来一起看看它们的用法。

data字段表示数据内容,下面的例子展示SSE中的一行和多行数据,可以注意到,当数据有多行时,可以用\n作为每一行的结尾,只要保证最后一行以\n\n结尾即可。

1
2
3
4
5
6
7
:这是一行数据内容
data: SSE给你发了一行消息\n\n
:这是多行数据内容
data: {\n
data: "foo": "foolish",\n
data: "bar", 2333\n
data: }\n\n

event字段表示自定义事件,默认为message,在浏览器中我们可以用addEventListener()来监听响应的事件,这正是为什么SSE被称为服务器推送事件,因为我们在这里既可以发送消息,同样可以发送事件。

1
2
3
4
5
6
7
8
9
: GameStart事件
event: GameStart\n
data: 敌军还有30秒到达战场\n\n

data: Double Kill\n\n

: GameOver事件
event: GaneOver\n
data: You Win!\n\n

id 字段是一个数据标识符,相当于我们可以给每一条消息一个编号。

1
2
3
4
5
6
id: 1\n
data: 敌军还有30秒到达战场\n\n
id: 2\n
data: Double Kill\n\n
id: 3\n
data: You Win!\n\n

retry字段可以指定浏览器重新发起连接的时间间隔,所以,SSE天生就支持断线重连机制。

1
retry: 10000\n

SSE客户端

SSE目前是HTML5标准之一,所以,目前主流的浏览器(除了IE和Edge以外)都天然支持这一特性,这意味着我们不需要依赖前端娱乐圈推崇的各种工具链,就可以快速地使用SSE来投入开发。这里需要使用地是EventSource对象,我们从下面这个例子开始了解:

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
if ('EventSource' in window) {
var source = new EventSource(url, { withCredentials: true });

/* open事件回调函数 */
source.onopen = function(){
console.log('SSE通道已建立...');
};

/* message事件回调函数 */
source.onmessage = function(evt){
console.log(evt.data);
}

/* error事件回调函数 */
source.onerror = function(evt){
console.log('SSE通道发生错误');
}

/* 自定义事件回调 */
source.addEventListener('foo', function (event) {
var data = event.data;
// handle message
},false);

/* 关闭SSE */
source.close()
}

和各种各样的HTML5接口一样,我们需要判断当前的浏览器环境是否支持SSE。建立SSE只需要后端提供一个Url即可,当存在跨域时,我们可以打开第二个参数:withCredentials,这样SSE会在建立通道时携带Cookie。我们通过实例化后的source对象来判断通道是否建立,该对象有一个重要的属性:readyState。当它的取值为0时,表示连接还未建立,或者断线正在重连;当它的取值为1时,表示连接已经建立,可以接受数据;当它的取值为2时,表示连接已断,且不会重连。

好了,当SSE被成功建立以后,首先会触发open事件。这里介绍下SSE中的关键事件,即open、message和error,我们可以分别通过onopenonmessageonerror这三个回调函数来监听相应的事件。对于SSE而言,它是一个单工通道,客户端不能主动向服务端发送信息,所以,一旦建立了SSE通道,客户端唯一需要关注的地方就是onmessage这个回调函数,因为客户端只需要负责处理消息即可,甚至我们可以连onerror都不用关注,因为SSE自带断线重连机制,当然你可以选择在发生错误的时候关掉连接,此时你需要close()方法。

我们在上面提到,SSE在服务端可以定义自定义事件,那么,在浏览器中我们该如何接收这些自定义事件呢?这当然要提到无所不能的addEventListener,在人肉操作DOM的jQuery时代,jQuery中提供的大量API在协调不同浏览器间差异的同时,让我们离这些底层的知识越来越远,时至今日,当erySelector/querySelectorAll完全可以替换jQuery的选择器的时候,我们是不是可以考虑重新把某些东西捡起来呢?言归正传,在SSE中,我们只需要像注册普通事件一样,就可以完成对自定义事件的监听,只要客户端和服务端定好消息的协议即可。

在.NET中集成Server-Sent Events

OK,说了这么多,大家一定感觉有一个鲜活的例子会比较好一点,奈何官方提供的示例都是PHP的,难道官方默认PHP是世界上最好的编程语言了吗?所谓万变不离其宗”,下面我们以.NET为例来快速集成Server-Sent Events,这里需要说明的是,博主下面的例子采用ASP.NET Core 2.0版本编写,首先,我们建一个名为SSEController的控制器,在默认的Index()方法中,按照SSE规范,我们首先组织HTTP响应头,然后发送了一个名为SSE_Start的自定义事件,接下来,我们每隔10秒钟给客户端发送一条消息,请原谅我如此敷衍的Sleep():

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
[Route("api/[controller]")]
[ApiController]
public class SSEController : Controller
{
[HttpGet]
public IActionResult Index()
{
//组织HTTP响应头
Response.Headers.Add("Connection", "keep-alive");
Response.Headers.Add("Cache-Control", "no-cache");
Response.Headers.Add("Content-Type", "text/event-stream");

//发送自定义事件
var message = BuildSSE(new { Content = "SSE开始发送消息", Time = DateTime.Now }, "SSE_Start");
Response.Body.Write(message, 0, message.Length);

//每隔10秒钟向客户端发送一条消息
while (true)
{
message = BuildSSE(new { Content = $"当前时间为{DateTime.Now}" });
Response.Body.Write(message, 0, message.Length);
Thread.Sleep(10000);
}
}
}

我们提到,SSE的数据是按照一定的格式,由id、event、data和retry四个字段构成的,那么,织消息格式的代码我们放在了BuildSSE()方法中,我们来一起看看它的实现:

1
2
3
4
5
6
7
8
9
10
private byte[] BuildSSE(TMessage message, string eventName = null, int retry = 30000)
{
var builder = new StringBuilder();
builder.Append($"id:{Guid.NewGuid().ToString("N")}\n");
if (!string.IsNullOrEmpty(eventName))
builder.Append($"event:{eventName}\n");
builder.Append($"retry:{retry}\n");
builder.Append($"data:{JsonConvert.SerializeObject(message)}\n\n");
return Encoding.UTF8.GetBytes(builder.ToString());
}

可以看到,完全按照SSE规范来定义的,这里每次生成一个新的GUID来作为消息的ID,客户端断线后重连的间隔为30秒,默认发送的是“消息”,当指定eventName参数时,它就表示一个自定义事件,这里我们使用JSON格式来传递信息。好了,这样我们就完成了服务端的开发,怎么样,是不是感觉非常简单呢?我们先让它跑起来,下面着手来编写客户端,这个就非常简单啦!

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



DotNet-SSE</h1>


div>