在雲端 Mac 的 iOS 模擬器裡,訊息列表看起來一切正常;換到真實網路後,連線短暫中斷卻可能漏掉一筆狀態更新,或把同一則通知處理兩次。SSE(Server-Sent Events,伺服器持續向用戶端傳送文字事件)特別容易遇到這類問題:HTTP 請求成功,不代表用戶端已處理完最後一筆事件。以下使用只監聽本機的測試服務,把中斷與續傳拆成可重複執行的檢查。
先釐清續傳約定
SSE 事件由數行欄位組成,以空行結束。id 是伺服器賦予事件的游標;用戶端重新連線時,會把最後一筆已處理事件的 ID 放進 Last-Event-ID 請求標頭。伺服器據此回傳後續事件。
「讀到 ID」和「處理完事件」是兩回事。連線可能剛好斷在 id: 2 與後面的 data 之間。如果此時就儲存 ID 2,重新連線便會從 3 開始,事件 2 將永遠遺失。應先收齊結尾空行,再完成業務處理,最後才提交游標。如果處理動作會寫入資料庫,還應依事件 ID 去重:提交處理結果與儲存游標之間也可能發生中斷。
測試的目的不是證明連線永遠不會中斷,而是證明中斷前後的事件集合一致,且每個 ID 最多只產生一次業務效果。
啟動可控制的本機事件來源
在雲端 Mac 建立 sse_fixture.py。腳本只綁定 127.0.0.1,不會將測試連接埠暴露到外部。cut=1 表示送出一筆事件後就結束回應;再次請求時,伺服器會讀取請求標頭,從下一筆事件繼續傳送。
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs, urlsplit
EVENTS = [(1, "queued"), (2, "building"), (3, "finished")]
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
url = urlsplit(self.path)
if url.path != "/events":
self.send_error(404)
return
try:
last = int(self.headers.get("Last-Event-ID", "0"))
except ValueError:
self.send_error(400)
return
cut = parse_qs(url.query).get("cut") == ["1"]
self.send_response(200)
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.end_headers()
for event_id, value in EVENTS:
if event_id <= last:
continue
payload = f"id: {event_id}\ndata: {value}\n\n".encode()
try:
self.wfile.write(payload)
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError):
break
if cut:
break
HTTPServer(("127.0.0.1", 8765), Handler).serve_forever()
執行 python3 sse_fixture.py,另開終端機檢查兩段回應:
curl -Ns 'http://127.0.0.1:8765/events?cut=1'
curl -Ns -H 'Last-Event-ID: 1' 'http://127.0.0.1:8765/events'
第一段應只有 ID 1,第二段應依序出現 ID 2、3。這裡以正常結束測試「串流結束後重新請求」;正式環境中的連線重設、逾時與半筆事件,仍需另外注入故障,不能把一次正常結束當作涵蓋所有斷線情境。
在 iOS 端依完整事件提交
測試用戶端可以使用 URLSession.bytes(for:) 逐行讀取。以下程式片段要求部署目標支援該介面;它只處理單次連線,重試排程由呼叫端負責。只有收到空行才交付事件,結尾不完整的片段則直接捨棄。
import Foundation
struct StreamEvent {
let id: String
let data: String
}
func readEvents(after lastID: String?, url: URL) async throws -> [StreamEvent] {
var request = URLRequest(url: url)
if let lastID {
request.setValue(lastID, forHTTPHeaderField: "Last-Event-ID")
}
let (bytes, response) = try await URLSession.shared.bytes(for: request)
guard let http = response as? HTTPURLResponse,
http.statusCode == 200 else {
throw URLError(.badServerResponse)
}
var result: [StreamEvent] = []
var eventID: String?
var data: [String] = []
for try await line in bytes.lines {
if line.isEmpty {
if let eventID, !data.isEmpty {
result.append(StreamEvent(id: eventID, data: data.joined(separator: "\n")))
}
eventID = nil
data = []
} else if line.hasPrefix("id:") {
eventID = String(line.dropFirst(3)).trimmingCharacters(in: .whitespaces)
} else if line.hasPrefix("data:") {
data.append(String(line.dropFirst(5)).dropFirst(
line.dropFirst(5).first == " " ? 1 : 0
).description)
}
}
return result
}
這個最小範例會保留多行 data,並忽略心跳行與其他欄位;正式解析器還須依協定處理沒有 ID 的事件、空 ID 與重試提示。範例回傳陣列是為了方便觀察測試結果,不適合持續執行的正式環境串流:長連線應邊接收邊處理,不要讓事件一直堆積在記憶體中。
用模擬器進行兩輪斷線檢查
先在 Mac 上以 curl 確認服務可連線,再讓 iOS 模擬器請求 http://127.0.0.1:8765/events?cut=1。模擬器通常可以存取所在 Mac 的本機服務;實機的 127.0.0.1 則指向實機本身,不能直接照用。如果應用程式預設禁止明文 HTTP,只在偵錯建置中設定範圍受限的本機網路例外,不要把寬泛的例外帶進發行設定。
第一輪記錄事件 1,處理成功後才將游標設為 1。第二輪移除 cut=1,帶上 Last-Event-ID: 1,確認收到的 ID 恰好是 2、3。接著刻意讓第一次業務處理失敗:游標必須維持舊值;再次收到事件時,應依 ID 去重或安全地重試。最後取消讀取工作,並確認重新連線計時器也已取消,否則離開頁面後可能仍有背景請求。
| 檢查點 | 通過條件 |
|---|---|
| 完整事件 | 收到結尾空行後才開始業務處理 |
| 斷線續傳 | 已提交 ID 之後的事件依序抵達 |
| 重複投遞 | 相同 ID 不會產生第二次業務效果 |
| 離開頁面 | 讀取工作與尚待執行的重試一併停止 |
將失敗歸因到正確的環節
如果第二輪從頭回傳,先檢查請求標頭是否確實帶上已提交的 ID,再確認伺服器是否支援依游標篩選;不要直接歸咎於模擬器。如果只有最後一筆遺失,應重點檢查解析器是否把「串流結束」誤認為事件邊界。如果重新連線造成重複通知,應檢查業務去重,而不是只把重試間隔調長。
還要區分「連線成功」與「事件交付成功」。伺服器傳完位元組便斷線,無法證明用戶端已寫入本機狀態;用戶端儲存了游標,也無法證明遠端業務操作天生可以安全重試。將事件 ID、處理結果與提交後的游標放在同一份測試紀錄中,下次修改解析器或重試邏輯時,才能逐項比對,而不是只靠肉眼盯著訊息列表判斷。
常見問題
讀到事件 ID 後就能儲存 Last-Event-ID 嗎?
不能只因讀到 id 欄位就儲存。應等事件組裝完整、業務處理成功後才提交 ID,避免中斷時跳過未處理資料。
模擬器能連到只監聽 127.0.0.1 的服務嗎?
iOS 模擬器通常能存取同一台 Mac 的本機服務。先以 curl 確認連線;實體裝置不能沿用這個位址。
想親自測試看看?
租用專屬 Mac mini,直接執行你的建置工作
從兩款 M4 實體主機中挑選,並依需求選擇租用期間與節點。實際供應情況以控制台即時顯示為準。