云端 Mac 上验收 iOS URL 查询参数编码与重复键契约

云端 Mac 上验收 iOS URL 查询参数编码与重复键契约

搜索框输入 C++ 入门,服务端却收到 C 入门;筛选条件传了两个同名键,最后只剩一个。这类故障通常不是网络丢包,而是客户端编码与服务端解码约定不一致。在云端 Mac 上把 URL 构造器拆成纯函数,用 XCTest 固定边界,比反复抓线上请求更容易定位,也不需要启动模拟器。

先写清楚参数契约

查询字符串不是字典。接口需要先约定:同名键能否重复,顺序是否有意义,缺少参数、无值参数与空字符串是否等价,以及服务端是否采用表单解码规则。前端与后端分别“使用标准库”,并不代表这些答案相同。

例如 flag、flag=、完全没有 flag 是三种不同输入。客户端可以用 URLQueryItem 的 nil、空字符串和不添加项目分别表达;服务端框架是否保留区别,要用实际解析器验证。

普通 URL 查询中的字面加号不天然等于空格。把加号解释为空格,是表单编码体系中的规则,不能直接推广到所有查询接口。

将这些约定写进测试名称。不要只保存一条“正常搜索成功”的用例,否则中文能通过,加号和重复筛选仍可能出错。

用纯函数构造 URL

创建一个 macOS 单元测试目标,加入下面的 Swift 文件。示例地址只用于本地解析,不会发出请求。业务接入时,将固定地址换成工程里的接口配置;参数值必须传入原文,不能先手动百分号编码。

import Foundation
import XCTest

func makeQueryURL(
    items: [URLQueryItem],
    formDecoder: Bool = false
) throws -> URL {
    var parts = URLComponents()
    parts.scheme = "https"
    parts.host = "xcodenode.com"
    parts.path = "/query-fixture"
    parts.queryItems = items

    if formDecoder, let encoded = parts.percentEncodedQuery {
        parts.percentEncodedQuery = encoded.replacingOccurrences(
            of: "+",
            with: "%2B"
        )
    }

    return try XCTUnwrap(parts.url)
}

final class QueryEncodingTests: XCTestCase {
    func testReservedCharactersRoundTrip() throws {
        let value = "C++ 入门 & x=1 # 50% / ?"
        let url = try makeQueryURL(items: [
            URLQueryItem(name: "q", value: value)
        ])
        let parts = try XCTUnwrap(
            URLComponents(url: url, resolvingAgainstBaseURL: false)
        )
        let items = try XCTUnwrap(parts.queryItems)
        XCTAssertEqual(items.count, 1)
        XCTAssertEqual(items.first?.value, value)
        XCTAssertNil(parts.fragment)
    }

    func testDuplicatesAndEmptyValues() throws {
        let url = try makeQueryURL(items: [
            URLQueryItem(name: "tag", value: "swift"),
            URLQueryItem(name: "tag", value: "ios"),
            URLQueryItem(name: "flag", value: nil),
            URLQueryItem(name: "empty", value: "")
        ])
        let parts = try XCTUnwrap(
            URLComponents(url: url, resolvingAgainstBaseURL: false)
        )
        let items = try XCTUnwrap(parts.queryItems)
        XCTAssertEqual(items.map(\.name), [
            "tag", "tag", "flag", "empty"
        ])
        XCTAssertEqual(items.map(\.value), [
            "swift", "ios", nil, ""
        ])
    }

    func testFormDecoderReceivesLiteralPlus() throws {
        let value = "C++ 50%"
        let url = try makeQueryURL(
            items: [URLQueryItem(name: "q", value: value)],
            formDecoder: true
        )
        let parts = try XCTUnwrap(
            URLComponents(url: url, resolvingAgainstBaseURL: false)
        )
        let query = try XCTUnwrap(parts.percentEncodedQuery)
        XCTAssertFalse(query.contains("+"))
        let pair = try XCTUnwrap(
            query.split(separator: "=", maxSplits: 1).last
        )
        let decoded = String(pair)
            .replacingOccurrences(of: "+", with: " ")
            .removingPercentEncoding
        XCTAssertEqual(decoded, value)
    }
}

第一项检查保留字符没有生成额外参数或片段;第二项确认数组中的重复键与顺序未丢失;第三项模拟表单解码的一次处理。它不是完整服务端解析器,只验证单个值的加号边界,不能替代接口集成测试。

扩充失败样本而不是扩大联网范围

在相同夹具中补充中文、组合字符、表情、空值、前后空格,以及字面字符串 %2B。最后一项尤其重要:用户输入的百分号需要被编码,不能被当成已经编码的加号。

遇到 %252B,先检查输入究竟是原始加号还是字面 %2B。前者通常说明二次编码,后者可能正是正确结果。仅凭看到 %25 就判错,会制造新的修复故障。

不要使用一个宽泛字符集合,对拼好的整条 URL 执行编码。参数名、参数值、路径属于不同上下文;直接拼接 &、=、# 可能改变结构。让构造器负责结构,再分别检查解析后的项目数量、名称和值。

超长搜索词另设输入长度限制。编码后的字节数与字符数不同,限制策略应按接口约定测试,而不是假设中文一个字符只占一个字节。

对签名请求增加字节层验收

语义相同的 URL 不一定有相同字节。参数顺序、空格的表示方式、百分号转义的大小写,都可能影响签名。普通查询测试可以验证往返值相等;签名测试还必须验证协议规定的规范化字符串。

若协议要求排序,先明确重复键如何排序,再对最终字符串签名。若要求保持顺序,就保留项目数组,不能借字典重建。签名完成后不要再切换加号处理模式,否则传输内容与签名输入会分离。

固定样本应包含同名键、空值与非 ASCII 文本,并断言最终编码字符串。这里的“固定”指工程约定的格式,不是把某次系统输出无条件当成永久标准。服务端也要保存相同样本,确认双方签名输入一致。

在云端节点执行并保存证据

测试目标使用 Foundation 即可,不必配置应用签名。通过测试目标的共享 Scheme 执行;下面的名称应替换为工程实际名称,结果目录必须尚不存在。

set -euo pipefail
mkdir -p artifacts
result="artifacts/query-$(uuidgen).xcresult"
xcodebuild test \
  -project QueryContract.xcodeproj \
  -scheme QueryContract \
  -destination 'platform=macOS' \
  -resultBundlePath "$result"

运行前记录 Xcode 版本、提交标识及构造器模式。同一批测试保持工具链一致;升级工具链后重新检查签名字节,不要只看绿色结果数量。

验收时确认四件事:原始值没有预编码;重复键没有被字典吞掉;表单解码开关由接口契约决定;实际服务端解析器通过同一组样本。失败报告保存输入类别和编码结果即可,涉及真实查询词或令牌时改用合成样本。

最终让纯函数测试守住客户端结构,让服务端契约测试守住解码边界。两层都通过,才能说明参数被正确传递,而不只是两次客户端解析碰巧相等。

常见问题

URLComponents 会把查询值里的加号编码成 %2B 吗?

不能依赖它自动这么做。普通 URL 查询允许保留加号,但表单解码器可能将其解释为空格。若接口明确采用表单解码,应在 URLComponents 完成编码后,把 percentEncodedQuery 中剩余的字面加号替换为 %2B,并增加服务端解码测试。

查询参数能否先转成字典再参与签名?

只有协议明确禁止重复键、忽略顺序并定义了规范化规则时才适合这样做。否则字典会丢失重复项或顺序。应保留 URLQueryItem 数组,并对签名协议要求的最终编码字节建立独立测试。

为开发任务选一台独享云端 Mac

Apple Silicon 物理节点,非虚拟机。按天、周、月或季租用,机型与价格公开可查,实际可用状态以控制台实时返回为准。

立即租用云端 Mac 查看机型与租期