服務(wù)器推送事件:一種從服務(wù)器流式推送事件的簡(jiǎn)易方法
哈嘍!昨天我見(jiàn)識(shí)到了一種我以前從沒(méi)見(jiàn)過(guò)的從服務(wù)器推送事件的炫酷方法:服務(wù)器推送事件server-sent events!如果你只需要讓服務(wù)器發(fā)送事件,相較于 Websockets,它們或許是一個(gè)更簡(jiǎn)便的選擇。
我會(huì)聊一聊它們的用途、運(yùn)作原理,以及我昨日在試著運(yùn)行它們的過(guò)程中遇到的幾個(gè)錯(cuò)誤。
問(wèn)題:從服務(wù)器流式推送更新
現(xiàn)在,我有一個(gè)啟動(dòng)虛擬機(jī)的 Web 服務(wù),客戶端輪詢服務(wù)器,直到虛擬機(jī)啟動(dòng)。但我并不想使用輪詢方式。
相反,我想讓服務(wù)器流式推送更新。我跟 Kamal 說(shuō)我要用 Websockets 來(lái)實(shí)現(xiàn)它,而他建議使用服務(wù)器推送事件不失為一個(gè)更簡(jiǎn)便的選擇!
我登時(shí)就愣住了——那什么玩意???聽(tīng)起來(lái)像是些我從來(lái)沒(méi)見(jiàn)過(guò)的稀罕玩意兒。于是乎我就查了查。
服務(wù)器推送事件就是個(gè) HTTP 請(qǐng)求協(xié)議
下文便是服務(wù)器推送事件的運(yùn)作流程。我-很-高-興-地了解到它們就是個(gè) HTTP 請(qǐng)求協(xié)議。
1.客戶端提出一個(gè) GET 請(qǐng)求(舉個(gè)例子)https://yoursite.com/events
2.客戶端設(shè)置 Connection: keep-alive
,這樣我們就能有一個(gè)長(zhǎng)連接 3.服務(wù)器設(shè)置設(shè)置一個(gè) Content-Type: text/event-stream
響應(yīng)頭 4.服務(wù)器開(kāi)始推送事件,就比如下文這樣:
event: status
data: one
舉個(gè)例子,這里是當(dāng)我借助 curl
發(fā)送請(qǐng)求時(shí),一些服務(wù)器推送事件的樣子:
$ curl -N 'http://localhost:3000/sessions/15/stream'
event: panda
data: one
event: panda
data: two
event: panda
data: three
event: elephant
data: four
服務(wù)器可以根據(jù)時(shí)間推移緩慢推送事件,并且客戶端也能夠在它們到來(lái)時(shí)讀取它們。你也可以將 JSON 或任何你想要的東西放在事件當(dāng)中,就比如 data: {'name': 'ahmed'}
。
線路協(xié)議真的很簡(jiǎn)單(只需要設(shè)置 event:
和 data:
,或者如果你愿意,可設(shè)置為 id:
和 retry:
),所以你并不需要任何花里胡哨的服務(wù)器庫(kù)來(lái)實(shí)現(xiàn)服務(wù)器推送事件。
JavaScript 的代碼也超級(jí)簡(jiǎn)單(僅使用 EventSource)
以下是用于流式服務(wù)器推送事件的瀏覽器 JavaScript 的代碼。(我從 服務(wù)器推送事件的 MND 頁(yè)面 得到的這個(gè)范例)
你可以訂閱所有事件,也可以為不同類型的事件使用不同的處理程序。這里我有一個(gè)只接受類型為 panda
的事件的處理程序(就像我們的服務(wù)器在上一節(jié)中推送的那樣)。
const evtSource = new EventSource("/sessions/15/stream", { withCredentials: true })
evtSource.addEventListener("panda", function(event) {
console.log("status", event)
});
客戶端在中途不能推送更新
不同于 Websockets,服務(wù)器推送事件不允許大量的來(lái)回事件通訊。(這體現(xiàn)在它的字眼中 —— 服務(wù)器 推送所有事件)。初始的時(shí)候客戶端發(fā)出一個(gè)請(qǐng)求,然后服務(wù)器發(fā)出一連串響應(yīng)。
如果 HTTP 連接結(jié)束,它會(huì)自動(dòng)重連
使用 EventSource
發(fā)出的 HTTP 請(qǐng)求和常規(guī) HTTP 請(qǐng)求有一個(gè)很大的區(qū)別,MDN 文檔中對(duì)此有所說(shuō)明:
默認(rèn)情況下,如果客戶端和服務(wù)器之間的連接斷開(kāi),則連接會(huì)重啟。請(qǐng)使用
.close()
方法來(lái)終止連接。
很奇怪,一開(kāi)始我真的被它嚇到了:我打開(kāi)了一個(gè)連接,然后在服務(wù)器端將其關(guān)閉,然后兩秒過(guò)后客戶端向我的傳送終端發(fā)送了另一條請(qǐng)求!
我覺(jué)得這里可能是因?yàn)檫B接在完成之前意外斷開(kāi)了,所以客戶端自動(dòng)重新打開(kāi)了它以防止類似情況再發(fā)生。
所以如果你不想讓客戶端繼續(xù)重試,你就得通過(guò)調(diào)用 .close()
直截了當(dāng)?shù)仃P(guān)閉連接。
這里還有些其它特性
你還能在服務(wù)器推送事件中設(shè)置 id:
和 retry:
字段。似乎,如果你在服務(wù)器推送事件上設(shè)置,那么當(dāng)重新連接時(shí),客戶端將發(fā)送一個(gè) Last-Event-ID
響應(yīng)頭,帶有它收到的最后一個(gè) ID???
我發(fā)現(xiàn) W3C 的服務(wù)器推送事件頁(yè)面 令人驚訝地容易理解。
在設(shè)置服務(wù)器推送事件的時(shí)候我遇到了兩個(gè)錯(cuò)誤
我在 Rails 中使用服務(wù)器推送事件時(shí)遇到了幾個(gè)問(wèn)題,我認(rèn)為這些問(wèn)題挺有趣的。其中一個(gè)緣于 Nginx,另一個(gè)是由 Rails 引起的。
問(wèn)題一:我不能在事件推送的過(guò)程中暫停
這個(gè)奇怪的錯(cuò)誤是在我做以下操作時(shí)出現(xiàn)的:
def handler
# SSE is Rails' built in server-sent events thing
sse = SSE.new(response.stream, event: "status")
sse.write('event')
sleep 1
sse.write('another event')
end
它會(huì)寫(xiě)入第一個(gè)事件,但不能寫(xiě)入第二個(gè)事件。我對(duì)此-非-常-困-惑,然后放開(kāi)腦洞,試著理解 Ruby 中的 sleep
是如何運(yùn)作的。但是 Cass 將我引領(lǐng)到一個(gè)與我有著相同困惑的 Stack Overflow 問(wèn)答帖,而這里包含了讓我為之震驚的回答!
事實(shí)證明,問(wèn)題出在我的 Rails 服務(wù)器位于 Nginx 之后,似乎 Nginx 默認(rèn)使用 HTTP/1.0 向上游服務(wù)器發(fā)起請(qǐng)求(為啥?都 2021 年了,還這么干?我相信這其中一定有合乎情理的解釋,也許是為了向下兼容之類的)。
所以客戶端(Nginx)會(huì)在服務(wù)器推送第一個(gè)事件之后直接關(guān)閉連接。我覺(jué)得如果在我推送第二個(gè)事件的過(guò)程中 沒(méi)有 暫停,它繼續(xù)正常工作,基本上就是服務(wù)器在連接關(guān)閉之前和客戶端在爭(zhēng)速度,爭(zhēng)著推送第二部分響應(yīng),如果我這邊推送速度足夠快,那么服務(wù)器就會(huì)贏得比賽。
我不確定為什么使用 HTTP/1.0 會(huì)使客戶端的連接關(guān)閉(可能是因?yàn)榉?wù)器在每個(gè)事件結(jié)尾寫(xiě)入了兩個(gè)換行符?),但因?yàn)榉?wù)器推送事件是一個(gè)比較新的玩意兒,HTTP/1.0 (這種老舊協(xié)議)不支持它一點(diǎn)都會(huì)不意外。
設(shè)置 proxy_http_version 1.1
從而解決那個(gè)麻煩。好欸!
問(wèn)題二:事件被緩沖
這個(gè)事情解決完,第二個(gè)麻煩接踵而至。不過(guò)這個(gè)問(wèn)題實(shí)際上非常好解決,因?yàn)?Cass 已經(jīng)建議將 stackoverflow 里另一篇帖的回答 作為前一個(gè)問(wèn)題的解決方案,雖然它并沒(méi)有是導(dǎo)致問(wèn)題一出現(xiàn)的源頭,但它-確-實(shí)-解-釋-了問(wèn)題二。
問(wèn)題在這個(gè)示例代碼中:
def handler
response.headers['Content-Type'] = 'text/event-stream'
# Turn off buffering in nginx
response.headers['X-Accel-Buffering'] = 'no'
sse = SSE.new(response.stream, event: "status")
10.times do
sse.write('event')
sleep 1
end
end
我本來(lái)期望它每秒返回 1 個(gè)事件,持續(xù) 10 秒,但實(shí)際上它等了 10 秒才把 10 個(gè)事件一起返回。這不是我們想要的流式傳輸方式!
原來(lái)這是因?yàn)?Rack ETag 中間件想要計(jì)算 ETag(響應(yīng)的哈希值),為此它需要整個(gè)響應(yīng)為它服務(wù)。因此,我需要禁用 ETag 生成。
Stack Overflow 的回答建議完全禁用 Rack ETag 中間件,但我不想這樣做,于是我去看了 鏈接至 GitHub 上的議題。
那個(gè) GitHub 議題建議我可以針對(duì)僅流式傳輸終端應(yīng)用一個(gè)解決方法,即 Last-Modified
響應(yīng)頭,顯然,這么做可以繞過(guò) ETag 中間件。
所以我設(shè)置為:
headers['Last-Modified'] = Time.now.httpdate
然后它起作用了?。?!
我還通過(guò)設(shè)置響應(yīng)頭 X-Accel-Buffering: no
關(guān)閉了位于 Nginx 中的緩沖區(qū)。我并沒(méi)有百分百確定我要那樣做,但這么做似乎更安全。
Stack Overflow 很棒
起初,我全身心致力于從頭開(kāi)始調(diào)試這兩個(gè)錯(cuò)誤。Cass 為我指向了那兩個(gè) Stack Overflow 帖子,一開(kāi)始我對(duì)那些帖下提出的解決方案持懷疑態(tài)度(我想:“我沒(méi)有使用 HTTP/1.0 ?。Tag 響應(yīng)頭什么玩意,跟這一切有關(guān)系嗎??”)。
但結(jié)果證明,我確實(shí)無(wú)意中使用 了 HTTP/1.0,并且 Rack ETag 中間件確實(shí)給我?guī)?lái)了問(wèn)題。
因此,也許這個(gè)故事告訴我,有時(shí)候計(jì)算機(jī)就是會(huì)以奇怪的方式相互作用,其它人在過(guò)去也遇到過(guò)計(jì)算機(jī)以完全相同的奇怪方式相互作用的問(wèn)題,而 Stack Overflow 有時(shí)會(huì)提供關(guān)于為什么會(huì)發(fā)生這些情況的答案 : )
我認(rèn)為重要的是不要隨意從 Stack Overflow 中嘗試各種解決方案(當(dāng)然,在這種情況下不會(huì)有人建議這樣做?。?duì)于這兩個(gè)問(wèn)題,我確實(shí)需要去仔細(xì)思考,了解發(fā)生了什么,還有為什么更改這些設(shè)置會(huì)起作用。
就是這樣!
今天我要繼續(xù)著手實(shí)現(xiàn)服務(wù)器推送事件,因?yàn)樽蛱煲徽煳叶汲两谏鲜鲞@些錯(cuò)誤里。好在我學(xué)到了一個(gè)以前從未聽(tīng)說(shuō)過(guò)的易學(xué)易用的網(wǎng)絡(luò)技術(shù),心里還是很高興的。