Alamofire源码学习(十一): MultipartFormData与MultipartUpload:多表单数据上传

1,123 阅读11分钟

往期导航:

Alamofire源码学习目录合集

简介

需要上传多表单数据时,需要将data封装在body中,并且使用分隔符分隔开,Alamofire封装了MultipartFormData类来操作多表单data的封装检测,拼接操作符等操作。
功能:

  • 可以接受:Data类型,Stream, fileurl类型的data,跟name,mimetype一起保存
  • 数据最终都会封装成InputStream保存,小文件可直接传入Data类型,大文件需要使用Stream或者fileurl类型,否则会超出内存
  • 可以之定义数据分隔符,不指定则使用默认分隔符
  • 保存的数据类型为bodyParts数组,最终编码入URLRequest对象的操作是在MutipartUpload对象中进行。

MutipartUpload类的作用为把封装处理好的MultipartFormData对象中的数据编码进URLRequest的body中,在编码时会进行判断,如果data的大小超过了限制,则会先存入临时文件,然后把文件url封装为UploadRequest.Uploadable回调给UploadRequest初始化使用。

MultipartFormData:

首先封装了几个辅助类型来处理表单数据:

1.EncodingCharacters:

封装回车换行字符串:

    enum EncodingCharacters {
        static let crlf = "\r\n"
    }
2.BoundaryGenerator:

封装多表单数据的分隔符,该分隔符需要存放在body头中,类型分为:开头,中间,结尾三种,不同类型前后带有的换行符不同。
默认会生成随机的分隔符,使用时也可以自己制定分隔符字符串。
输出格式为Data,在对表单数据编码时,会按顺序插入data间隔中。

    enum BoundaryGenerator {
        enum BoundaryType {
            case initial//起始: --分隔符\r\n
            case encapsulated//中间: \r\n--分隔符\r\n
            case final//结束: \r\n--分隔符--\r\n
        }
        
        /// 随机分隔符
        /// 随机生成两个32位无符号整数,然后转成十六进制展示,使用0补足8个字符,加上前缀
        static func randomBoundary() -> String {
            let first = UInt32.random(in: UInt32.min...UInt32.max)
            let second = UInt32.random(in: UInt32.min...UInt32.max)

            return String(format: "alamofire.boundary.%08x%08x", first, second)
        }
        
        //生成分隔符Data, 拼接数据用
        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 Data(boundaryText.utf8)
        }
    }
3.BodyPart类:

封装了多表单数据中每一个表单数据对象,持有数据的头数据,body的数据长度,使用hasInitialBoundaryhasFinalBoundary来记录数据前后分隔符的类型。

    class BodyPart {
        // 每个body的头
        let headers: HTTPHeaders
        // body数据stream
        let bodyStream: InputStream
        // 数据长度
        let bodyContentLength: UInt64
        
        /// 使用下面两个变量来控制数据前后分隔符的类型,在最终编码时,把BodyParts数组的头尾对应开关打开, 两个都为false代表中间表单数据
        // 是否有开始分隔符
        var hasInitialBoundary = false
        // 是否有结尾分隔符
        var hasFinalBoundary = false
        

        init(headers: HTTPHeaders, bodyStream: InputStream, bodyContentLength: UInt64) {
            self.headers = headers
            self.bodyStream = bodyStream
            self.bodyContentLength = bodyContentLength
        }
    }

私有属性与初始化:

    /// 编码数据时,最大的内存容量,默认10MB,超过则把数据编码到磁盘临时文件中
    public static let encodingMemoryThreshold: UInt64 = 10_000_000

    /// 多表单数据的头部Content-Type, 定义了multipart/form-data与分隔符
    open lazy var contentType: String = "multipart/form-data; boundary=\(self.boundary)"

    /// 所有表单数据部分的data大小, 不包括分隔符
    public var contentLength: UInt64 { bodyParts.reduce(0) { $0 + $1.bodyContentLength } }

    /// 用来分割表单数据的分隔符
    public let boundary: String

    /// 添加fileurl类型的数据时用来操作文件使用, 以及将data写入临时文件时用
    let fileManager: FileManager

    /// 保存多表单数据的数组
    private var bodyParts: [BodyPart]
    /// 追加表单数据时出现的错误, 会抛给上层
    private var bodyPartError: AFError?
    /// 读写IOStream时的buffer大小,默认1024Byte
    private let streamBufferSize: Int
    
    public init(fileManager: FileManager = .default, boundary: String? = nil) {
        self.fileManager = fileManager
        self.boundary = boundary ?? BoundaryGenerator.randomBoundary()
        bodyParts = []

        //
        // The optimal read/write buffer size in bytes for input and output streams is 1024 (1KB). For more
        // information, please refer to the following article:
        //   - https://developer.apple.com/library/mac/documentation/Cocoa/Conceptual/Streams/Articles/ReadingInputStreams.html
        //
        streamBufferSize = 1024
    }

追加表单数据相关

提供了5个方法来追加3中不同的表单数据类型:

  1. Data+name(直接从内存中读取,只能用于小文件,可指定文件名字与mime类型)
  2. fileurl+name(未指明文件名字,与mime类型,根据fileurl最后的文件名与扩展名来判断,然后调用方法3)
  3. fileurl+name+文件名+mime类型(等同于2)
  4. InputStream+data长度+文件名+mime类型(会先把mime类型封装为HTTPHeaders然后调用方法5)
  5. InputStream+data长度+HTTPHeaders

上面5个方法中,前4个最终都会调用到第5个,Data与fileurl类型都会转换成InputStream类型,然后跟表单头一起封装成BodyPart类型保存。

1.Data+name(可选:+fileName+mime类型)
    /// 最终编码的表单数据格式:
    /// - `前分隔符(若是第一个数据块, 就没有前分隔符)`
    /// - `Content-Disposition: form-data; name=#{name}; filename=#{filename}` (表单头)
    /// - `Content-Type: #{mimeType}` (表单头)
    /// - `Data`
    /// - `后分隔符(若是最后一个数据块, 后分隔符是终结分隔符)`
    public func append(_ data: Data, withName name: String, fileName: String? = nil, mimeType: String? = nil) {
        let headers = contentHeaders(withName: name, fileName: fileName, mimeType: mimeType)
        let stream = InputStream(data: data)
        let length = UInt64(data.count)

        append(stream, withLength: length, headers: headers)
    }
2.fileurl+name
    /// 最终编码的表单数据格式:
    /// - `前分隔符(若是第一个数据块, 就没有前分隔符)`
    /// - `Content-Disposition: form-data; name=#{name}; filename=#{根据fileurl获取到的文件名}`
    /// - `Content-Type: #{根据fileurl获取到的mime类型}`
    /// - fileurl读取出来的Data
    /// - `后分隔符(若是最后一个数据块, 后分隔符是终结分隔符)`
    /// 文件名与mime类型是根据fileurl最后path中的文件名与扩展名获取
    public func append(_ fileURL: URL, withName name: String) {
        // 获取文件名与mime类型, 若读取不到就记录错误并return
        let fileName = fileURL.lastPathComponent
        let pathExtension = fileURL.pathExtension

        if !fileName.isEmpty && !pathExtension.isEmpty {
            // 使用辅助函数获取mime类型字符串
            let mime = mimeType(forPathExtension: pathExtension)
            // 调用下面方法3继续处理
            append(fileURL, withName: name, fileName: fileName, mimeType: mime)
        } else {
            setBodyPartError(withReason: .bodyPartFilenameInvalid(in: fileURL))
        }
    }
3.fileurl+name+文件名+mime类型
  • 可以用来上传大文件,因为最后是使用InputStream来读取暂存文件。
  • 对fileurl进行了各种判断,任何一部出错都会抛出对应的错误
    /// 最终编码的表单数据格式:
    /// - `前分隔符(若是第一个数据块, 就没有前分隔符)`
    /// - `Content-Disposition: form-data; name=#{name}; filename=#{filename}`
    /// - `Content-Type: #{mimeType}`
    /// - fileurl读取出来的Data
    /// - `后分隔符(若是最后一个数据块, 后分隔符是终结分隔符)`
    public func append(_ fileURL: URL, withName name: String, fileName: String, mimeType: String) {
        //封装表单头
        let headers = contentHeaders(withName: name, fileName: fileName, mimeType: mimeType)
        // 1.检测url是否合法, 不合法记录错误并return
        guard fileURL.isFileURL else {
            setBodyPartError(withReason: .bodyPartURLInvalid(url: fileURL))
            return
        }
        // 2.检测文件url是否可以访问
        do {
            let isReachable = try fileURL.checkPromisedItemIsReachable()//这个方法可以快速检测文件是否可以访问, 当不可访问并有错误时, 会记录错误并return
            guard isReachable else {
                setBodyPartError(withReason: .bodyPartFileNotReachable(at: fileURL))
                return
            }
        } catch {
            // catch异常并记录错误并return
            setBodyPartError(withReason: .bodyPartFileNotReachableWithError(atURL: fileURL, error: error))
            return
        }

        // 3.检测url是否是目录, 是目录直接记录错误并return
        var isDirectory: ObjCBool = false
        let path = fileURL.path

        guard fileManager.fileExists(atPath: path, isDirectory: &isDirectory) && !isDirectory.boolValue else {
            setBodyPartError(withReason: .bodyPartFileIsDirectory(at: fileURL))
            return
        }

        // 4.检测是否能获取到文件大小, 无法获取文件大小时记录错误并return
        let bodyContentLength: UInt64

        do {
            guard let fileSize = try fileManager.attributesOfItem(atPath: path)[.size] as? NSNumber else {
                setBodyPartError(withReason: .bodyPartFileSizeNotAvailable(at: fileURL))
                return
            }

            bodyContentLength = fileSize.uint64Value
        } catch {
            setBodyPartError(withReason: .bodyPartFileSizeQueryFailedWithError(forURL: fileURL, error: error))
            return
        }

        // 5.检测能否创建InputStream, 无法创建InputStream时记录错误并return

        guard let stream = InputStream(url: fileURL) else {
            setBodyPartError(withReason: .bodyPartInputStreamCreationFailed(for: fileURL))
            return
        }
        // 6.调用下面的方法5继续处理
        append(stream, withLength: bodyContentLength, headers: headers)
    }
4.InputStream+data长度+name+文件名字+mime类型
    /// 最终编码的表单数据格式:
    /// - `前分隔符(若是第一个数据块, 就没有前分隔符)`
    /// - `Content-Disposition: form-data; name=#{name}; filename=#{filename}`
    /// - `Content-Type: #{mimeType}`
    /// - fileurl读取出来的Data
    /// - `后分隔符(若是最后一个数据块, 后分隔符是终结分隔符)`
    public func append(_ stream: InputStream,
                       withLength length: UInt64,
                       name: String,
                       fileName: String,
                       mimeType: String) {
        //封装下表单头
        let headers = contentHeaders(withName: name, fileName: fileName, mimeType: mimeType)
        //使用下面的方法5继续
        append(stream, withLength: length, headers: headers)
    }
5.InputStream+data长度+表单头
    /// 最终编码的表单数据格式:
    /// - `前分隔符(若是第一个数据块, 就没有前分隔符)`
    /// - `表单头`
    /// - `表单数据`
    /// - `后分隔符(若是最后一个数据块, 后分隔符是终结分隔符)`
    public func append(_ stream: InputStream, withLength length: UInt64, headers: HTTPHeaders) {
        //封装成BodyPart对象
        let bodyPart = BodyPart(headers: headers, bodyStream: stream, bodyContentLength: length)
        //存入数组
        bodyParts.append(bodyPart)
    }

编码BodyPart对象

Alamofire提供了两个公开方法来将封装的BodyPart对象编码为Data类型,用来塞入URLRequest的bodydata使用

  1. 编码为Data(所有数据都在内存中,注意文件太大会爆内存)
  2. 使用IOStream来保存到临时文件中(因为上面封装完的BodyPart保存的数据都是以InputStream类型,因此只需要创建一个OutputStream对象来写文件就行。
  • 另外提供了N个私有方法来辅助写入
1.写到内存:

公开方法:

    /// 内存编码, 编码为Data类型, 注意大文件容易爆内存, 大文件使用下面的编码到fileUrl方法.
    public func encode() throws -> Data {
        // 检测是否有保存错误
        if let bodyPartError = bodyPartError {
            // 有保存错误的话, 直接抛出异常
            throw bodyPartError
        }
        
        // 准备追加数据
        var encoded = Data()
        
        // 设置头尾分隔符
        bodyParts.first?.hasInitialBoundary = true
        bodyParts.last?.hasFinalBoundary = true

        // 遍历编码data, 然后追加
        for bodyPart in bodyParts {
            let encodedData = try encode(bodyPart)
            encoded.append(encodedData)
        }

        return encoded
    }

私有方法:用来编码单个BodyPart数据

    // 编码单个BodyPart数据
    private func encode(_ bodyPart: BodyPart) throws -> Data {
        // 准备追加用的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
    }

    // 编码表单头
    private func encodeHeaders(for bodyPart: BodyPart) -> Data {
        //格式为: `表单头1名字: 表单头1值\r\n表单头2名字: 表单头2值\r\n...\r\n`
        let headerText = bodyPart.headers.map { "\($0.name): \($0.value)\(EncodingCharacters.crlf)" }
            .joined()
            + EncodingCharacters.crlf
        // utf8编码
        return Data(headerText.utf8)
    }

    // 编码表单数据
    private func encodeBodyStream(for bodyPart: BodyPart) throws -> Data {
        let inputStream = bodyPart.bodyStream
        // 打开stream
        inputStream.open()
        // 方法结束要关闭stream
        defer { inputStream.close() }

        var encoded = Data()

        // 直接循环读取
        while inputStream.hasBytesAvailable {
            // buffer,长度为1024Byte
            var buffer = [UInt8](repeating: 0, count: streamBufferSize)
            // 一次读取一个1024Byte的数据
            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
    }
2.写到文件:

使用IOStream往文件写数据,适合大文件处理 公开方法:

    /// 使用IOStream往文件写数据, 适合处理大文件
    public func writeEncodedData(to fileURL: URL) throws {
        if let bodyPartError = bodyPartError {
            // 1.有错误直接抛出
            throw bodyPartError
        }
        
        if fileManager.fileExists(atPath: fileURL.path) {
            // 2.文件已存在抛出错误
            throw AFError.multipartEncodingFailed(reason: .outputStreamFileAlreadyExists(at: fileURL))
        } else if !fileURL.isFileURL {
            // 3.url不是文件url抛出错误
            throw AFError.multipartEncodingFailed(reason: .outputStreamURLInvalid(url: fileURL))
        }

        guard let outputStream = OutputStream(url: fileURL, append: false) else {
            // 4.创建OutputStream失败抛出错误
            throw AFError.multipartEncodingFailed(reason: .outputStreamCreationFailed(for: fileURL))
        }
        // 打开OutputStream
        outputStream.open()
        // 方法结束关闭OutputStream
        defer { outputStream.close() }
        
        // 设置头尾分隔符标志
        bodyParts.first?.hasInitialBoundary = true
        bodyParts.last?.hasFinalBoundary = true
        //遍历使用私有方法写数据
        for bodyPart in bodyParts {
            try write(bodyPart, to: outputStream)
        }
    }

中间私有方法(只封装处理下,尚未往OStream里写数据,真正写数据的只有两个方法):

    /// 编码单个BodyPart到OStream, 会派发个四个子方法
    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)
    }
    /// 编码数据前部分隔符
    private func writeInitialBoundaryData(for bodyPart: BodyPart, to outputStream: OutputStream) throws {
        // 起始分隔符类型(可能为起始分隔符, 也可能为中间分隔符)编码成Data
        let initialData = bodyPart.hasInitialBoundary ? initialBoundaryData() : encapsulatedBoundaryData()
        // 继续派发
        return try write(initialData, to: outputStream)
    }
    /// 编码表单头
    private func writeHeaderData(for bodyPart: BodyPart, to outputStream: OutputStream) throws {
        // 把表单头编码成Data
        let headerData = encodeHeaders(for: bodyPart)
        // 继续派发
        return try write(headerData, to: outputStream)
    }
    /// 编码表单数据
    private func writeBodyStream(for bodyPart: BodyPart, to outputStream: OutputStream) throws {
        let inputStream = bodyPart.bodyStream
        // 打开IStream
        inputStream.open()
        // 方法结束要关闭IStream
        defer { inputStream.close() }
        // 循环读取Bytes
        while inputStream.hasBytesAvailable {
            // 缓存, 大小为1024Byte
            var buffer = [UInt8](repeating: 0, count: streamBufferSize)
            // 一次读1024Byte
            let bytesRead = inputStream.read(&buffer, maxLength: streamBufferSize)

            if let streamError = inputStream.streamError {
                // 有错误就抛出
                throw AFError.multipartEncodingFailed(reason: .inputStreamReadFailed(error: streamError))
            }

            if bytesRead > 0 {
                // 若读出来的数据小于缓存, 取前面有效数据
                // 上面转成Data不用这样处理是因为不需要往文件里写
                if buffer.count != bytesRead {
                    buffer = Array(buffer[0..<bytesRead])
                }
                // 继续派发(把字节数据数组写入OStream)
                try write(&buffer, to: outputStream)
            } else {
                break
            }
        }
    }
    // 编码最终分隔符
    private func writeFinalBoundaryData(for bodyPart: BodyPart, to outputStream: OutputStream) throws {
        // 只有最后一个表单数据才编码最终分隔符
        if bodyPart.hasFinalBoundary {
            //编码成Data, 然后继续派发
            return try write(finalBoundaryData(), to: outputStream)
        }
    }

最终往OStream中写数据的两个方法(最底层社畜(ಥ▽ಥ)o)

    // 以Data格式写入OStream
    private func write(_ data: Data, to outputStream: OutputStream) throws {
        //拷贝成字节数组
        var buffer = [UInt8](repeating: 0, count: data.count)
        data.copyBytes(to: &buffer, count: data.count)
        // 继续派发
        return try write(&buffer, to: outputStream)
    }
    // 以字节数组格式写入OStream
    private func write(_ buffer: inout [UInt8], to outputStream: OutputStream) throws {
        var bytesToWrite = buffer.count
        // 循环往OStream中写数据
        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
            // buffer除去写入的数据
            if bytesToWrite > 0 {
                buffer = Array(buffer[bytesWritten..<buffer.count])
            }
        }
    }

辅助函数:

辅助处理操作,包括:

  • 根据文件扩展名获取mime类型字符串
  • 把表单name字段,文件名,mime类型封装成HTTPHeaders类型
  • 把三种类型的分隔符编码为Data格式
  • 保存追加表单数据时出现的错误,只会保存第一个出现的错误

MultipartUpload内部类

  • Alamofire内部类,用来快速发送上传请求使用
  • 用来把封装好的MultipartFormData编码成Data或者编码进临时文件,然后与关联的URLRequest对象封装成一个元组返回
  • 实现了UploadConvertible用来创建UploadRequest

属性与初始化

因为是内部类,所有全部属性都是intenal的,模块外部无法访问

    // 懒加载属性result, 首次读取时会调用build方法编码MultipartFormData数据
    lazy var result = Result { try build() }
    // 是否是后台任务, 如果是, 就会把表单数据编码到临时文件中
    let isInBackgroundSession: Bool
    // 表单数据
    let multipartFormData: MultipartFormData
    // 最大内存开销, 表单数据大于该值就会被编码到临时文件中
    let encodingMemoryThreshold: UInt64
    // 关联的URLRequestConvertible协议对象
    let request: URLRequestConvertible
    // 操作临时文件
    let fileManager: FileManager

    init(isInBackgroundSession: Bool,
         encodingMemoryThreshold: UInt64,
         request: URLRequestConvertible,
         multipartFormData: MultipartFormData) {
        self.isInBackgroundSession = isInBackgroundSession
        self.encodingMemoryThreshold = encodingMemoryThreshold
        self.request = request
        fileManager = multipartFormData.fileManager
        self.multipartFormData = multipartFormData
    }

核心方法:build 编码数据

    //编码数据, 并返回创建的URLRequest与UploadRequest.Uploadable关联的元组
    func build() throws -> (request: URLRequest, uploadable: UploadRequest.Uploadable) {
        // 创建URLRequest
        var urlRequest = try request.asURLRequest()
        // 设置请求头的Content-Type字段的, 值为:`multipart/form-data; boundary={表单分隔符}`
        urlRequest.setValue(multipartFormData.contentType, forHTTPHeaderField: "Content-Type")
        // 编码后的Uploadable
        let uploadable: UploadRequest.Uploadable
        if multipartFormData.contentLength < encodingMemoryThreshold && !isInBackgroundSession {
            // 表单数据小于设置的内存开销, 且不能为后台Session就直接编码成Data类型
            let data = try multipartFormData.encode()

            uploadable = .data(data)
        } else {
            // 系统缓存目录
            let tempDirectoryURL = fileManager.temporaryDirectory
            // 保存临时表单文件的目录
            let directoryURL = tempDirectoryURL.appendingPathComponent("org.alamofire.manager/multipart.form.data")
            // 临时文件名
            let fileName = UUID().uuidString
            // 临时文件url
            let fileURL = directoryURL.appendingPathComponent(fileName)

            // 创建临时表单文件目录
            try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil)

            do {
                // 把表单数据编码到临时文件
                try multipartFormData.writeEncodedData(to: fileURL)
            } catch {
                // 若编码失败, 删除临时文件, 并抛出异常
                try? fileManager.removeItem(at: fileURL)
                throw error
            }
            // 返回的UploadRequest.Uploadable, 并设置需要完成后删除临时文件
            uploadable = .file(fileURL, shouldRemove: true)
        }
        // 返回创建的URLRequest与UploadRequest.Uploadable关联的元组
        return (request: urlRequest, uploadable: uploadable)
    }

扩展实现UploadConvertible协议用来创建UploadRequest

extension MultipartUpload: UploadConvertible {
    func asURLRequest() throws -> URLRequest {
        try result.get().request
    }

    func createUploadable() throws -> UploadRequest.Uploadable {
        try result.get().uploadable
    }
}

以上纯属个人理解,难免有误,如发现有错误的地方,欢迎评论指出,将第一时间修改,非常感谢~