在云端 Mac 上验收 iOS 压缩响应的完整性与解压上限

在云端 Mac 上验收 iOS 压缩响应的完整性与解压上限

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

立即租用 Mac mini