往期导航:
简介
需要上传多表单数据时,需要将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的数据长度,使用hasInitialBoundary与hasFinalBoundary来记录数据前后分隔符的类型。
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中不同的表单数据类型:
- Data+name(直接从内存中读取,只能用于小文件,可指定文件名字与mime类型)
- fileurl+name(未指明文件名字,与mime类型,根据fileurl最后的文件名与扩展名来判断,然后调用方法3)
- fileurl+name+文件名+mime类型(等同于2)
- InputStream+data长度+文件名+mime类型(会先把mime类型封装为HTTPHeaders然后调用方法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使用
- 编码为Data(所有数据都在内存中,注意文件太大会爆内存)
- 使用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
}
}
以上纯属个人理解,难免有误,如发现有错误的地方,欢迎评论指出,将第一时间修改,非常感谢~