iT邦幫忙

2022 iThome 鐵人賽

DAY 9
2
Modern Web

這些那些你可能不知道我不知道的Web技術細節系列 第 9

你可能不知道的即時更新方案:Server Send Event

Server Send Event

Server Send Event(SSE)解決了Long Polling會需要建立多次Request的問題。相比起Long Polling「取得Response後,需要在建立一次Reqeust」。Server Send Event在同一次HTTP連線中,由Server送出多次更新資料。

優點

連線可重複使用。

相比起Long Polling「取得Response後,需要在建立一次Reqeust」。Server Send Event在同一次HTTP連線中,由Server送出多次更新資料。

缺點

僅能夠由Server傳送訊息到瀏覽器的單向傳輸。

Lab

前端頁面

<!-- www-data/index.html -->
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8"/>
    <title>即時更新內容 - Sever Send Event</title>
  </head>
  <body>
    <h1 id="content"></h1>
  </body>
  <script defer type="module">
   const DEFAULT_TIMEOUT = 30000 /*ms*/;
   const HEATBEAT_INTVAL = 5000 /*ms*/;
   const contentEl = document.querySelector('#content');

   let evtSource = new EventSource("/connect");

   /* recive default message
   evtSource.addEventListener('message', async (event) => {
       console.log(`recive message: ${event.data}`);
   })
   */


   /* recive special event - update */
   evtSource.addEventListener('update', (event) => {
       contentEl.innerText = event.data;
   });
</script>
</html>

前端頁面實現也算是簡單的,透過EventSource()建立Server Send Event來源的連線。透過監聽message<event>來取得更新資訊。

後端API

按慣例先引入一些必要的package:

import uvicorn
from typing import TypedDict
from fastapi import FastAPI
from fastapi.responses import HTMLResponse, FileResponse, StreamingResponse
from watchdog.events import LoggingEventHandler
from watchdog.observers import Observer
import time

CONTENT_FILE = 'content.txt'
GMT_FORMAT = '%a, %d %b %Y %H:%M:%S GMT'

前端畫面一樣簡簡單單的提供給瀏覽器

@app.get('/index.html', response_class=HTMLResponse)
async def index():
    return FileResponse('index.html')

不同的是現在/connect不是回傳後就直接關閉連線。這次我們要用StreamingResponse和生成器(generator),來提供源源不斷的更新資料:

@app.get('/connect')
def connect():
    headers = {
        "Content-Type": "text/event-stream",
        "Cache-Control": "no-cache",
    }
    return StreamingResponse(watch(CONTENT_FILE), headers=headers)

特別注意Content-Type申明是text/event-stream,用以告知瀏覽器這是一個事件串流連線,要求瀏覽器以特定方式處理內容。這個特定方式後面會在提到,現在先看一下實現回傳了什麼樣的資料給瀏覽器。

def watch(file_path: str):
    message = "event: update\n"
    for line in open(file_path):
        message += f'data: {line}'
    message += '\n'
    yield message
    # 略後續......

watch(file)可以知道/connect優先回傳了<file>(也就是content.txt)的內容。格式基礎如下:

event: update
data: Hello World!

聲明事件類型為update,並緊跟著一行傳遞資料內容和一行空白行。實際上當content.txt爲多行的時候,實際格式爲:

event: update
data: Hello World!
data: 你好,世界怎麼跟得上臺灣!

也就是對於單一<event>,可以跟著多個data:部分,每個<data>代表資料的一行,然後同樣跟隨著一行空白行。如果你熟悉HTTP或Mail封包,我想你會和我同樣覺得這樣的格式設計很像HTTP Header的部分。

接著同樣去監看content.txt檔案是否有被修改過,若被修改過就在傳一次同樣格式的訊息出去。


def watch(file_path: str):
    # 前略...
    file_stat: FileStat = { "modified": False }
    observer = Observer()
    observer.schedule(WatchDogEvent(file_stat), CONTENT_FILE, recursive=False)

    observer.start()
    try:
        while True:
            if file_stat["modified"]:
                message = "event: update\n"
                for line in open(file_path):
                    message += f'data: {line}'
                message += '\n'
                yield message
                
                file_stat["modified"] = False
            time.sleep(1)
    except KeyboardInterrupt:
        observer.stop()
    observer.stop()

DEMO

現在可以嘗試啓動服務器看看

uvicorn app:app

開啓瀏覽器瀏覽 http://localhost:8000/index.html

技術細節

上面DEMO示例所傳送的資料格式是:

event: update
data: Hello World!
data: 你好,世界怎麼跟得上臺灣!

實際上<event>的申請是可選的,也就不需要添加。預設會是message事件,也就是說下面格式:

data: Hello World!
data: 你好,世界怎麼跟得上臺灣!

等同於

event: message
data: Hello World!
data: 你好,世界怎麼跟得上臺灣!

那麼在瀏覽器接受訊息的時候就應該寫成:

   evtSource.addEventListener('message', async (event) => {
       console.log(`recive message: ${event.data}`);
   })

註解

如同JavaScript、CSS、HTML都有定義註解格式一樣,直接以:開頭的將被當時註釋忽略。譬如:

: this is a test stream

雖然註解幾乎要使用其他網路工具才可以檢視到,但是定期發送一個訊息作爲heatbeat有助於維持雙方的連線。

註解將有助於防止連線逾時;伺服器端可以定時發送註解以維持連線活著。[^1]

不過我嘗試直接傳出其他格式內容,似乎也會被忽略處理。

錯誤處理

你可以透過設定<EventSource>onerror來處理在瀏覽器中的錯誤:

   evtSource.onerror = (err) => {
       console.error("EventSource failed:", err);
       evtSource.close();
   }

比如在發生錯誤後關閉連線。這裡也就不多做其他示例了。

小節語

其實我是優先寫WebSocket的處理方式的,因為原本擔心 HTTP/2 的 Server Push 和 Server Send Event(SSE) 指的是同一件事情,畢竟兩者從概觀上行為非常相像。不過後來了解到是不同東西,我找到一個 Server Push 的 DEMO 頁面。

可以見得在 Google Chrome 網頁瀏覽器下,有部分資源是由 Server Push 推送而來。 在 Mozilla Firefox Browser 觀察不到這樣的結果,並不確定瀏覽器的支援程度。
此外,在未來 Google Chrome 的版本中,也有可能不支援 Server Push,Chrome將移除不實用的HTTP/2伺服器推送功能

也因此原本計劃將其放在「你可能不知道的即時更新方案」中倒數第二,所以優先寫了 WebSocket 的內容,但其實可以看到Lab環境建立起的DEMO效果是與Server Push不同的。所以現在感覺起來 WebSocket 的示例似乎寫複雜了?。

然後關於 Heartbeat 這件事情啊,如果你是使用高級框架,那麼通常框架幫你做掉了,也就不需要自己做。但是這次這個 DEMO 並沒有使用什麼高層次的 API ,初衷就是希望盡可能接近原生 API 。 所以提醒一下,儘管我沒有做,但實際使用時,最好每隔固定時間,就由 Server 送出一跳簡單訊息作為 Heartbeat ,也是為了確保連線正常。更多情況使用時, API 伺服器前可能還有一層 API Gateway 作為反向代理。長時間沒有訊息傳遞,連線可能被關閉。

參考資料

[^1]: MDN-Server Send Event

本文同時發表於我的隨筆


上一篇
你可能不知道的即時更新方案:Long Polling
下一篇
你可能不知道的即時更新方案:WebSocket
系列文
這些那些你可能不知道我不知道的Web技術細節33
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言