Alamofire之form多表單上傳

使用 AFNetworking 進行文件上傳的使用方法大家想必已經(jīng)很熟悉. 那么作為其 Swift 版本, 我們當然也不能少.

先來個例子.

upload(multipartFormData: { (multipartFormData) in
    
    multipartFormData.append("lb".data(using: .utf8)!, withName: "username")
    multipartFormData.append("123456".data(using: .utf8)!, withName: "password")
    multipartFormData.append("wdms82jnds".data(using: .utf8)!, withName: "token")
    
}, to: "http://www.baidu.com") { (result) in
    print(result)
}

打開 Charles或者其他抓包工具, 運行項目查看我們本次請求.


其具體結(jié)構(gòu)很清晰

  • alamofire.bounday.91b32560f55a049f 分隔符
  • Content-Dispositon:form-data;name="name" (key)
  • /r/n
  • value

接下來分析下其內(nèi)部邏輯是如何處理的.
點擊進入 upload 具體實現(xiàn)方法, 中間過渡方法省略.

open func upload(
    multipartFormData: @escaping (MultipartFormData) -> Void,
    usingThreshold encodingMemoryThreshold: UInt64 = SessionManager.multipartFormDataEncodingMemoryThreshold,
    with urlRequest: URLRequestConvertible,
    queue: DispatchQueue? = nil,
    encodingCompletion: ((MultipartFormDataEncodingResult) -> Void)?)
{
    DispatchQueue.global(qos: .utility).async {
        let formData = MultipartFormData()
        multipartFormData(formData)

        var tempFileURL: URL?
        
        var urlRequestWithContentType = try urlRequest.asURLRequest()
        urlRequestWithContentType.setValue(formData.contentType, forHTTPHeaderField: "Content-Type")

        let isBackgroundSession = self.session.configuration.identifier != nil

        if formData.contentLength < encodingMemoryThreshold && !isBackgroundSession {
            let data = try formData.encode()

            let encodingResult = MultipartFormDataEncodingResult.success(
                request: self.upload(data, with: urlRequestWithContentType),
                streamingFromDisk: false,
                streamFileURL: nil
            )

            (queue ?? DispatchQueue.main).async { encodingCompletion?(encodingResult) }
        } else {
            let fileManager = FileManager.default
            let tempDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory())
            let directoryURL = tempDirectoryURL.appendingPathComponent("org.alamofire.manager/multipart.form.data")
            let fileName = UUID().uuidString
            let fileURL = directoryURL.appendingPathComponent(fileName)

            tempFileURL = fileURL

            var directoryError: Error?

            // Create directory inside serial queue to ensure two threads don't do this in parallel
            self.queue.sync {
               try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil)
            }

            if let directoryError = directoryError { throw directoryError }

            try formData.writeEncodedData(to: fileURL)

            let upload = self.upload(fileURL, with: urlRequestWithContentType)

            //...
            (queue ?? DispatchQueue.main).async {
                let encodingResult = MultipartFormDataEncodingResult.success(
                    request: upload,
                    streamingFromDisk: true,
                    streamFileURL: fileURL
                )

                encodingCompletion?(encodingResult)
            }
        }
        
    }
}

提示:

  • (中間有一些 try catch 異常處理因篇幅原因省略掉了. 不影響)
  • 先看 if else 邏輯控制語句. 搞清楚后把其種之一折起來, 有助于在查看源碼時理清思路.

該方法具體操作如下:

  • 1??: 在全局隊列開啟異步執(zhí)行下面任務(wù).
  • 2??: 初始化一個 MultipartFormData 對象并調(diào)用閉包參數(shù)孩等。調(diào)用用戶在外界閉包中所做的處理, 也就是收取用戶拼接的數(shù)據(jù)
let formData = MultipartFormData()
multipartFormData(formData)

執(zhí)行用戶傳遞的 multipartFormData 閉包, 我們在這個閉包中調(diào)用了 MultipartFormDataappend 方法. 存到了這個臨時變量里.

  • 3??: 設(shè)置請求頭格式
  • 4??: 根據(jù)數(shù)據(jù)長度來區(qū)分處理, 長度為該方法的 usingThreshold 參數(shù). 用戶不傳時為默認閾值.
public static let multipartFormDataEncodingMemoryThreshold: UInt64 = 10_000_000

接下來:

  • 當滿足長度閾值和 SessionManager 的會話環(huán)境為默認時, 進行 formData 編碼, 上傳.
  • 否則,則將數(shù)據(jù)寫入文件, 上傳.

那么我們分別探討.

Alamofire form表單數(shù)據(jù)編碼

由于 upload 方法中, 調(diào)用了用戶所添加的參數(shù), 也就是調(diào)用 multipartFormData.append , 那我們點進去 append 方法

public func append(_ data: Data, withName name: String) {
    let headers = contentHeaders(withName: name)
    let stream = InputStream(data: data)
    let length = UInt64(data.count)

    append(stream, withLength: length, headers: headers)
}

先處理頭 進入

private func contentHeaders(withName name: String, fileName: String? = nil, mimeType: String? = nil) -> [String: String] {
    var disposition = "form-data; name=\"\(name)\""
    if let fileName = fileName { disposition += "; filename=\"\(fileName)\"" }

    var headers = ["Content-Disposition": disposition]
    if let mimeType = mimeType { headers["Content-Type"] = mimeType }

    return headers
}

對照我們抓包結(jié)果來看, 其實就是
Content-Disposition: form-data; name=\"\(name)\"
然后進行 append

append(stream, withLength: length, headers: headers)

這里就是對 bodyParts 這個數(shù)組中添加 BodyPart 對象進去. 那么我們回到 判斷條件 滿足閾值判定滿足時, 首先調(diào)用 encode 方法.

let data = try formData.encode()

直接點擊進入 encode 方法

public func encode() throws -> Data {
    if let bodyPartError = bodyPartError {
        throw bodyPartError
    }

    var encoded = Data()

    bodyParts.first?.hasInitialBoundary = true
    bodyParts.last?.hasFinalBoundary = true

    for bodyPart in bodyParts {
        let encodedData = try encode(bodyPart)
        encoded.append(encodedData)
    }

    return encoded
}

對頭尾進行標識. 然后遍歷調(diào)用 encode .

private func encode(_ bodyPart: BodyPart) throws -> Data {
    var encoded = Data()

    let initialData = bodyPart.hasInitialBoundary ? initialBoundaryData() : encapsulatedBoundaryData()
    encoded.append(initialData)

    let headerData = encodeHeaders(for: bodyPart)
    encoded.append(headerData)

    let bodyStreamData = try encodeBodyStream(for: bodyPart)
    encoded.append(bodyStreamData)

    if bodyPart.hasFinalBoundary {
        encoded.append(finalBoundaryData())
    }

    return encoded
}

針對數(shù)組中的頭尾和中間三種情況,分別處理, 返回.

  • 是首元素艾君,則調(diào)用 initialBoundaryData() 函數(shù)蓝角。
  • 是中間元素亭枷,則調(diào)用 encapsulatedBoundaryData() 函數(shù)。
  • 是尾元素捻勉,則調(diào)用 finalBoundaryData() 函數(shù)权她。

通過type區(qū)分最終調(diào)用如下函數(shù):

static func boundaryData(forBoundaryType boundaryType: BoundaryType, boundary: String) -> Data {
    let boundaryText: String

    switch boundaryType {
    case .initial:
        boundaryText = "--\(boundary)\(EncodingCharacters.crlf)"
    case .encapsulated:
        boundaryText = "\(EncodingCharacters.crlf)--\(boundary)\(EncodingCharacters.crlf)"
    case .final:
        boundaryText = "\(EncodingCharacters.crlf)--\(boundary)--\(EncodingCharacters.crlf)"
    }

    return boundaryText.data(using: String.Encoding.utf8, allowLossyConversion: false)!
}
  • 其中 boundary 為分隔符. 也就是
static func randomBoundary() -> String {
    return String(format: "alamofire.boundary.%08x%08x", arc4random(), arc4random())
}
  • EncodingCharacters.crlf 為換行
struct EncodingCharacters {
    static let crlf = "\r\n"
}

還有值得一提的一點是在循環(huán)數(shù)組進行 encode 方法中還調(diào)用了一個
encodeBodyStream 方法

private func encodeBodyStream(for bodyPart: BodyPart) throws -> Data {
    let inputStream = bodyPart.bodyStream
    inputStream.open()
    defer { inputStream.close() }

    var encoded = Data()

    while inputStream.hasBytesAvailable {
        var buffer = [UInt8](repeating: 0, count: streamBufferSize)
        let bytesRead = inputStream.read(&buffer, maxLength: streamBufferSize)

        if let error = inputStream.streamError {
            throw AFError.multipartEncodingFailed(reason: .inputStreamReadFailed(error: error))
        }

        if bytesRead > 0 {
            encoded.append(buffer, count: bytesRead)
        } else {
            break
        }
    }

    return encoded
}

使用數(shù)據(jù)流stream 的方式讀取數(shù)據(jù), 利用其優(yōu)化措施, 防止內(nèi)存讀取存儲暴增問題.

Alamofire 表單上傳 - 寫入文件方式

也就是我們剛剛 if else 時, else 的情況
upload 方法中 else 部分:

let fileManager = FileManager.default
let tempDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory())
let directoryURL = tempDirectoryURL.appendingPathComponent("org.alamofire.manager/multipart.form.data")
let fileName = UUID().uuidString
let fileURL = directoryURL.appendingPathComponent(fileName)

tempFileURL = fileURL

var directoryError: Error?

// Create directory inside serial queue to ensure two threads don't do this in parallel
self.queue.sync {
    do {
        try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil)
    } catch {
        directoryError = error
    }
}

if let directoryError = directoryError { throw directoryError }

try formData.writeEncodedData(to: fileURL)

let upload = self.upload(fileURL, with: urlRequestWithContentType)

前面的文件操作就不多贅述, 直接來到

try formData.writeEncodedData(to: fileURL)

點擊進入方法

public func writeEncodedData(to fileURL: URL) throws {
    if let bodyPartError = bodyPartError {
        throw bodyPartError
    }

    if FileManager.default.fileExists(atPath: fileURL.path) {
        throw AFError.multipartEncodingFailed(reason: .outputStreamFileAlreadyExists(at: fileURL))
    } else if !fileURL.isFileURL {
        throw AFError.multipartEncodingFailed(reason: .outputStreamURLInvalid(url: fileURL))
    }

    guard let outputStream = OutputStream(url: fileURL, append: false) else {
        throw AFError.multipartEncodingFailed(reason: .outputStreamCreationFailed(for: fileURL))
    }

    outputStream.open()
    defer { outputStream.close() }

    self.bodyParts.first?.hasInitialBoundary = true
    self.bodyParts.last?.hasFinalBoundary = true

    for bodyPart in self.bodyParts {
        try write(bodyPart, to: outputStream)
    }
}

老樣子 前面文件名重復(fù), 文件路徑無效等異常處理直接過.
我們看到同樣是開流的方式. outputStream.open / .close. 然后標識頭尾. 最后循環(huán)調(diào)用 .write.

private func write(_ bodyPart: BodyPart, to outputStream: OutputStream) throws {
    try writeInitialBoundaryData(for: bodyPart, to: outputStream)
    try writeHeaderData(for: bodyPart, to: outputStream)
    try writeBodyStream(for: bodyPart, to: outputStream)
    try writeFinalBoundaryData(for: bodyPart, to: outputStream)
}

然后分別調(diào)用四個過渡方法, 最終來到

private func write(_ buffer: inout [UInt8], to outputStream: OutputStream) throws {
    var bytesToWrite = buffer.count

    while bytesToWrite > 0, outputStream.hasSpaceAvailable {
        let bytesWritten = outputStream.write(buffer, maxLength: bytesToWrite)

        if let error = outputStream.streamError {
            throw AFError.multipartEncodingFailed(reason: .outputStreamWriteFailed(error: error))
        }

        bytesToWrite -= bytesWritten

        if bytesToWrite > 0 {
            buffer = Array(buffer[bytesWritten..<buffer.count])
        }
    }
}

循環(huán)寫入到流中, 最后使用文件 URL 上傳.

private func upload(_ uploadable: UploadRequest.Uploadable) -> UploadRequest {
    do {
        let task = try uploadable.task(session: session, adapter: adapter, queue: queue)
        let upload = UploadRequest(session: session, requestTask: .upload(uploadable, task))

        if case let .stream(inputStream, _) = uploadable {
            upload.delegate.taskNeedNewBodyStream = { _, _ in inputStream }
        }

        delegate[task] = upload

        if startRequestsImmediately { upload.resume() }

        return upload
    } catch {
        return upload(uploadable, failedWith: error)
    }
}

這里就回到了我們熟悉的 request 的流程 , 啟動 -> 響應(yīng) -> 回調(diào). 不熟悉的可以去閱讀一下
Alamofire之Request(二)和隊列執(zhí)行順序分析
Alamofire之Request(一)
這兩篇文章. 本文就不重復(fù)闡述了.
.

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末虹茶,一起剝皮案震驚了整個濱河市逝薪,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌蝴罪,老刑警劉巖董济,帶你破解...
    沈念sama閱讀 219,188評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異洲炊,居然都是意外死亡感局,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,464評論 3 395
  • 文/潘曉璐 我一進店門暂衡,熙熙樓的掌柜王于貴愁眉苦臉地迎上來询微,“玉大人,你說我怎么就攤上這事狂巢〕琶” “怎么了?”我有些...
    開封第一講書人閱讀 165,562評論 0 356
  • 文/不壞的土叔 我叫張陵唧领,是天一觀的道長藻雌。 經(jīng)常有香客問我,道長斩个,這世上最難降的妖魔是什么胯杭? 我笑而不...
    開封第一講書人閱讀 58,893評論 1 295
  • 正文 為了忘掉前任,我火速辦了婚禮受啥,結(jié)果婚禮上做个,老公的妹妹穿的比我還像新娘。我一直安慰自己滚局,他們只是感情好居暖,可當我...
    茶點故事閱讀 67,917評論 6 392
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著藤肢,像睡著了一般太闺。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上嘁圈,一...
    開封第一講書人閱讀 51,708評論 1 305
  • 那天省骂,我揣著相機與錄音,去河邊找鬼最住。 笑死冀宴,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的温学。 我是一名探鬼主播略贮,決...
    沈念sama閱讀 40,430評論 3 420
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了逃延?” 一聲冷哼從身側(cè)響起览妖,我...
    開封第一講書人閱讀 39,342評論 0 276
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎揽祥,沒想到半個月后讽膏,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,801評論 1 317
  • 正文 獨居荒郊野嶺守林人離奇死亡拄丰,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,976評論 3 337
  • 正文 我和宋清朗相戀三年府树,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片料按。...
    茶點故事閱讀 40,115評論 1 351
  • 序言:一個原本活蹦亂跳的男人離奇死亡奄侠,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出载矿,到底是詐尸還是另有隱情垄潮,我是刑警寧澤,帶...
    沈念sama閱讀 35,804評論 5 346
  • 正文 年R本政府宣布闷盔,位于F島的核電站弯洗,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏逢勾。R本人自食惡果不足惜牡整,卻給世界環(huán)境...
    茶點故事閱讀 41,458評論 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望溺拱。 院中可真熱鬧逃贝,春花似錦、人聲如沸盟迟。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,008評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽攒菠。三九已至,卻和暖如春歉闰,著一層夾襖步出監(jiān)牢的瞬間辖众,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,135評論 1 272
  • 我被黑心中介騙來泰國打工和敬, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留凹炸,地道東北人。 一個月前我還...
    沈念sama閱讀 48,365評論 3 373
  • 正文 我出身青樓昼弟,卻偏偏與公主長得像啤它,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 45,055評論 2 355

推薦閱讀更多精彩內(nèi)容