以流的形式執行 Multipart 請求

SwiftGG翻譯組發表於2019-01-21

作者:Soroush Khanlou,原文連結,原文日期:2018-11-14譯者:鄭一一;校對:numbbbbbpmst;定稿:Forelax

Foundation 框架中的 URL 類提供了非常全面的功能,此後還在 iOS 7 中新增了 URLSession 類。儘管如此,基礎庫中仍然缺少 multipart 檔案上傳的功能。

什麼是 multipart 請求?

Multipart 編碼實際上就是在網路中上傳大型檔案的方法。在瀏覽器中,有時候你會選擇一個檔案作為表單提交內容的一部分。這個檔案便是以 multipart 請求的方式實現上傳的。

乍一看,multipart 請求和一般請求差不多。不同之處是 multipart 請求額外為 HTTP 請求體指定了唯一編碼。同 JSON 編碼({"key": "value"
}
)或者 URL 字元編碼 (key=value) 相比,multipart 編碼乾的事略微有所不同。因為 multipart 請求體實際上只是一串位元組流,接收端實體在解析資料時,需要知道位元組流中各個部分之間的界限。所以 multipart 請求需要使用 “boundaries” 來解決這個問題。在請求首部的 Content-Type 中,可以定義 boundary:

Accept: application/jsonContent-Type: multipart/form-data;
boundary=khanlou.comNczcJGcxe複製程式碼

Boundary 的具體內容並不重要,唯一需要注意的是:在請求體中,boundary 是不能重複出現(這樣才能體現 boundary 的作用)。你可以使用 UUID 作為 boundary。

請求的每一部分可以是普通資料(比如圖片)或者後設資料(一般是文字,對應一個名字,組成一個鍵值對)。如果資料是圖片的話,那它看起來應該是這樣的:

--<
boundary>
Content-Disposition: form-data;
name=<
name>
;
filename=<
filename.jpg>
Content-Type: image/jpeg<
image data>
複製程式碼

如果是普通文字,則是這樣:

--<
boundary>
Content-Disposition: form-data;
name=<
name>
Content-Type: text/plain<
some text>
複製程式碼

請求結尾會有一個帶著兩個連字元的 boundary,--<
boundary>
--
。(此處需要注意,所有新行必須是回車換行。)

以上就是關於 multipart 請求的所有內容,並不是特別複雜。事實上,當在寫第一個有關 multipart 編碼的客戶端實現時,我有些牴觸閱讀 multipart/form-data 的 RFC。可是在開始閱讀之後,我對這個協議的理解更深了。整個文件可讀性很強,很輕易就能直達知識的源頭。

我在開源的 Backchannel SDK 實現了上述功能。BAKUploadAttachmentRequestBAKMultipartRequestBuilder 類包含了處理 mulitipart 的方法。在這個專案中,僅僅包含了處理單個檔案的情況,並且沒有包括後設資料。但是作為範例,依舊很好地展示了 mulitipart 請求是如何構建的。可以通過新增額外的實現程式碼,來支援後設資料和多檔案的功能。

無論是使用一個請求上傳多個檔案,還是多個請求分別對應上傳一個檔案,來實現多檔案上傳功能,都會碰到一個問題。這個問題就是,如果你嘗試一次性上傳很多檔案的話,app 將會閃退。這是因為使用 該版本的程式碼,載入的資料會直接進入記憶體,在記憶體暴漲的情況下,即使使用當下效能最強的旗艦手機也會有閃退發生。

將硬碟中資料以流的形式讀取

最常見的解決方法是將硬碟中的資料以流的形式讀取出來。其核心思想是檔案的位元組資料會一直儲存在硬碟裡,直到被讀取併發往網路。記憶體中只保留了很小一部分的映象資料。

目前,我想出兩種方法可以解決這個問題。第一個方法,把 multipart 請求體中的所有資料寫到硬碟的一個新檔案中,並使用 URLSession 的 uploadTask(with request: URLRequest, fromFile fileURL: URL) 方法將檔案轉化為流。這個方法可以奏效,但我並不想為每一個請求新建一個新檔案儲存到硬碟中。因為這意味著在請求發出後,還需要刪除這個檔案。

第二種方法是將記憶體和硬碟的資料合併在一起,並通過統一的介面向網路輸出資料。

如果你覺得第二種方法聽起來像是 類簇,恭喜你,完全正確。很多常用 Cocoa 類都允許建立子類,並實現一些父類方法,使其和父類表現一致。回想一下 NSArray-count 屬性和 -objectAtIndex: 方法。因為 NSArray 的所有其它方法都是基於 -count 屬性和 -objectAtIndex: 方法實現的,你可以非常輕易地建立優化版本的 NSArray 子類。

你可以建立一個 NSData 子類,它無需真正從硬碟讀取資料,而只是建立一個指標直接指向硬碟中的資料。這樣做的好處是是不需要把資料載入記憶體中進行讀取。這種方法稱為記憶體對映,基於 Unix 方法 mmap。你可以通過 .mappedIfSafe 或者 alwaysMapped 選項,來使用 NSData 的這項特性。因為 NSData 是一個類簇,我們將建立一個 ConcatenatedData 子類(就像 FlattenCollection 在 Swift 中的工作方式),該子類會將多個 NSData 物件視作一個連續的 NSData。完成建立以後,我們就做好所有準備來解決這個問題啦。

通過檢視 NSData 所有原生方法,可以發現,需要實現的是 -count-bytes。實現 -count 並不難,我們可以把所有 NSData 物件的大小相加得到;但在實現 -bytes 時則會有個問題。 -bytes 需要返回一個指向一段連續緩衝區的指標,而目前我們並沒有這個指標。

在基礎庫中,提供了 NSInputStream 類用於處理不連續的資料。非常幸運,NSInputStream 同樣是一個類簇。我們可以建立一個子類,將多條流合併。在使用子類時,感覺上就像是一條流。通過使用 +inputStreamWithData:+inputStreamWithURL: 方法,可以輕易地建立一條輸入流,用來代表硬碟中的檔案和記憶體中的資料(比如 boundaries)。

通過閱讀最好的第三方網路庫原始碼,你會發現 AFNetworking 採用了這種方法。(Alamofire,Swift 版本的 AFNetworking,則採用了第一種方法,將資料全部載入到記憶體中,但如果資料量太大,就會寫到硬碟的一個檔案中。)

將所有部分拼接起來

你可以在 這裡 看看我的序列輸入流的實現(是用 Objective-C 實現的,以後我可能還會寫一個 Swift 版本的)。

通過 SKSerialInputStream 類,可以將流組合在一起。下面展示了字首和字尾屬性:

extension MultipartComponent { 
var prefixData: Data {
let string = """ \(self.boundary) Content-Disposition: form-data;
name="\(self.name);
filename="\(self.filename)" """ return string.data(using: .utf8)
} var postfixData: Data {
return "\r\n".data(using: .utf8)
}
}複製程式碼

將後設資料和檔案的 dataStream 組合在一起,得到一條輸入流:

extension MultipartComponent { 
var inputStream: NSInputStream {
let streams = [ NSInputStream(data: prefixData), self.fileDataStream, NSInputStream(data: postfixData), ] return SKSerialInputStream(inputStreams: streams)
}
}複製程式碼

建立好每一部分輸入流之後,就可以把所有流組合在一起,得到一條完整輸入流。此外,在請求結尾還需要新增一個 boundary:

extension RequestBuilder { 
var bodyInputStream: NSInputStream {
let stream = parts .map({
$0.inputStream
}) + [NSInputStream(data: "--\(self.boundary)--".data(using: .utf8))] return SKSerialInputStream(inputStreams: streams)
}
}複製程式碼

最後,將 bodyInputStream 賦值給 URL 請求的 httpBodyStream 屬性:

let urlRequest = URLRequest(url: url)urlRequest.httpBodyStream = requestBuilder.bodyInputStream;
複製程式碼

注意,httpBodyStreamhttpBody 兩個屬性是互斥的——兩個屬性不會同時 生效。設定 httpBodyStream 會使得 Data 版本 httpBody 失效,反之亦然。

流檔案上傳的關鍵是能夠將多條輸入流合併成一條流。SKSerialInputStream 類完成了整個工作。儘管說子類化 NSInputStream 有一些困難,可一旦解決這個問題,我們就離成功不遠啦。

子類化過程中需要注意的問題

子類化 NSInputStream 的過程不會太輕鬆,甚至可以說很困難。你必須實現 9 個方法。其中的 7 個方法,父類只有一些微不足道的預設實現。而在文件中只提到了 9 個方法中的 3 個,所以你還得實現 6 個 NSStreamNSInputStream 的父類)的方法,其中有 2 個是 run loop 方法,並允許空實現。在這 之前,你還需要額外 實現 3 個私有方法,不過現在不必實現了。此外,還需要定義 3 個只讀屬性:streamStatusstreamErrordelegate

在處理完上述子類化相關的細節後,接下來的挑戰是建立一個 NSInputStream 子類,其行為應該和 API 使用者所期望的保持一致。然而,這個類狀態的重度耦合是不容易被人發現的。

有一些狀態需要保證行為一致。舉個例子,hasBytesAvailable 是不同於其它狀態的,但還是存在細微的聯絡。在我最近發現的一個 bug 裡,hasBytesAvailable 屬性會返回 self.currentIndex != self.inputStreams.count,但是這會造成一個 bug,流會一直處於開啟的狀態,並最終造成請求超時。修復這個 bug 的辦法是改為返回 YES,但我一直沒有找到這個 bug 的根源所在。

另外一個狀態 streamStatus,存在許多可能的值,其中比較重要的兩個值是 NSStreamStatusOpenNSStreamStatusClosed

最後一個比較有意思的狀態是位元組數,從 read 方法中返回值。這個屬性除了會返回正整型數之外,還會返回 -1,-1 代表有錯誤產生,需要進一步檢查非空屬性 streamError 來獲取更多資訊。位元組數還可以返回 0,根據文件描述,這是標明流結尾的另外一種方式。

文件並不會告訴你哪些狀態的組合是有意義的。比如說流產生一個 streamError,但狀態卻是 NSStreamStatusClosed,而不是 NSStreamStatusError,在這種情況下是否會有問題?想要管理好所有的狀態非常難,不過到最後終究還是能解決的。

對於 SKSerialStream 類,是否可以在所有情況下都能正常工作,我還不是特別有信心。但看起來,SKSerialStream 通過使用 URLSession 能很好地支援上傳 multipart 資料。如果你在使用這份程式碼的時候發現任何問題,請務必聯絡我,我們可以一起不斷優化這個類。

本文由 SwiftGG 翻譯組翻譯,已經獲得作者翻譯授權,最新文章請訪問 swift.gg

來源:https://juejin.im/post/5c453ee6f265da61630257e1#comment

相關文章