告別無盡的轉圈圈:用 Server-Sent Events (SSE) 打造即時、透明的後端任務體驗

身為開發者,我們都遇過那個熟悉的場景:使用者點擊了一個按鈕,觸發了一個需要較長處理時間的後端任務,可能是產生一份複雜的報表、處理上傳的影片、或是呼叫 AI 模型進行深度分析。

畫面上,一個轉圈圈的 loading 圖示開始無盡地旋轉。使用者不知道現在是處理到一半、快完成了、還是系統早已崩潰。更糟的是,如果處理時間超過伺服器或瀏覽器的超時限制,這次請求就石沉大海,使用者體驗跌落谷底。

傳統的請求-回應(Request-Response)模型在這塊顯得力不從心。但幸運的是,有一個更優雅、更輕量的選擇:Server-Sent Events (SSE)

什麼是一般的 API 互動?想像你去訂製一台電腦

在典型的 RESTful API 世界裡,互動模式就像去一家沒有客服的電腦店訂製主機:

  1. 你(客戶端):向店員(伺服器)提交一份詳細的規格清單(發出一個 POST 請求)。
  2. 店員(伺服器):收下訂單,轉身走進倉庫開始組裝。你只能在店門口乾等。
  3. 等待…等待… 你完全不知道裡面發生了什麼事。主機板裝好了嗎?記憶體有貨嗎?你唯一的選擇就是繼續等待,或是放棄離開(請求超時)。
  4. 交易完成:很久之後,店員終於把一台完整包裝好的電腦交給你(伺服器回傳一個巨大的 JSON 或檔案)。

這種模式對於「獲取使用者資料」、「更新一筆訂單」等快速操作非常有效。但對於耗時任務,它就是一個極差的體驗。

SSE 如何改變遊戲規則?你的專屬進度回報熱線

現在,想像另一家智慧電腦店。當下訂單後,店員不但收下訂單,還給了一支專屬的客服熱線電話,並告訴你:「我們會隨時打電話向你回報進度。」

這就是 SSE 的運作精神:

  1. 你(客戶端):同樣提交規格清單,但這次你不是在門口乾等,而是使用 EventSource API 撥打了這支客服熱線(向伺服器的一個特定端點發起連線)。
  2. 伺服器:接起電話,說:「好的,我們開始組裝了!」並保持這條電話線路暢通
  3. 接收即時推送:接下來,你不需要做任何事,伺服器會主動打電話告訴你:
    • 叮鈴鈴... 「進度更新:我們已安裝好 CPU 和主機板。」
    • 叮鈴鈴... 「進度更新:記憶體和顯示卡已就位,正在進行初步測試。」
    • 叮鈴鈴... 「任務完成:電腦已組裝完畢,這是取貨單號!」
  4. 前端即時更新:在網頁上,每當接到一個「電話」(一個事件),就可以即時更新 UI 上的進度條或狀態訊息,讓使用者對整個過程一目了然。

SSE 建立了一條由伺服器到客戶端的單向、持久性的 HTTP 連線,讓伺服器能「主動」將數據流推送給客戶端。

SSE API vs. 傳統 API:重點比較

特性 傳統 API (RESTful) SSE API
互動模型 請求-回應 (Request-Response) 單向推送 (Server-Push)
連線生命週期 短暫的、一次性的 持久的、長期的
通訊主導方 永遠由客戶端發起 客戶端發起一次,伺服器後續主導推送
數據流向 雙向(請求 -> 回應) 單向(伺服器 -> 客戶端)
數據形式 一次性返回完整數據包 持續發送多個小數據片段 (事件流)
使用者體驗 對於耗時任務是個黑盒子,容易超時 即時、透明,顯著提升使用者體驗
最佳應用場景 CRUD 操作、獲取固定資源 即時進度更新、新聞推播、通知、AI 生成式回覆

SSE 互動流程的可視化

為了更清晰地展示這個流程,使用序列圖 (Sequence Diagram) 來描繪各個角色之間的互動。在這個場景中有三個主要角色:

  • Browser (瀏覽器):使用者介面。
  • Web Server (網站伺服器):負責接收請求和管理 SSE 連線。
  • Backend Worker (後端工作程序):實際執行耗時任務的服務。
sequenceDiagram
    participant Browser
    participant Web Server
    participant Backend Worker

    Note over Browser, Backend Worker: 1. 觸發耗時任務
    Browser->>Web Server: POST /api/start-task (例如:影片轉檔請求)
    Web Server->>Backend Worker: 開始執行任務 (taskId: xyz-123)
    Web Server-->>Browser: 202 Accepted { "taskId": "xyz-123" }

    Note over Browser, Backend Worker: 2. 建立 SSE 連線以監聽進度
    Browser->>Web Server: GET /api/task-status/xyz-123 (new EventSource())
    note over Web Server: 設置 Content-Type: text/event-stream<br/>並保持連線開啟

    loop 任務執行中
        Note over Browser, Backend Worker: 3. 後端回報進度,伺服器推送事件
        Backend Worker->>Web Server: 進度更新:25%
        Web Server-->>Browser: data: {"progress":25, "status":"影片讀取中..."}
        note over Browser: 更新 UI 進度條

        Backend Worker->>Web Server: 進度更新:75%
        Web Server-->>Browser: data: {"progress":75, "status":"轉檔中..."}
        note over Browser: 再次更新 UI 進度條
    end

    Note over Browser, Backend Worker: 4. 任務完成與連線關閉
    Backend Worker->>Web Server: 任務完成 (結果URL: /videos/final.mp4)
    Web Server-->>Browser: event: complete<br>data: {"url":"/videos/final.mp4"}
    note over Browser: 顯示完成訊息,觸發下載
    Web Server-->>Browser: 關閉 SSE 連線
    note over Browser: EventSource.readyState 變為 CLOSED

實戰範例:打造一個影片轉檔服務的即時進度 API

讓我們透過一個非常經典的應用場景——影片轉檔服務——來深入理解 SSE 的實戰應用。影片轉檔是一個典型的耗時任務,它具備以下特點:

  • 耗時長:根據影片大小和目標格式,可能需要數十秒到數小時。
  • 資源密集:非常消耗 CPU 和記憶體。
  • 多階段:過程可以被分解為多個步驟,如「讀取影片」、「分析元數據」、「轉碼中」、「合併音軌」、「完成」。

這些特點使得它成為展示 SSE 優勢的完美範例。我們的目標是打造一個系統,讓使用者上傳影片後,能即時看到轉檔的每一個進度。

整個流程被巧妙地拆分為三步,分離了「觸發」和「監控」的職責。


第一步:觸發任務並取得識別碼 (The Kick-off)

這是整個流程的起點。重點在於快速回應

目的:
使用者上傳影片後,我們不能讓他一直等著轉檔完成。伺服器的工作是立即接收請求,建立一個待辦任務,然後馬上給使用者一個「收據」——也就是獨一無二的 taskId

互動細節:

  1. 請求 (Client -> Server):前端使用一個標準的 POST 請求,將影片檔案傳送到 /api/transcode 端點。
  2. 處理 (Server):伺服器不進行實際轉檔。它做的是:
    • 驗證請求的合法性。
    • 將影片檔案儲存到暫存區。
    • 在資料庫或任務佇列(如 Redis、RabbitMQ)中建立一筆新的轉檔任務,並為其生成一個唯一的 taskId(例如 xyz-123-abc)。
    • 將此任務分派給後端的背景工作程序 (Backend Worker)。
  3. 回應 (Server -> Client):伺服器立即回傳 HTTP 202 Accepted 狀態碼。這個狀態碼的語意非常精確:「你的請求我已收到並接受,但我還沒處理完。」同時,在回應本文中附上關鍵的 taskId

程式碼範例:

# Client 發出請求
POST /api/transcode
Content-Type: multipart/form-data
[...影片檔案的二進位資料...]

# Server 立即回覆
HTTP/1.1 202 Accepted
Content-Type: application/json

{
  "taskId": "xyz-123-abc"
}

至此,使用者瀏覽器拿到了 taskId,它有了追蹤這個特定任務進度的憑證。請求-回應的生命週期就此結束,非常高效。


第二步:前端建立 SSE 連線,化身為進度監聽站 (The Listening Post)

拿到 taskId 後,前端的角色從「請求者」轉變為「監聽者」。

目的:
使用瀏覽器內建的 EventSource API,建立一條指向伺服器狀態端點的持久連線,並準備好接收任何伺服器推送過來的進度更新。

互動細節與程式碼解析:

EventSource 是專為 SSE 設計的 Web API,比使用 fetch 來模擬串流要簡單且強大得多。它會自動處理連線中斷後的重連。

// 從第一步的回應中取得 taskId
const taskId = 'xyz-123-abc'; 
const progressElement = document.getElementById('progress-status');

// 1. 建立 EventSource 實例,這會立即向伺服器發起一個長連線的 GET 請求。
const eventSource = new EventSource(`/api/transcode/status/${taskId}`);

// 2. 監聽 'message' 事件:這是預設事件。
//    如果伺服器發送的數據沒有指定 'event' 名稱,就會觸發 onmessage。
eventSource.onmessage = function(event) {
  // event.data 是伺服器推送過來的原始字串 (通常是 JSON 格式)
  const data = JSON.parse(event.data);

  // 更新使用者介面 (UI)
  progressElement.innerText = `[${data.progress}%] ${data.status}`;
};

// 3. 監聽自定義事件,例如 'complete'。
//    這讓我們的通訊更結構化,可以處理不同類型的消息。
eventSource.addEventListener('complete', function(event) {
  const data = JSON.parse(event.data);
  progressElement.innerText = `轉檔完成!影片連結:${data.url}`;

  // 4. 任務已完成,主動關閉連線,釋放資源。
  eventSource.close(); 
});

// 5. 錯誤處理是必須的。
//    如果網路中斷或伺服器關閉了連線,這裡會被觸發。
eventSource.onerror = function(err) {
  console.error("EventSource 連線失敗:", err);
  progressElement.innerText = '與伺服器的進度連線中斷。';
  eventSource.close(); // 同樣關閉,避免瀏覽器不斷重試
};

第三步:後端推送進度事件 (The Mission Control)

這是後端的 SSE 核心,負責管理連線並推送數據流。

目的:
/api/transcode/status/{taskId} 這個端點建立一個特殊的處理程序。它需要保持連線開啟,並在底層的轉檔任務有進展時,立即將進度格式化為 SSE 訊息並發送出去。

互動細節與 SSE 協議:

當後端發送 SSE 數據時,它必須遵循一個簡單的純文字格式。最重要的幾個欄位是:

  • data::訊息的內容。通常是一個 JSON 字串。
  • event::事件的自定義名稱(可選)。如果省略,則為預設的 message 事件。
  • id::事件的唯一 ID(可選)。用於斷線重連時,瀏覽器會將最後一個收到的 id 透過 Last-Event-ID 標頭傳回,讓伺服器可以從中斷的地方繼續。
  • retry::告知瀏覽器在斷線後應等待多少毫秒再嘗試重連(可選)。

每一條完整的訊息都必須以兩個換行符 \n\n 結尾。

程式碼解析 (以 Node.js/Express 為例):

app.get('/api/transcode/status/:taskId', (req, res) => {
  // 1. 設置 SSE 必要的回應標頭
  res.writeHead(200, {
    'Content-Type': 'text/event-stream', // 告訴瀏覽器這是一個事件流
    'Cache-Control': 'no-cache',         // 確保不被任何代理伺服器快取
    'Connection': 'keep-alive',          // 保持連線開啟
  });

  // 2. 創建一個輔助函式來標準化事件發送
  const sendEvent = (data, eventName) => {
    // 如果提供了 eventName,先寫入 event: 行
    if (eventName) {
      res.write(`event: ${eventName}\n`);
    }
    // 寫入 data: 行,內容必須是字串
    res.write(`data: ${JSON.stringify(data)}\n\n`); // 注意結尾的 \n\n
  };

  const taskId = req.params.taskId;

  // 3. 在真實世界中,這裡會訂閱一個訊息佇列或事件中心
  //    來接收來自背景工作程序的進度更新。
  //    我們用 setTimeout 模擬這個非同步過程。
  const task = findAndMonitorTask(taskId); // 假設這是一個返回事件發射器的函式

  task.on('progress', (progressData) => {
    // 收到進度更新,發送 'message' 事件
    sendEvent(progressData); // 使用預設事件名 'message'
  });

  task.on('complete', (completionData) => {
    // 收到完成通知,發送 'complete' 自定義事件
    sendEvent(completionData, 'complete');
  
    // 4. 發送完最後一條消息後,由伺服器主動結束響應,關閉連線。
    res.end();
  });

  // 5. 處理客戶端主動斷開連線的情況
  req.on('close', () => {
    console.log(`客戶端 ${taskId} 已關閉連線。`);
    // 在此可以通知背景工作程序停止任務,以節省資源。
    task.stop();
  });
});

透過這三步的協同工作,成功將一個體驗糟糕的長時間等待應用,改造成了一個互動性強、進度透明的現代化 Web 應用。這就是 SSE 在互動設計上的真正威力。

結論

Server-Sent Events 並不是要取代 RESTful API,而是作為它的絕佳補充。REST 負責處理狀態變更和資源請求,而 SSE 則專門處理將即時更新「推送」給客戶端的場景。

下次當面對一個需要長時間執行的後端任務時,別再讓使用者對著轉圈圈的圖示發呆了。試著導入 SSE,為他們打造一個反應靈敏、進度透明的現代化 Web 體驗吧!