以流的形式执行 Multipart 请求

栏目: IOS · 发布时间: 5年前

内容简介:作者:Soroush Khanlou,译者:Foundation 框架中的 URL 类提供了非常全面的功能,此后还在 iOS 7 中新增了 URLSession 类。尽管如此,基础库中仍然缺少 multipart 文件上传的功能。

作者:Soroush Khanlou, 原文链接 ,原文日期:2018-11-14

译者: 郑一一 ;校对: numbbbbbpmst ;定稿: Forelax

Foundation 框架中的 URL 类提供了非常全面的功能,此后还在 iOS 7 中新增了 URLSession 类。尽管如此,基础库中仍然缺少 multipart 文件上传的功能。

什么是 multipart 请求?

Multipart 编码实际上就是在网络中上传大型文件的方法。在浏览器中,有时候你会选择一个文件作为表单提交内容的一部分。这个文件便是以 multipart 请求的方式实现上传的。

乍一看,multipart 请求和一般请求差不多。不同之处是 multipart 请求额外为 HTTP 请求体指定了唯一编码。同 JSON 编码( {"key": "value"} )或者 URL 字符编码 ( key=value ) 相比,multipart 编码干的事略微有所不同。因为 multipart 请求体实际上只是一串字节流,接收端实体在解析数据时,需要知道字节流中各个部分之间的界限。所以 multipart 请求需要使用 “boundaries” 来解决这个问题。在请求首部的 Content-Type 中,可以定义 boundary:

Accept: application/json
Content-Type: multipart/form-data; boundary=khanlou.comNczcJGcxe

Boundary 的具体内容并不重要,唯一需要注意的是:在请求体中,boundary 是不能重复出现(这样才能体现 boundary 的作用)。你可以使用 UUID 作为 boundary。

请求的每一部分可以是普通数据(比如图片)或者元数据(一般是文本,对应一个名字,组成一个键值对)。如果数据是图片的话,那它看起来应该是这样的:

--<boundary>
Content-Disposition: form-data; name=<name>; filename=<filename.jpg>
Content-Type: image/jpeg

<image data>

如果是普通文本,则是这样:

--<boundary>
Content-Disposition: form-data; name=<name>
Content-Type: text/plain

<some text>

请求结尾会有一个带着两个连字符的 boundary, --<boundary>-- 。(此处需要注意,所有新行必须是回车换行。)

以上就是关于 multipart 请求的所有内容,并不是特别复杂。事实上,当在写第一个有关 multipart 编码的客户端实现时,我有些抵触阅读 multipart/form-data 的 RFC 。可是在开始阅读之后,我对这个协议的理解更深了。整个文档可读性很强,很轻易就能直达知识的源头。

我在开源的 Backchannel SDK 实现了上述功能。 BAKUploadAttachmentRequest BAKMultipartRequestBuilder 类包含了处理 mulitipart 的方法。在这个项目中,仅仅包含了处理单个文件的情况,并且没有包括元数据。但是作为范例,依旧很好地展示了 mulitipart 请求是如何构建的。可以通过添加额外的实现代码,来支持元数据和多文件的功能。

无论是使用一个请求上传多个文件,还是多个请求分别对应上传一个文件,来实现多文件上传功能,都会碰到一个问题。这个问题就是,如果你尝试一次性上传很多文件的话,app 将会闪退。这是因为使用 该版本的代码 ,加载的数据会直接进入内存,在内存暴涨的情况下,即使使用当下性能最强的旗舰手机也会有闪退发生。

将硬盘中数据以流的形式读取

最常见的解决方法是将硬盘中的数据以流的形式读取出来。其核心思想是文件的字节数据会一直保存在硬盘里,直到被读取并发往网络。内存中只保留了很小一部分的镜像数据。

目前,我想出两种方法可以解决这个问题。第一个方法,把 multipart 请求体中的所有数据写到硬盘的一个新文件中,并使用 URLSession 的 uploadTask(with request: URLRequest, fromFile fileURL: URL) 方法将文件转化为流。这个方法可以奏效,但我并不想为每一个请求新建一个新文件保存到硬盘中。因为这意味着在请求发出后,还需要删除这个文件。

第二种方法是将内存和硬盘的数据合并在一起,并通过统一的接口向网络输出数据。

如果你觉得第二种方法听起来像是 类簇 ,恭喜你,完全正确。很多常用 Cocoa 类都允许创建子类,并实现一些父类方法,使其和父类表现一致。回想一下 NSArray-count 属性和 -objectAtIndex: 方法。因为 NSArray 的所有其它方法都是基于 -count 属性和 -objectAtIndex: 方法实现的,你可以非常轻易地创建优化版本的 NSArray 子类。

你可以创建一个 NSData 子类,它无需真正从硬盘读取数据,而只是创建一个指针直接指向硬盘中的数据。这样做的好处是是不需要把数据载入内存中进行读取。这种方法称为内存映射,基于 Unix 方法 mmap 。你可以通过 .mappedIfSafe 或者 alwaysMapped 选项,来使用 NSData 的这项特性。因为 NSData 是一个类簇,我们将创建一个 ConcatenatedData 子类(就像 FlattenCollection 在 Swift 中的工作方式),该子类会将多个 NSData 对象视作一个连续的 NSData 。完成创建以后,我们就做好所有准备来解决这个问题啦。

通过查看 NSData 所有原生方法,可以发现,需要实现的是 -count-bytes 。实现 -count 并不难,我们可以把所有 NSData 对象的大小相加得到;但在实现 -bytes 时则会有个问题。 -bytes 需要返回一个指向一段连续缓冲区的指针,而目前我们并没有这个指针。

在基础库中,提供了 NSInputStream 类用于处理不连续的数据。非常幸运, NSInputStream 同样是一个类簇。我们可以创建一个子类,将多条流合并。在使用子类时,感觉上就像是一条流。通过使用 +inputStreamWithData:+inputStreamWithURL: 方法,可以轻易地创建一条输入流,用来代表硬盘中的文件和内存中的数据(比如 boundaries)。

通过阅读最好的第三方网络库源代码,你会发现 AFNetworking 采用了 这种方法 。( Alamofire ,Swift 版本的 AFNetworking,则采用了第一种方法, 将数据全部加载到内存中 ,但如果数据量太大,就会写到硬盘的一个文件中。)

将所有部分拼接起来

你可以在 这里 看看我的串行输入流的实现(是用 Objective-C 实现的,以后我可能还会写一个 Swift 版本的)。

通过 SKSerialInputStream 类,可以将流组合在一起。下面展示了前缀和后缀属性:

extension MultipartComponent {
    var prefixData: Data {
        let string = """
        \(self.boundary)
        Content-Disposition: form-data; name="\(self.name); filename="\(self.filename)"
        """
        return string.data(using: .utf8)
    }
    
    var postfixData: Data {
        return "\r\n".data(using: .utf8)
    }
}

将元数据和文件的 dataStream 组合在一起,得到一条输入流:

extension MultipartComponent {
    var inputStream: NSInputStream {
        
        let streams = [
            NSInputStream(data: prefixData),
            self.fileDataStream,
            NSInputStream(data: postfixData),
        ]
    
        return SKSerialInputStream(inputStreams: streams)
    }
}

创建好每一部分输入流之后,就可以把所有流组合在一起,得到一条完整输入流。此外,在请求结尾还需要添加一个 boundary:

extension RequestBuilder {
    var bodyInputStream: NSInputStream {
        let stream = parts
            .map({ $0.inputStream })
            + [NSInputStream(data: "--\(self.boundary)--".data(using: .utf8))]
    
        return SKSerialInputStream(inputStreams: streams)
    }
}

最后,将 bodyInputStream 赋值给 URL 请求的 httpBodyStream 属性:

let urlRequest = URLRequest(url: url)

urlRequest.httpBodyStream = requestBuilder.bodyInputStream;

注意, httpBodyStreamhttpBody 两个属性是互斥的——两个属性不会同时生效。设置 httpBodyStream 会使得 Data 版本 httpBody 失效,反之亦然。

流文件上传的关键是能够将多条输入流合并成一条流。 SKSerialInputStream 类完成了整个工作。尽管说子类化 NSInputStream 有一些困难,可一旦解决这个问题,我们就离成功不远啦。

子类化过程中需要注意的问题

子类化 NSInputStream 的过程不会太轻松,甚至可以说很困难。你必须实现 9 个方法。其中的 7 个方法,父类只有一些微不足道的默认实现。而在文档中只提到了 9 个方法中的 3 个,所以你还得实现 6 个 NSStreamNSInputStream 的父类)的方法,其中有 2 个是 run loop 方法,并允许空实现。在这之前,你还需要额外 实现 3 个私有方法 ,不过现在不必实现了。此外,还需要定义 3 个 只读 属性: streamStatusstreamErrordelegate

在处理完上述子类化相关的细节后,接下来的挑战是创建一个 NSInputStream 子类,其行为应该和 API 使用者所期望的保持一致。然而,这个类状态的重度耦合是不容易被人发现的。

有一些状态需要保证行为一致。举个例子, hasBytesAvailable 是不同于其它状态的,但还是存在细微的联系。在我最近发现的一个 bug 里, hasBytesAvailable 属性会返回 self.currentIndex != self.inputStreams.count ,但是这会造成一个 bug,流会一直处于开启的状态,并最终造成请求超时。修复这个 bug 的办法是改为返回 YES ,但我一直没有找到这个 bug 的根源所在。

另外一个状态 streamStatus ,存在许多可能的值,其中比较重要的两个值是 NSStreamStatusOpenNSStreamStatusClosed

最后一个比较有意思的状态是字节数,从 read 方法中返回值。这个属性除了会返回正整型数之外,还会返回 -1,-1 代表有错误产生,需要进一步检查非空属性 streamError 来获取更多信息。字节数还可以返回 0,根据文档描述,这是标明流结尾的另外一种方式。

文档并不会告诉你哪些状态的组合是有意义的。比如说流产生一个 streamError ,但状态却是 NSStreamStatusClosed ,而不是 NSStreamStatusError ,在这种情况下是否会有问题?想要管理好所有的状态非常难,不过到最后终究还是能解决的。

对于 SKSerialStream 类,是否可以在所有情况下都能正常工作,我还不是特别有信心。但看起来, SKSerialStream 通过使用 URLSession 能很好地支持上传 multipart 数据。如果你在使用这份代码的时候发现任何问题,请务必联系我,我们可以一起不断优化这个类。

本文由 SwiftGG 翻译组翻译,已经获得作者翻译授权,最新文章请访问http://swift.gg。


以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

编程珠玑(第二版)

编程珠玑(第二版)

[美] Jon Bentley / 谢君英、石朝江 / 中国电力出版社 / 2004-4 / 28.00元

《编程珠玑(第2版)》是计算机科学方面的经典名著。书的内容围绕程序设计人员面对的一系列实际问题展开。作者Jon Bentley 以其独有的洞察力和创造力,引导读者理解这些问题并学会解决方法,而这些正是程序员实际编程生涯中至关重要的。一起来看看 《编程珠玑(第二版)》 这本书的介绍吧!

Base64 编码/解码
Base64 编码/解码

Base64 编码/解码

URL 编码/解码
URL 编码/解码

URL 编码/解码

html转js在线工具
html转js在线工具

html转js在线工具