云端 Mac 上验收 iOS multipart 请求编码与二进制边界

云端 Mac 上验收 iOS multipart 请求编码与二进制边界

同一份图片在调试工具里上传成功,换成 iOS 客户端却得到“缺少文件字段”,先别调整超时。把上传请求体带到云端 Mac,用固定输入检查原始字节,通常能更快分清:是字段名不符、换行错误,还是文件内容被字符串转换破坏。你的那台开发机只需要可用的 Swift 工具链,不需要连接正式上传服务。

先定义请求体的字节契约

multipart/form-data 不是把文件拼进一段文本。每个部分由边界行、字段头、空行和内容组成;协议换行使用 CRLF,即两个字节 0D 0A。文件结束后也要补 CRLF,最后的边界还要带两个尾部连字符。

先向接口维护者确认三个事实:文本字段名称、文件字段名称、是否允许同名字段重复。下面约定文本字段为 note,文件字段为 file,按数组顺序编码,不用字典抹掉重复键。

验收对象是最终发送的 Data,不是日志里看起来正常的一段字符串。截图不能证明二进制内容没有变化。

测试固定 boundary,便于比较;正式请求生成随机值,并确认没有与内容碰撞。请求头里的 boundary 参数不带分隔行额外添加的两个前导连字符。

写一个范围明确的编码器

将下面代码保存为 multipart-check.swift。它只支持安全的 ASCII 字段名和文件名,文件部分固定使用 application/octet-stream。这是一份有意收窄的验收实现,不承诺覆盖所有服务端对国际化文件名的处理方式。

import Foundation

struct Part {
    let name: String
    let filename: String?
    let bytes: Data
}

enum EncodingError: Error {
    case invalidHeader
    case invalidBoundary
    case boundaryCollision
}

func safeHeader(_ value: String) -> Bool {
    !value.isEmpty && value.utf8.allSatisfy {
        $0 >= 32 && $0 <= 126 && $0 != 34 && $0 != 92
    }
}

func encode(_ parts: [Part], boundary: String) throws -> Data {
    guard (1...70).contains(boundary.utf8.count),
          boundary.utf8.allSatisfy({
              (48...57).contains($0) ||
              (65...90).contains($0) ||
              (97...122).contains($0) || $0 == 45
          }) else {
        throw EncodingError.invalidBoundary
    }

    var body = Data()
    func append(_ text: String) {
        body.append(Data(text.utf8))
    }

    for part in parts {
        guard safeHeader(part.name),
              part.filename.map({ safeHeader($0) }) ?? true else {
            throw EncodingError.invalidHeader
        }
        guard part.bytes.range(of: Data(boundary.utf8)) == nil else {
            throw EncodingError.boundaryCollision
        }

        append("--\(boundary)\r\n")
        var header = "Content-Disposition: form-data; name=\"\(part.name)\""
        if let filename = part.filename {
            header += "; filename=\"\(filename)\""
        }
        append(header + "\r\n")
        append(part.filename == nil
            ? "Content-Type: text/plain; charset=utf-8\r\n"
            : "Content-Type: application/octet-stream\r\n")
        append("\r\n")
        body.append(part.bytes)
        append("\r\n")
    }
    append("--\(boundary)--\r\n")
    return body
}

func expectFailure(_ action: () throws -> Data) {
    do {
        _ = try action()
    } catch {
        return
    }
    fatalError("Expected encoding failure")
}

let boundary = "fixture-boundary"
let binary = Data([0x00, 0xFF, 0x0D, 0x0A, 0x80])
let parts = [
    Part(name: "note", filename: nil, bytes: Data("验收".utf8)),
    Part(name: "file", filename: "sample.bin", bytes: binary)
]
let body = try encode(parts, boundary: boundary)
precondition(body.starts(with: Data("--\(boundary)\r\n".utf8)))
precondition(body.suffix(Data("--\(boundary)--\r\n".utf8).count)
    == Data("--\(boundary)--\r\n".utf8))
let filePrefix = Data(
    ("Content-Disposition: form-data; name=\"file\"; filename=\"sample.bin\"\r\n"
    + "Content-Type: application/octet-stream\r\n\r\n").utf8
)
guard let headerRange = body.range(of: filePrefix) else {
    fatalError("Missing file header")
}
let start = headerRange.upperBound
precondition(body.subdata(in: start..<(start + binary.count)) == binary)
precondition(body.subdata(in: (start + binary.count)..<body.count)
    == Data("\r\n--\(boundary)--\r\n".utf8))

expectFailure {
    try encode([
        Part(name: "file", filename: "bad\r\nInjected: yes", bytes: binary)
    ], boundary: boundary)
}
expectFailure {
    try encode([
        Part(name: "note", filename: nil, bytes: Data(boundary.utf8))
    ], boundary: boundary)
}

代码把文件作为 Data 直接追加,不执行 UTF-8 解码。头字段拒绝控制字符、双引号和反斜杠,避免用户提供的文件名逃出引号或插入新字段头。这里的碰撞检查比协议要求更保守:内容中出现 boundary 本身就拒绝,生产环境可以重新生成后重试。

在云端 Mac 执行并补齐样本

先运行 xcrun swift multipart-check.swift。正常结果是无输出且退出码为零;断言失败或编码异常都会产生非零结果。不要用关闭断言的优化模式执行这份验收脚本。

当前二进制样本故意包含零字节、无效 UTF-8 和 CRLF。断言不仅搜索文件内容,还检查文件后紧接结束分隔行,避免“字节存在,但位置错误”也通过。

增加四组失败与边界输入

继续加入空文件、空文本、两个同名文本字段,以及名称含双引号的部分。空内容仍应保留头后的空行与内容后的 CRLF;重复字段必须保留次数和顺序。另加中文文件名样本,明确预期为拒绝,而不是悄悄改名。

若业务必须接收中文文件名,先确定服务端支持的编码约定,再扩展实现。不要擅自把文件名百分号编码后当作通用修复,也不要直接放开所有 Unicode 字符而跳过头注入检查。

接入 URLSession 时保持同一份数据

正式接入时,把 boundary 和编码后的 Data 一起交给请求构造层。Content-Type 使用 multipart/form-data; boundary= 加实际值,再将同一个 Data 交给上传任务。不要让两层代码分别生成边界。

小样本可以在内存编码;大文件则应分段写入临时请求体文件,再使用文件上传方式,避免同时持有原文件和完整请求体两份数据。改成文件编码后,要对最终文件重跑相同字节验收。

如果业务设置 Content-Length,必须取最终请求体的字节数,不能取字符数或原文件大小。是否需要手动设置应由网络层统一决定,编码器不要另写一个可能失真的长度值。

把协议验收和接口验收分开

本地字节检查通过,只能证明符合这份编码契约。接下来让测试接口解析请求,核对字段值、文件长度及文件摘要,再检查服务端对重复字段、空文件和文件名的实际行为。

发现失败时,保留 boundary、字节长度和合成样本即可,不归档真实用户上传内容。诊断顺序固定为:先查分隔符,再查字段头,最后比对文件字节;遇到文件超限或接口拒绝类型,回到服务端约定,不要靠改换行掩盖业务错误。

最终把脚本纳入提交检查,并让测试报告记录工具链版本与退出码。这样,上传模块换实现时,你验收的是相同的字节契约,而不是某一次偶然成功的网络请求。

常见问题

生产请求也应该使用固定 boundary 吗?

不应该。固定 boundary 只用于可重复测试;生产环境应生成随机边界,检查它没有出现在各部分内容中,再用同一个值设置请求头和编码请求体。

能把整个请求体转成字符串后检查吗?

不能作为二进制验收方法。文件内容可能不是有效 UTF-8,应直接比较 Data 字节;文本视图只适合辅助检查字段头和分隔符。

为你的工程,保留一台独享 Mac

Mac mini 物理节点,非虚拟机。按天、周、月或季租用,先选用途与配置,再核对控制台实时返回的可用状态。

选择机型与租期 查看完整价格