iOS 应用接收压缩 JSON 时,抓包里只有几十 KB,交给解析器的内容却可能大得多。要查清应用究竟接受了什么,不必等待线上接口制造故障:在云端 Mac 上启动一个只监听本机的测试服务,再让同机运行的 iOS 模拟器请求它。下面用正常、校验值损坏、高解压量三种响应,检查 URLSession 返回的数据,以及应用在解析前设置的边界。
先划清传输长度与应用数据
gzip 压缩的是传输中的响应正文。Content-Length 可以表示压缩后发送的字节数;业务代码拿到的 Data 则可能是已经解压的正文。只对前者设上限,挡不住重复字符或高度相似的 JSON 片段在解压后占用大量内存。本文把 256 KiB 设为示例接口的解压后上限。这是测试阈值,不是所有 iOS 接口的通用值;你应根据真实响应规模另定。
测试还要区分“网络调用成功”和“正文可用”。状态码正确、请求没有超时,都不能代替对内容的校验。尤其是压缩流损坏时,不要预设系统一定以某一种错误返回;应用最终不能把损坏数据当成完整业务结果,才是需要验收的行为。
测试断言应落在应用使用的数据上,而不是只落在传输头或网络面板显示的压缩大小上。
在本机生成三种 gzip 响应
把下面的脚本保存为 /tmp/gzip_fixture.py,在云端 Mac 执行 python3 /tmp/gzip_fixture.py。服务只绑定 127.0.0.1:8765,不向公网开放。/ok 返回短正文;/broken 修改 gzip 尾部校验值;/large 发送可高度压缩、解压后为 1 MiB 的正文。三个路径都发送实际压缩数据,并按压缩后的长度填写响应头。
import gzip
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
SMALL = b'{"status":"ready"}'
LARGE = b"A" * 1_048_576
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path not in ("/ok", "/broken", "/large"):
self.send_error(404)
return
payload = LARGE if self.path == "/large" else SMALL
wire = bytearray(gzip.compress(payload))
if self.path == "/broken":
wire[-8] ^= 0xFF
self.send_response(200)
self.send_header("Content-Type", "application/octet-stream")
self.send_header("Content-Encoding", "gzip")
self.send_header("Content-Length", str(len(wire)))
self.end_headers()
self.wfile.write(wire)
ThreadingHTTPServer(("127.0.0.1", 8765), Handler).serve_forever()
这里特意改动校验值,而不是随意截断 TCP 连接:这样可以分别观察“完整传输但内容损坏”与连接中断。脚本提供的是固定输入,不声称代表真实接口的压缩率或吞吐量。运行测试前,用 lsof -nP -iTCP:8765 -sTCP:LISTEN 确认监听者确实是这次启动的进程;端口被占用时,先停掉旧服务,不要让用例误连到别的程序。
在模拟器中检查实际正文
以下 XCTest 示例放进工程已有的测试 target。它仅适用于与服务运行在同一台 Mac 上的模拟器:这里的 127.0.0.1 指向本机;如果改用真实设备,就必须重新设计服务地址和访问控制。测试工程需要允许这一开发环境中的本地 HTTP 请求;不要为了运行用例而放宽正式构建对远程请求的安全配置。
import Foundation
import XCTest
final class GzipResponseTests: XCTestCase {
private let base = URL(string: "http://127.0.0.1:8765")!
private func fetch(_ path: String) async throws -> Data {
let url = base.appendingPathComponent(path)
let (data, response) = try await URLSession.shared.data(from: url)
XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 200)
return data
}
func testValidBody() async throws {
let data = try await fetch("ok")
XCTAssertEqual(data, Data(#"{"status":"ready"}"#.utf8))
}
func testLargeDecodedBodyExceedsAppLimit() async throws {
let data = try await fetch("large")
XCTAssertGreaterThan(data.count, 256 * 1024)
}
func testDamagedBodyIsNotAcceptedAsComplete() async {
do {
let data = try await fetch("broken")
XCTAssertNotEqual(
data,
Data(#"{"status":"ready"}"#.utf8)
)
} catch {
return
}
}
}
先跑正常用例,再跑大正文和损坏用例。正常用例若收到 gzip 原始字节而非预期文本,就先检查响应头、测试环境和请求实现,不要直接把断言改成“只要有数据就通过”。损坏用例接受两种可观察结果:调用报错,或者返回的数据不等于完整预期正文。它不保证系统会在某个固定阶段拒绝校验值错误,也不替代业务层对 JSON 字段的验证。
把大小限制放在解析之前
在应用代码里,先检查 Data.count,再交给解码器。下面展示的是收到完整响应后的大小门槛;它可以避免继续解析过大的正文,不能避免下载及解压过程中已经发生的内存占用。若接口可能返回极大内容,还需要在应用架构上采用分块接收、磁盘暂存或服务端限制,不能只依赖这一行判断。
enum ResponseError: Error {
case tooLarge
}
func checkedBody(_ data: Data) throws -> Data {
guard data.count <= 256 * 1024 else {
throw ResponseError.tooLarge
}
return data
}
把大正文用例进一步改成断言 checkedBody(data) 抛出 tooLarge,正常正文则应通过。这样测试目标从“观察到差异”推进到“应用确实执行了边界”。如果你的接口返回 JSON,再在通过大小检查后执行 JSON 解码,并核对必需字段;不要把“能解析”误当作“内容来自预期接口”。
收尾时留下可复现的检查项
提交这组测试前,核对五件事:服务只监听本机;测试明确使用同机模拟器;正常响应比对完整正文;损坏响应不能被接受为完整内容;大小限制针对 URLSession 交给应用的 Data。最后停止临时服务,确认 8765 端口不再监听,并检查测试 target 没有把本地 HTTP 的例外带进正式构建。
这套夹具刻意保持很小。后续如果要覆盖空正文、错误状态码或不同编码,应新增独立路径和独立断言,不要把多种故障塞进一个偶尔变化的响应。固定输入、明确边界、检查应用实际使用的字节,比仅凭抓包里的压缩长度判断安全得多。
常见问题
为什么不能只检查响应头里的 Content-Length?
它描述的是传输中的压缩字节数,不等于应用最终收到的解压数据量。大小限制应检查 URLSession 返回的 Data。
校验值损坏后,测试是否必须断言 URLSession 抛错?
不必把特定错误形式当作契约。测试应要求应用不能把损坏响应当作完整正文接受:抛错或数据校验失败都可以。
需要动手验证?
租一台独享 Mac mini,直接跑你的构建
从两档 M4 物理机中选一台,按实际需要选择租期与节点。可用性以控制台实时返回为准。