多文件并行上传方案设计
多文件并行上传方案设计 https://mp.weixin.qq.com/s/Zb-PBejtSBLaBN0LEPrjVg
多文件并行上传方案设计
本文字数:2360字
预计阅读时间:15分钟
01
背景
抖音、快手等短视频 APP 都有本地编辑视频并上传的功能,这里的上传指的就是上传视频文件,其实无论是上传视频还是其他文件,技术原理都是相同的。
搜狐视频 APP 的文件上传除了基础的上传功能外,还支持多个视频文件的上传处理,以串行的形式进行上传。并且,在单个视频文件的上传中,为了保证充分利用带宽,还设计了并行上传的逻辑。整体方案如下。
02
方案设计
1、任务管理下图就是上传整体逻辑,上传逻辑分为三部分,核心上传逻辑由 UploadManager 实现,其他都是偏业务的:
-
每个视频文件的上传,都会被当做一个 task 来处理。上传前会先判断是否秒传,后面会对秒传逻辑进行详细讲解;
-
所有任务都由 uploadTasks 进行管理,当添加新任务时,会判断是否有任务在上传中。如果没有任务进行上传,会先将任务加入到 uploadTasks 中,随后就会进入上传流程。如果有任务在上传中,则会将任务添加到 uploadTasks 后,在队列中等待前面的任务上传完成;
-
随后进入分片上传的逻辑,所有分片上传完成后,会自动开始下一个文件的上传。
需要注意的是,当视频删除时,需要调用接口告知服务端,这个文件 id 废弃,以及删除已上传文件。
2、秒传逻辑由于视频文件比较大,比较占服务器存储空间,而且不同的 CDN 节点还会存在多个相同的备份,这样存储空间占用更严重。本质上来说,同一份文件在服务器只需要存在一份,所以对于这个问题,我们设计了一套排重逻辑。
在获取上传地址前,我们会计算一份视频文件的 md5,并在上传地址接口传给服务端,服务端会做比对。如果存在相同文件会直接走秒传逻辑,下发一个文件 id,客户端只通过文件 id 更新视频信息,不上传视频文件,这样从业务层就完成了上传流程。
3、分片上传这里需要先分清 task 和分片的概念,每个文件对应一个 task,一个文件会被切为多个分片进行上传。上传方式是表单提交,具体流程如下图:
-
上传地址是由服务器动态下发的,并不是固定地址。因为上传地址会过期,所以无论是第一次上传还是续传,在上传开始前都需要请求一遍上传地址;
-
如果第一次上传,则请求 upload.do 接口获取上传地址和文件 id,如果是续传则请求 resume.do 接口获取续传地址,续传因为不是第一次上传,所以会有文件 id,需要把文件 id 也给服务器带过去;
-
随后进入文件切片的阶段,文件切片通过 handle 实现,从前往后 seek 对应的 size,截取对应的 bytes,切片后需要拼接表单参数;
-
初始化待上传数组,数组中存储的元素是待上传的 index,上传过程中会从上传数组中取出待上传的 index,逐个分片进行上传。上传成功后,分片会插入到 finish 数组,表示已上传完成;
-
上传过程是并行的,并且并发数量不是固定值,而是不断进行动态计算的。上传模块有测速逻辑,并根据测速结果动态改变分片并行上传的并发数;
-
当发生错误时,如果是网络抖动或服务器导致上传失败,会根据对应的 case 选择是否重试,单个 task 最多重试三次;
-
当 task 对应的分片都上传完之后,会请求上传地址,并发送一个特殊标识,告知服务器。当前 task 文件上传完成。
03
表单提交
表单处理起来比较复杂,都是遵循标准格式,大概格式如下:
--boundary
Content-Disposition: form-data; name="参数名"
参数值
--boundary
Content-Disposition:form-data;name=”表单控件名”;filename=”上传文件名”
Content-Type:mime type
要上传文件二进制数据
--boundary--
其代码实现逻辑如下,代码已脱敏,iOS 项目换一个 Boundary 就可以直接用。
表单开头和结尾都需要添加 Boundary,表示文件的边界,在不同的参数间也需要添加 Boundary,这是固定格式。代码中有一些换行操作,这些换行都是固定格式,不能增加或减少。
- (NSString *)writeMultipartFormData:(NSData *)data parameters:(NSDictionary *)parameters {
if (data.length == 0) {
return nil;
}
NSMutableData *formData = [NSMutableData data];
NSData *lineData = [@"\r\n" dataUsingEncoding:NSUTF8StringEncoding];
NSString *boundaryString = [NSString stringWithFormat:@"--%@", Boundary];
NSData *boundary = [boundaryString dataUsingEncoding:NSUTF8StringEncoding];
// 拼接上传参数
[parameters enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) {
[formData appendData:boundary];
[formData appendData:lineData];
NSString *thisFieldString = [NSString stringWithFormat:
@"Content-Disposition: form-data; name=\"%@\"\r\n\r\n%@",
key, obj];
[formData appendData:[thisFieldString dataUsingEncoding:NSUTF8StringEncoding]];
[formData appendData:lineData];
}];
// 拼接上传文件及信息
[formData appendData:boundary];
[formData appendData:lineData];
NSString *thisFieldString = [NSString stringWithFormat:
@"Content-Disposition: form-data; name=\"name\"; filename=\"filename\"\r\nContent-Type: mimetype"];
[formData appendData:[thisFieldString dataUsingEncoding:NSUTF8StringEncoding]];
[formData appendData:lineData];
[formData appendData:lineData];
[formData appendData:data];
[formData appendData:lineData];
[formData appendData: [[NSString stringWithFormat:@"--%@--\r\n", Boundary] dataUsingEncoding:NSUTF8StringEncoding]];
NSString *filePath = [NSString stringWithFormat:@"%@/%ld", self.segmentDocumentPath, self.currentIndex];
BOOL write = [formData writeToFile:filePath atomically:YES];
return write ? filePath : nil;
}
1、断点续传文件上传整体是有断点续传的,包括 task 和分片两个维度。
如果现在 uploadTasks 队列中包含三个上传 task,第一个正在上传中,其他两个等待上传中,退出应用下一次进入应用,依然会从当前 task 的上传进度开始,并且后面两个 task 依然处于等待状态。
任务的实现很简单,退出应用以及特定时机持久化 uploadTasks 队列即可。分片的续传,以及如何保证分片可以一个不差的上传到服务器,则是一个关键问题。
-
对于这个问题,我设计了双数组的方式,在创建上传任务后,根据特定规则计算出单个 size 的大小,并计算源文件需要多少个分片,提前创建好对应 count 的数组,数组元素是分片索引,命名为 uploadSegments 待上传数组;
-
之后的上传任务都是从 uploadSegments 数组中取出 index,切片后拼接表单进行上传;
-
分片请求服务器后,如果请求成功则从 uploadSegments 中删除,添加到 successSegments 中。失败的话,可能遇到的 case 比较多,如果是网络波动等情况,就进行请求重试。如果是文件格式等问题,则停止上传并提示错误;
-
上传成功的分片 index 都会添加到 successSegments 中,直到 successSegments 的 count 等于分片的 count,所有分片任务就都上传完成,给服务器发一个标识即可完成整个任务上传;
-
在退出应用前,会保存 successSegments 和 uploadSegments 两个数组,下次启动后续传直接从 uploadSegments 中取出 index 后执行分片逻辑即可。没错,我们的续传是以分片为维度的。
这个方案看似比较麻烦,但对于保证上传成功率非常有效,可以解决续传、失败等多种情况。
2、内存峰值如果是高清的视频文件,size 会比较大,1个 G 的文件也是存在的。所以,需要考虑大文件上传的问题。
需要注意的是,NSURLSession 有下面两种上传文件的方法,第二种会存在内存问题,尤其是上传较大文件时,会出现很大的内存峰值。即便是分片上传,内存峰值的问题依然存在,可能会导致上传过程中发生崩溃。
- (NSURLSessionUploadTask *)uploadTaskWithRequest:(NSURLRequest *)request
fromFile:(NSURL *)fileURL;
- (NSURLSessionUploadTask *)uploadTaskWithRequest:(NSURLRequest *)request
fromData:(NSData *)bodyData;
无论是用 AFNetworking 还是 NSURLSession,都存在这个问题,解决方法就是使用 fromFile 的方式解决。
所以,我们采取 fromFile 的方案,先对源文件切片,拼接表单后写入到本地,再将路径传进去上传。这样就不会出现内存峰值崩溃的问题,即便上传1个 G 的视频,整体上传流程内存依然很平稳。
文件上传的过程比较长,在此期间可能会遇到很多 case,下面列出一些常见问题及处理方式。
-
如果发生内存空间不足,或者无网络等情况,需要暂停所有任务,并保存对应任务状态;
-
未知网络错误,或其他非常见网络问题,重试三次,如果均失败则暂停任务;
-
网络未授权或飞行模式,提示用户并暂停任务。