消息列表在云端 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 物理机中选一台,按实际需要选择租期与节点。可用性以控制台实时返回为准。