代码示例: https://github.com/johnlui/Swift-On-iOS/blob/master/BuildYourHTTPRequestLibrary
开源项目:Pitaya,适合大文件上传的 HTTP 请求库: https://github.com/johnlui/Pitaya
本篇文章是此系列文章的终结篇,我们将一起给我们的网络请求库增加“快速文件上传”的功能。
我翻出了以前买的《图解 HTTP》:
找到第 46-47 页,“发送多种数据的多部分对象集合”:
// HTTP 头 开始 Content-Type: multipart/form-data; boundary=PitayaUGl0YXlh // HTTP 头 结束 // HTTP Body 开始 --PitayaUGl0YXlh Content-Disposition: form-data; name="field1" John Lui --PitayaUGl0YXlh Content-Disposition: form-data; name="text"; filename="file1.txt" ···[file1.txt 的数据]··· --PitayaUGl0YXlh-- // HTTP Body 结束
HTTP 协议是一种非常基础的“字符串格式化约定”,本质上传输的依然是一堆字符,只是由于遵守了标准协议,后端的 HTTP 服务软件(Apache、nginx)和前端的浏览器、NSData、NSURLSession 等接口可以顺畅地交流。
在 HTTP 协议中,上传文件可以进行如下设置:
设定 Content-Type 头字段如下:
Content-Type: multipart/form-data; boundary=PitayaUGl0YXlh
boundary 是我们自己指定的间隔符。
之后设定 HTTP Body 如下:
--PitayaUGl0YXlh Content-Disposition: form-data; name="field1" John Lui --PitayaUGl0YXlh Content-Disposition: form-data; name="text"; filename="file1.txt" ···[file1.txt 的数据]··· --PitayaUGl0YXlh--
每个字段以 “--间隔符” 开头,最后总体以 “--间隔符--” 结尾。
HTTP 协议中,换行必须用 /r/n,我尝试过只使用 /n 换行,系统会直接原封不动地发送这个换行,如果后端的 HTTP 服务器不支持这种容错的话,可能就会出问题,所以建议大家还是要遵守标准协议。
上传文件也是表单,也需要一个 name,所以我们需要构造一个 File 结构体,来描述要上传的文件:
struct File { let name: String! let url: NSURL! init(name: String, url: NSURL) { self.name = name self.url = url } }
上面代码中,我们使用 NSURL 来描述文件地址。
class NetworkManager { let method: String! let params: Dictionary<String, AnyObject> let callback: (data: NSData!, response: NSURLResponse!, error: NSError!) -> Void // add files var files: Array<File> let session = NSURLSession.sharedSession() let url: String! var request: NSMutableURLRequest! var task: NSURLSessionTask! // add files init(url: String, method: String, params: Dictionary<String, AnyObject> = Dictionary<String, AnyObject>(), files: Array<File> = Array<File>(), callback: (data: NSData!, response: NSURLResponse!, error: NSError!) -> Void) { self.url = url self.request = NSMutableURLRequest(URL: NSURL(string: url)!) self.method = method self.params = params self.callback = callback // add files self.files = files } ...... }
class NetworkManager { let boundary = "PitayaUGl0YXlh" ...... }
if self.files.count > 0 { request.addValue("multipart/form-data; boundary=" + self.boundary, forHTTPHeaderField: "Content-Type") } else if self.params.count > 0 { request.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") }
func buildBody() { let data = NSMutableData() if self.files.count > 0 { if self.method == "GET" { NSLog("/n/n------------------------/nThe remote server may not accept GET method with HTTP body. But Pitaya will send it anyway./n------------------------/n/n") } for (key, value) in self.params { data.appendData("--/(self.boundary)/r/n".nsdata) data.appendData("Content-Disposition: form-data; name=/"/(key)/"/r/n/r/n".nsdata) data.appendData("/(value.description)/r/n".nsdata) } for file in self.files { data.appendData("--/(self.boundary)/r/n".nsdata) data.appendData("Content-Disposition: form-data; name=/"/(file.name)/"; filename=/"/(file.url.description.lastPathComponent)/"/r/n/r/n".nsdata) if let a = NSData(contentsOfURL: file.url) { data.appendData(a) data.appendData("/r/n".nsdata) } } data.appendData("--/(self.boundary)--/r/n".nsdata) } else if self.params.count > 0 && self.method != "GET" { data.appendData(buildParams(self.params).nsdata) } request.HTTPBody = data }
.nsdata 属性是我对 String 做的一个扩展,代码在: https://github.com/johnlui/Swift-On-iOS/blob/master/BuildYourHTTPRequestLibrary/BuildYourHTTPRequestLibrary/Network.swift#L46-L50
static func request(method: String, url: String, callback: (data: NSData!, response: NSURLResponse!, error: NSError!) -> Void) { let manager = NetworkManager(url: url, method: method, callback: callback) manager.fire() } static func request(method: String, url: String, params: Dictionary<String, AnyObject>, callback: (data: NSData!, response: NSURLResponse!, error: NSError!) -> Void) { let manager = NetworkManager(url: url, method: method, params: params, callback: callback) manager.fire() } static func request(method: String, url: String, files: Array<File>, callback: (data: NSData!, response: NSURLResponse!, error: NSError!) -> Void) { let manager = NetworkManager(url: url, method: method, files: files, callback: callback) manager.fire() } static func request(method: String, url: String, params: Dictionary<String, AnyObject>, files: Array<File>, callback: (data: NSData!, response: NSURLResponse!, error: NSError!) -> Void) { let manager = NetworkManager(url: url, method: method, params: params, files: files, callback: callback) manager.fire() }
增加一张图片用于上传文件测试:
测试代码如下:
let file = File(name: "file", url: NSURL(fileURLWithPath: NSBundle.mainBundle().pathForResource("Pitaya", ofType: "png")!)!) Network.request("POST", url: "http://pitayaswift.sinaapp.com/pitaya.php", files: [file]) { (data, response, error) -> Void in let string = NSString(data: data, encoding: NSUTF8StringEncoding) as! String if string == "1" { println("上传文件成功!") } }
http://pitayaswift.sinaapp.com/pitaya.php 会在收到 name="file" 的文件之后,输出 1。
运行项目,点击按钮,输出结果,成功!
Alamofire 并不支持表单文件上传,似乎只支持流文件上传(不确定),故我之前在使用 Alamofire 的时候,是把二进制文件读出来之后进行 base64 编码,然后当做字符串字段传输的,除了体积会增大三分之一外,最严重的问题在于非常长的 HTTP 准备时间(开始发送数据包之前的处理时间),这期间还是阻塞的。实际测试,无论是 A5 处理器的 touch5 还是 A8 处理器的 iPhone6,500KB 的语言都需要接近 30S 的预处理时间。阻塞问题可以通过超线程方式解决,但是总体上传时间依然是非常长的,500 KB 的语音文件的预处理时间和网络传输时间几乎都一样长了。
快在哪里?采用 NSData 方式直接赋值给 HTTP Body,这种方式不会消耗任何预处理时间,当然也不会对主线程造成阻塞。而且传输的字符串的长度减少 25%,实际测试 500KB 语音文件上传速度从 57S 缩短为 21S,增速十分可观。