聊聊 EventStream 服务器端推送

聊聊 EventStream 服务器端推送

编程那点事 随便写写 Go 1493 字 / 3 分钟

之前在写 Cardinal 平台的从 Docker 镜像部署靶机的功能时,打算在后台拉取镜像时,前端能有一个实时滚动的控制台日志给用户以反馈。 最初的想法是使用 WebSocket 实现后端和前端之间的持续通信,正准备写之前,我想到 Drone CI 在执行持续集成任务时,也有一个类似的实时日志的功能,遂打算先研究下 Drone CI 的实现,吸取下经验,结果接触到了 EventStream 这么一个“新鲜”玩意。

因为之前给协会用 Go 写了一套 CAS 统一登录系统,因此我们也在协会服务器上搭了一个 Drone CI 来实现自动部署。我直接登上了协会的 Drone,重新执行了一个已完成的构建任务,结果发现开发者工具 Network - WS 下居然没有 WebSocket 连接的建立!

最后才发现他是通过一个持久的 HTTP GET 请求来实现服务端向客户端单向推送信息。 在开发者工具里该条连接不再显示 Preview 和 Response,而是 EventStream。

MDN 上对 EventStream 的描述已经十分详细了。 EventStream 并不是协议层面的新技术,而是 HTML5 的新内容。其特点是返回的 MIME Content-Typetext/event-stream。一般来说,如果浏览器 HTTP 请求一个资源,当资源未全部传完时,浏览器是会一直等待的,此时页面还是空白一片。而当响应头中Content-Type: text/event-stream时,虽然 HTTP 连接还未被关闭,但浏览器会渲染这个持久化的连接的响应内容。当有新的数据被传输过来时,浏览器会继续显示出来。就很神奇。 因此这只是浏览器在保持、处理持久的 HTTP 连接时的新机制。在 HTTP 协议层面,这只是个普通的一直在持续加载的请求。

对比 WebSocket

相比于 WebSocket 而言,我之前分别使用过 PHP + Swoole 和 Go + Gorilla 做过 WebSocket。它给我最深的印象就是对于客户端的每个请求,我必须要自己维护一个 map 一样的字典,将每个连接存放到里面。并且服务端不能主动检测到客户端的中途意外断开,只能时不时发个心跳包,通过是否发送成功来判断。若发现客户端已断开,还需要将客户端的连接从 map 中移除。且 WebSocket 连接时还需要返回 101 协议升级。

而 EventStream 是一个单纯的 HTTP 请求,如果用户在传输中途断开,这个事件在 Web 框架层面就已经处理妥当,是不需要开发者操心的。但同时开发者又可以捕获到这个事件:

func handler (w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithCancel(r.Context())
	defer cancel()
	
	L:
		for {
			select {
			case <-ctx.Done():
				break L
				//...
			}
		}
}

但 WebSocket 的好处是服务端和客户端可以双向通信,而 EventStream 基于 HTTP 请求,仅支持服务端到客户端单向通信。因此它的应用场景一般像是实时获取新的推文、刷新页面数据等。

发送的事件流格式

EventStream 的每条消息使用字段 + : + 内容构成。若没有冒号:,则整段将被当成一个字段名;若冒号前没有消息类型,则这是一条注释,会被前端忽略。

以下就是三个典型的 EventStream 事件:

: this is a test stream

data: some text

data: another message
data: with two lines 

最后一块是多行消息 another message\nwith two lines

通过event字段可以发送一个命名事件:

event: message
data: here is message body

对于一个命名事件,在前端就可以声明相应的侦听器来监听:

const event = new EventSource("http://domainhere.com/stream")

evtSource.addEventListener("message", function(event) {
	console.log(event.data)
});

而对于无类型消息,则会通过onmessage处理函数:

evtSource.onmessage = function(event) {
	console.log(event.data)
}

注意事项

EventStream 虽然是基于 HTTP,但是在前端使用EventSource接口时,和 WebSocket 一样,并不能自定义 HTTP 请求头。鉴权方面只能通过传输同源下的 Cookie。并不能自定义一个 Authorization 头带 Token 之类的。我在这一点上不是很满意。

同时,在不使用 HTTP/2 时,会受到浏览器的最大连接数限制。在 Chrome 和 Firefox 浏览器中所有打开选项卡下同域的连接最多只能有 6 个。而使用 HTTP/2 时,HTTP 同一时间内的最大连接数由服务器和客户端之间协商(默认为100)。

https://developer.mozilla.org/zh-CN/docs/Server-sent_events/Using_server-sent_events#Event_stream_format

以上大致就是关于 EventStream 的介绍,Go 下的具体实现可以参考 Drone CI。 Drone CI 也是使用的 Gin 框架,他们早年时 EventStream 是使用 Gin 原生的 c.Stream 以及 c.SSEvent 方法,但目前最新的版本是自己构造发送的消息体:

h := w.Header()
h.Set("Content-Type", "text/event-stream")
h.Set("Cache-Control", "no-cache")
h.Set("Connection", "keep-alive")
h.Set("X-Accel-Buffering", "no")

f, ok := w.(http.Flusher)
if !ok {
	return
}

io.WriteString(w, ": ping\n\n")
f.Flush()

我尚且还不明白他们这样做的好处。因此目前在 Cardinal 中的实现还是采用 Gin 原生的方法。