同一份图片在调试工具里上传成功,换成 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 字节;文本视图只适合辅助检查字段头和分隔符。