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

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

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

Content-Typetext/event-streamContent-Type: text/event-stream

对比 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 事件:

: 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)
}

注意事项

EventSource

同时,在不使用 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

c.Streamc.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 原生的方法。