SSE 提供了一种简单而高效的方式,它可以在服务器和网页客户端之间建立一个单向连接,这样服务器就能够实时地发送更新信息,而无需不断地进行数据请求。

理解 Server-Sent Events (SSE)

Server-Sent Events(服务器发送事件)是 HTML5 规范的一部分,它允许服务器通过一条持久的单一连接向 Web 客户端推送(发送)数据。与 WebSocket(全双工通信协议)不同,后者支持全双工(双向)通信,而服务器发送事件更适合于需要从服务器到客户端的单向通信的场景。

SSE 如何工作

SSE 依赖于客户端的 EventSource API,它让浏览器能够与服务器端点建立一个持久的连接。一旦建立了连接,服务器就可以将事件以简单的文本数据的形式(通常是 “text/event-stream” 格式)发送到客户端。然后,客户端的 JavaScript 可以处理这些事件,并实时刷新网页。

SSE 的优点

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

Implementing Server-Sent Events with Go

如果我们想在 Go 应用程序中实施 SSE,就需要建立一个 HTTP 端点供客户端连接。下面是一份详细的步骤指南,还配有一些代码示例:

Step 1: Create a Basic HTTP Server

首先,我们来使用 net/http 包搭建一个基础的 Go HTTP 服务器。这是一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
package main

import (
"fmt"
"net/http"
"time"
)

func main() {
http.HandleFunc("/events", eventsHandler)
http.ListenAndServe(":8080", nil)
}

在这个示例中,我们设定了一个 HTTP 服务器,它会在 8080 端口进行监听,且设立了一个唯一的端点,“/events”,这个端点将负责处理 SSE 连接。

Step 2: Implement the SSE Handler

下一步,我们需要实现 SSE 的处理器。这个处理器的作用是向已连接的客户端发送事件。这是一个示例:

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

func eventsHandler(w http.ResponseWriter, r *http.Request) {
// Set CORS headers to allow all origins. You may want to restrict this to specific origins in a production environment.
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Expose-Headers", "Content-Type")

w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")

// Simulate sending events (you can replace this with real data)
for i := 0; i < 10; i++ {
if i%2 == 0 {
fmt.Fprintf(w, "event: notice\n")
}
fmt.Fprintf(w, "id: %d\n", i)
fmt.Fprintf(w, "data: %s\n", fmt.Sprintf("Event %d", i))
fmt.Fprintf(w, "retry: 10000\n")
fmt.Fprintf(w, "\n\n")
fmt.Fprintf(w, ": \n\n")
time.Sleep(2 * time.Second)
w.(http.Flusher).Flush()
}

// Simulate closing the connection
closeNotify := w.(http.CloseNotifier).CloseNotify()
<-closeNotify
}

在这段代码中,我们设定了响应头以指明 SSE 的数据类型是事件流( text/event-stream ),禁止了缓存,并且保证了连接的持久性。接着我们使用了一个循环来模拟发送事件。

事件流是一个简单的文本流,仅支持 UTF-8 格式的编码。每条消息以一个空行作为分隔符。

在规范中为消息定义了 4 个字段:

event 消息的事件类型。客户端收到消息时,会在当前的 EventSource 对象上触发一个事件,这个事件的名称就是这个字段的值,如果消息没有这个字段,客户端的 EventSource 对象就会触发默认的 message 事件。

id 这条消息的 ID。客户端接收到消息后,会把这个 ID 作为内部属性 Last-Event-ID,在断开重连 成功后,会把 Last-Event-ID 发送给服务器。

data 消息的数据字段。 客户端会把这个字段解析为字符串,如果一条消息有多个 data 字段,客户端会自动用换行符 连接成一个字符串。

retry 指定客户端重连的时间。只接受整数,单位是毫秒。如果这个值不是整数则会被自动忽略。

一个很有意思的地方是,规范中规定以冒号开头的消息都会被当作注释,一条普通的注释(:\n\n)对于服务器来说只占 5 个字符,但是发送到客户端上的时候不会触发任何事件,这对客户端来说是非常友好的。所以注释一般被用于维持服务器和客户端的长连接。

Step 3: Handle SSE on the Client Side

在客户端,你可以使用 JavaScript 来打开一个 SSE 连接并处理接收到的事件。以下是一个简单的 HTML 和 JavaScript 代码片段:

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
<!DOCTYPE html>
<html>
<head>
<title>SSE Example</title>
</head>
<body>
<div id="sse-data"></div>

<script>
const eventSource = new EventSource('http://localhost:8080/events');
const dataElement = document.getElementById('sse-data');

eventSource.onmessage = function(event) {
dataElement.innerHTML += 'message: ' + event.data + '<br>';
};

eventSource.addEventListener("notice", e => {
dataElement.innerHTML += 'notice: ' + event.data + '<br>';
}, false)

eventSource.addEventListener('error', e => {
if (e.target.readyState === EventSource.CLOSED) {
dataElement.innerHTML += 'Disconnected' + '<br>';
} else if (e.target.readyState === EventSource.CONNECTING) {
dataElement.innerHTML += 'Connecting...' + '<br>';
}
}, false);
</script>
</body>
</html>

这个 HTML 页面使用 EventSource API 连接到 “/events” 端点,并将接收到的事件添加到一个 <div> 元素中。

EventSource 从父接口 EventTarget 中继承了属性和方法,其内置了 3 个 EventHandler 属性、2 个只读属性和 1 个方法:

EventHandler 属性
EventSource.onopen 在连接打开时被调用。
EventSource.onmessage 在收到一个没有 event 属性的消息时被调用。
EventSource.onerror 在连接异常时被调用。

只读属性
EventSource.readyState 一个 unsigned short 值,代表连接状态。可能值是 CONNECTING (0), OPEN (1), 或者 CLOSED (2)。
EventSource.url 连接的 URL。

方法
EventSource.close() 关闭连接

Step 4: Run the Go Application

打开一个网页浏览器,导航到你创建的 HTML 页面,然后编译并运行你的 Go 应用程序。你应该能看到实时事件在网页上显示。

SSE使用注意事项

SSE 如何保证数据完整性

客户端在每次接收到消息时,会把消息的 id 字段作为内部属性 Last-Event-ID 储存起来。

SSE 默认支持断线重连机制,在连接断开时会 触发 EventSource 的 error 事件,同时自动重连。再次连接成功时 EventSource 会把 Last-Event-ID 属性作为请求头发送给服务器,这样服务器就可以根据这个 Last-Event-ID 作出相应的处理。

这里需要注意的是,id 字段不是必须的,服务器有可能不会在消息中带上 id 字段,这样子客户端就不会存在 Last-Event-ID 这个属性。所以为了保证数据可靠,我们需要在每条消息上带上 id 字段。

减少开销

在 SSE 的草案中提到,“text/event-stream” 的 MIME 类型传输应当在静置 15 秒后自动断开。在实际的项目中也会有这个机制,但是断开的时间没有被列入标准中。

为了减少服务器的开销,我们也可以有目的的断开和重连。

简单的办法是服务器发送一个 关闭消息并指定一个重连的时间戳,客户端在触发关闭事件时关闭当前连接并创建 一个计时器,在重连时把计时器销毁 。

浏览器兼容

向下兼容:早些时候,为了实现数据实时更新最常见的方法就是轮询。

轮询是以一个固定频率向服务器发送请求,服务器在有 数据更新时 返回新的数据,以此来管理数据的更新。这种轮询的方式不但开销大,而且更新的效率和频率有关,也不能达到及时更新的目的。

接着便出现了长轮询的方式:客户端向服务器发送请求之后,服务器会暂时把请求挂起,等到有数据更新时再返回最新的数据给客户端,客户端在接收到新的消息后再向服务器发送请求。与常规轮询的不同之处是:数据可以做到实时更新,可以减少不必要的开销。

这里有一个「选择长轮询还是常规轮询?」的命题,长轮询是不是总比常规轮询占有优势?我们可以从带宽占用的角度分析,如果一个程序数据更新太过频繁,假设每秒 2 次更新,如果使用长轮询的话每分钟要发送 120 次 HTTP 请求。如果使用常规轮询,每 5 秒发送一次请求的话, 一分钟才 20 次,从这里看,常规轮询更占有优势。

长轮询和 SSE 最关键的区别在于,每一次数据更新都需要一次 HTTP 请求。和 WebSocket 还有 SSE 一样,长轮询也会 占用一个 socket。在数据更新效率上和 SSE 差不多,一有数据更新就能检测到。加上所有浏览器都支持,是一个不错的 SSE 替代方案。

Conclusion

服务器发送事件(Server-Sent Events,简称 SSE)是一种简洁且高效的技术,可以用来在网页应用中实现实时通信。通过使用 Go 语言,我们可以方便地创建一个 SSE 服务器,这个服务器可以向客户端推送实时更新,从而为用户提供流畅且高效的实时体验。无论是用于展示实时通知,更新数据仪表盘,还是其他任何用途,SSE 都是你网页开发工具集中的重要组成部分。

Reference

Server-Sent Events 教程
SSE vs WebSockets