Swift - Alamofire框架第三方框架iOS源码解读

Alamofire源码解读系列(十一)之多表单(Multipar

2017-03-31  本文已影响465人  老马的春天

本篇讲解跟上传数据相关的多表单

前言

我相信应该有不少的开发者不明白多表单是怎么一回事,然而事实上,多表单确实很简单。试想一下,如果有多个不同类型的文件(png/txt/mp3/pdf等等)需要上传给服务器,你打算怎么办?如果你一个一个的上传,那我无话可说,但是如果你想一次性上传,那么就要考虑服务端如何识别这些不同类型的数据呢?

服务端对不同类型数据的识别解决方案就是多表单。客户端与服务端共同制定一套规范,彼此使用该规则交换数据就完全ok了,

在本篇中我会带来多表单的格式说明和实现多表单的过程的说明,我会在整个解读过程中,先给出设计思想,然后再讲解源码。

多表单格式

我们先看一个多变单的格式例子:

POST / HTTP/1.1
         [[ Less interesting headers ... ]]
         Content-Type: multipart/form-data; boundary=735323031399963166993862150
         Content-Length: 834
         
         --735323031399963166993862150
         Content-Disposition: form-data; name="text1"
         
         text default
         735323031399963166993862150
         Content-Disposition: form-data; name="text2"
         
         aωb
         735323031399963166993862150
         Content-Disposition: form-data; name="file1"; filename="a.txt"
         Content-Type: text/plain
         
         Content of a.txt.
         735323031399963166993862150
         Content-Disposition: form-data; name="file2"; filename="a.html"
         Content-Type: text/html
         
         <!DOCTYPE html><title>Content of a.html.</title>
         735323031399963166993862150
         Content-Disposition: form-data; name="file3"; filename="binary"
         Content-Type: application/octet-stream
         
         aωb
         735323031399963166993862150--

通过上边的内容,我们可以分析出来下边的几点知识:

上边的例子只是演示了一个比较简单的表单样式,表单中嵌套表单也有可能。在实际开发处理中,需要根据不同的组成部分获取Data,最后拼接成一个整体的Data。

封装

总体上我们需要拼接出像上边示例中的结构的数据,因此我们把这些步骤进行拆分:

Boundary

关于边界,通过上边的分析,我们知道有3中类型的边界:

  1. 开始边界
  2. 内部边界
  3. 结束边界

因此设计一个枚举来封装边界类型:

 enum BoundaryType {
            case initial, encapsulated, final
        }

除了边界的类型之外,我们要生成边界字符串,通常该字符串采用随机生成的方式:

static func randomBoundary() -> String {
            return String(format: "alamofire.boundary.%08x%08x", arc4random(), arc4random())
        }

上边的代码有一个小的知识点,%08x为整型以16进制方式输出的格式字符串,会把后续对应参数的整型数字,以16进制输出。08的含义为,输出的16进制值占8位,不足部分左侧补0。于是,如果执行printf("0x%08x", 0x1234);会输出0x00001234。

因为最终上传的数据是Data类型,因此需要一个转换函数,把边界转换成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 boundaryText.data(using: String.Encoding.utf8, allowLossyConversion: false)!
        }

在Alamofire中,上边的代码组成了BoundaryGenerator,表示边界生产者。上边代码中用到了EncodingCharacters.crlf,其实它是对"\r\n"的一个封装,表示换行回车的意思。

BodyPart

针对多表单中的内一个表单也需要做一个封装成一个对象,其内部需要作出下边这些说明:

因此设计的代码如下:

 /// 对每一个body部分的描述,这个类只能在MultipartFormData内部访问,外部无法访问
    class BodyPart {
        let headers: HTTPHeaders
        let bodyStream: InputStream
        let bodyContentLength: UInt64
        var hasInitialBoundary = false
        var hasFinalBoundary = false

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

MultipartFormData

MultipartFormData被设计为一个对象,在SessionManager.swift那一篇文章中我们会介绍MultipartFormData的具体用法。总之,MultipartFormData必须给我们提供一下的几个功能:

接下来,我们就跟着上边这些设计思想来一步一步的分析核心代码的来源。

属性

公开或者私有的属性有下边几个:

open var contentType: String { return "multipart/form-data; boundary=\(boundary)" }

    /// The content length of all body parts used to generate the `multipart/form-data` not including the boundaries.
    /// 这里的0表示初始值,$0表示计算结果类型,$1表示数组元素类型
    public var contentLength: UInt64 { return bodyParts.reduce(0) { $0 + $1.bodyContentLength } }

    /// The boundary used to separate the body parts in the encoded form data.
    public let boundary: String

    private var bodyParts: [BodyPart]
    private var bodyPartError: AFError?
    private let streamBufferSize: Int

我们对他们做一些简单的说明:

初始化方法

初始化方法就一个:

 /// Creates a multipart form data object.
    ///
    /// - returns: The multipart form data object.
    public init() {
        self.boundary = BoundaryGenerator.randomBoundary()
        self.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
        ///

        self.streamBufferSize = 1024
    }

Body Parts

我们想象一下,如果有很多种不同类型的文件要拼接到一个对象中,该怎么办?我们分析一下:

  1. 首先应该考虑输入源的问题,因为在开发中可能使用的输入源有3种

    • Data 直接提供Data类型的数据,比如把一张图片编码成Data,然后拼接进来
    • fileURL 通过一个文件的本地URL来获取数据,然后拼接进来
    • Stream 直接通过stream导入数据
  2. 明确了数据的输入源之后,我们还要考虑提供哪些参数来描述这些数据,这很有必要,比如只传递一个Data,服务端根本不知道应该如何解析它。根据不同的需求,需要提供一下参数:

    • name 与数据相关的名字
    • mimeType 表示数据的类型
    • fileName 表示数据的文件名称,
    • length 表示数据大小
    • stream 表示输入流
    • headers 数据的headers
  3. 根据第二步中的参数设计函数,函数的目的就是把每一条数据封装成BodyPart对象,然后拼接到bodyParts数组中

通过上边的分析呢,我们接下来的任务就是设计各种包含不同参数的函数。结合上边第一步和第二步的内容,我们分析后的结果如下:

encode() -> Data

通过上一小节的append方法,我们已经能够把数据拼接到bodyParts数组中了,接下来考虑的是怎么数组中的模型拼接成一个完整的Data。

这里有一个编码的小技巧,必须先检测有没有错误发生,如果有错误发生,那么就没必要继续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
    }

上边的代码做了3件事:

  1. 检查错误
  2. 给数组中第一个数据设置开始边界,最后一个数据设置结束边界
  3. 把bodyPart对象转换成Data类型,然后拼接到encoded中

上边的函数出现了一个新的函数;encode(_ bodyPart: BodyPart) throws -> Data

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
    }

上边的代码做了四件事:

  1. 在文章的开头我们就讲解了多表单的结构,第一步就是把边界转换成Data
  2. 把header转换成Data
  3. 把数据转换成Data
  4. 如果有结束边界,把结束边界转换成Data

在上边的函数中出现了5个辅助函数:

把拼接后的数据写入fireURL

在Alamofire中,如果编码后的数据超过了某个值,就会把该数据写入到fileURL中,在发送请求的时候,在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 关键字为此提供了安全又简单的处理方式:声明一个 block,当前代码执行的闭包退出时会执行该 block。
        defer { outputStream.close() }

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

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

上边的代码在检查完错误后,创建了一个outputStream,通过这个outputStream来把数据写到fileURL中。

注意,通过上边的函数可以看出,Alamofire并没有使用上边的encode函数来生成一个Data,然后再写入fileURL。这是因为大文件往往我们是通过append(fileURL)方式拼接进来的,并没有把数据加载到内存。

上边的代码中出现了一个辅助函数write(bodyPart, to: outputStream)

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)
    }

该函数出现了4个辅助函数:

 private func writeInitialBoundaryData(for bodyPart: BodyPart, to outputStream: OutputStream) throws {
        let initialData = bodyPart.hasInitialBoundary ? initialBoundaryData() : encapsulatedBoundaryData()
        return try write(initialData, to: outputStream)
    }

    private func writeHeaderData(for bodyPart: BodyPart, to outputStream: OutputStream) throws {
        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

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

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

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

            if bytesRead > 0 {
                if buffer.count != bytesRead {
                    buffer = Array(buffer[0..<bytesRead])
                }

                try write(&buffer, to: outputStream)
            } else {
                break
            }
        }
    }

    private func writeFinalBoundaryData(for bodyPart: BodyPart, to outputStream: OutputStream) throws {
        if bodyPart.hasFinalBoundary {
            return try write(finalBoundaryData(), to: outputStream)
        }
    }

由于上边函数的思想我们在文章中都讲过了,这里就不提了。除了上边的函数,还有两个写数据的辅助函数:

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)
    }

    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])
            }
        }
    }

对于上边的函数,大家了解下就行了。那么到这里为止,MultipartFormData我们就已经分析完成了。

总结

上边漏掉了下边这一个函数:

  private func mimeType(forPathExtension pathExtension: String) -> String {
        if
            let id = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, pathExtension as CFString, nil)?.takeRetainedValue(),
            let contentType = UTTypeCopyPreferredTagWithClass(id, kUTTagClassMIMEType)?.takeRetainedValue()
        {
            return contentType as String
        }
/// 如果是一个二进制文件,通常遇到这种类型,软件丢回提示使用其他程序打开
        return "application/octet-stream"
    }

当Content-Type使用了application/octet-stream时,往往客户端就会给出使用其他程序打开的提示。大家平时有没有见过这种情况呢?

由于知识水平有限,如有错误,还望指出

链接

Alamofire源码解读系列(一)之概述和使用 简书-----博客园

Alamofire源码解读系列(二)之错误处理(AFError) 简书-----博客园

Alamofire源码解读系列(三)之通知处理(Notification) 简书-----博客园

Alamofire源码解读系列(四)之参数编码(ParameterEncoding) 简书-----博客园

Alamofire源码解读系列(五)之结果封装(Result) 简书-----博客园

Alamofire源码解读系列(六)之Task代理(TaskDelegate) 简书-----博客园

Alamofire源码解读系列(七)之网络监控(NetworkReachabilityManager) 简书-----博客园

Alamofire源码解读系列(八)之安全策略(ServerTrustPolicy) 简书-----博客园

Alamofire源码解读系列(九)之响应封装(Response) 简书-----博客园

Alamofire源码解读系列(十)之序列化(ResponseSerialization) 简书-----博客园

上一篇 下一篇

猜你喜欢

热点阅读