雲端 Mac 上檢核 iOS multipart 請求編碼與二進位邊界
同一張圖片在除錯工具中上傳成功,換成 iOS 用戶端卻收到「缺少檔案欄位」的錯誤,先別急著調整逾時設定。把上傳請求本文帶到雲端 Mac,以固定輸入檢查原始位元組,通常能更快釐清:究竟是欄位名稱不符、換行錯誤,還是檔案內容在轉成字串時遭到破壞。開發機只需要可用的 Swift 工具鏈,不必連線到正式上傳服務。
先定義請求本文的位元組契約
multipart/form-data 並不是把檔案拼進一段文字。每個部分都由邊界分隔行、欄位標頭、空行和內容組成;協定使用 CRLF 換行,也就是 0D 0A 這兩個位元組。檔案內容結束後也要補上 CRLF,最後的邊界分隔行還必須加上兩個結尾連字號。
先向 API 維護者確認三件事:文字欄位名稱、檔案欄位名稱,以及是否允許同名欄位重複出現。以下約定文字欄位為 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 本身就會拒絕編碼;正式環境可以重新產生 boundary 後再試一次。
在雲端 Mac 執行並補齊測試樣本
先執行 xcrun swift multipart-check.swift。正常結果是不產生輸出,且結束碼為零;斷言失敗或編碼發生例外,都會得到非零的結束碼。不要以會停用斷言的最佳化模式執行這份驗收指令碼。
目前的二進位樣本刻意包含零位元組、無效的 UTF-8 位元組與 CRLF。斷言不只是搜尋檔案內容,還會檢查檔案後方是否緊接結束分隔行,避免「位元組存在,但位置錯誤」的情況也通過驗收。
增加四組失敗與邊界輸入
接著加入空檔案、空文字、兩個同名文字欄位,以及名稱含有雙引號的部分。即使內容為空,仍應保留標頭後的空行與內容後的 CRLF;重複欄位則必須保留出現次數與順序。另外加入中文檔名樣本,明確將預期結果設為拒絕,而不是默默重新命名。
如果業務需求必須接受中文檔名,先確認伺服器支援的編碼約定,再擴充實作。不要自行將檔名做百分比編碼就當成通用修正,也不要直接允許所有 Unicode 字元,卻略過標頭注入檢查。
串接 URLSession 時沿用同一份資料
正式串接時,將 boundary 與編碼後的 Data 一起交給請求建構層。Content-Type 使用 multipart/form-data; boundary= 加上實際值,再將同一份 Data 交給上傳任務。不要讓兩層程式碼各自產生邊界。
小型樣本可以在記憶體中編碼;大型檔案則應分段寫入暫存的請求本文檔案,再採用從檔案上傳的方式,避免同時持有原始檔案與完整請求本文兩份資料。改為寫入檔案後,必須對最終檔案重新執行相同的位元組驗收。
如果業務邏輯會設定 Content-Length,必須使用最終請求本文的位元組數,不能使用字元數或原始檔案大小。是否需要手動設定,應由網路層統一決定;編碼器不要另外產生一個可能不準確的長度值。
將協定驗收與 API 驗收分開
本機位元組檢查通過,只能證明符合這份編碼契約。接下來要讓測試 API 解析請求,核對欄位值、檔案長度與檔案雜湊摘要,再檢查伺服器對重複欄位、空檔案及檔名的實際處理方式。
發現失敗時,保留 boundary、位元組長度與合成樣本即可,不要封存使用者實際上傳的內容。診斷順序固定為:先查分隔符號,再查欄位標頭,最後比對檔案位元組。若遇到檔案超出大小限制,或 API 拒絕檔案類型,應回頭確認伺服器端約定,不要靠改換行來掩蓋業務錯誤。
最後將指令碼納入提交檢查,並讓測試報告記錄工具鏈版本與結束碼。如此一來,上傳模組更換實作時,驗收的仍是同一份位元組契約,而不是某一次偶然成功的網路請求。
常見問題
正式請求也應使用固定 boundary 嗎?
不應該。固定 boundary 僅供可重複測試;正式環境應產生隨機邊界,確認它未出現在各部分內容中,再以同一個值設定標頭及編碼請求本文。
可以把整個請求本文轉成字串來檢查嗎?
不能用來驗收二進位資料。檔案內容可能不是有效的 UTF-8,應直接比較 Data 位元組;文字檢視僅適合輔助檢查欄位標頭與分隔符號。