云端 Mac 上测试 iOS SSE 流式事件断线续传

云端 Mac 上测试 iOS SSE 流式事件断线续传

消息列表在云端 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;否则中断时可能跳过尚未处理的数据。

模拟器能连接 Mac 上仅监听 127.0.0.1 的测试服务吗?

iOS 模拟器通常可以访问所在 Mac 的本机地址。先在 Mac 上用 curl 验证端口,再从模拟器测试;真机不能沿用这一地址。

需要动手验证?

租一台独享 Mac mini,直接跑你的构建

从两档 M4 物理机中选一台,按实际需要选择租期与节点。可用性以控制台实时返回为准。

立即租用 Mac mini