JS

文件的分片上传和断点续传

Posted by WWJ on November 11, 2020

什么是分片上传


分片上传就是把一个大文件切割成若干块,一小块一小块的传输,分片上传的好处在于避免文件的重新上传。 试想一下,如果我们上传的文件是一个很大的文件,上传时间就会比较久,再加上网络不稳定或者误操作等不稳定因素,很容易导致传输中断,用户出了重新上传没有其他别的什么办法。 而分片上传就可以解决上述问题,当传输中断之后,我们可以只从中断的地方重新上传剩余片段,而不需要重新上传整个文件,大大减少了重传的开销。

分片上传的原理

  1. 将需要上传的文件按照一定的规则,分割成大小相同的数据块
  2. 初始化一个分片上传任务,并返回本次上传的唯一标识
  3. 按照一定的策略(串行或者并行)发送各个数据块
  4. 发送完成之后,服务端判断数据上传是否完整,如果完整,则将数据块进行合成得到原始文件

分片上传流程

Alt text

使用分片上传需要注意的点是:

  1. 上传之前需要授权,避免第三方未经授权将数据往Bucket里面传
  2. 如果上传的文件小于100kb,默认使用直接上传
  3. 除了最后一个数据块,其他数据块的大小不能小于100KB,否则会导致合成失败
  4. 切割的数据块不是越大越好,也不是越小越好,要根据实际的网络情况做对应的处理,核心就是网络好的情况下可以切割的大一点,反之就切割的小一点

文件分块(divideParts)

首先需要定义好分片的大小partSize,然后根据文件的大小fileSize除以partSize取余就可以计算出文件一共被分割成了多少个数据块

const divideParts = (fileSize, partSize) => {
	const numParts = Math.ceil(fileSize / pasrtSize);
	const partOffs = [];
	
	for(let i = 0; i < numParts; i++) {
		const start = partSize * i;
		cosnt end = Math.min(start + partSize, fileSize);
		
		partOffs.push({
			start,
			end
		})
	}
	
	return partOffs;
}

初始化分片任务

在这个过程中,主要是生成一个唯一的UploadId,用于标识本次Multiple Upload事件,之后的中止上传以及断点续传都是通过这个UploadId来进行操作的;其中MD5算法可以用来生成唯一标识

const request = async () => {
	// 开始请求UploadId等相关数据
	return {};
}

const initMultipartUpload = async (name, options) => {
	// 获取UploadId的请求参数
	const params = {
	}
    const result = await request(params);

    return {
      res: result.res,
      bucket: result.data.Bucket,
      name: result.data.Key,
      uploadId: result.data.UploadId
	};
}

分片任务初始化完成,拿到UploadId之后,就可以进行真正的文件传输了,不过在此之前,仍需要做的一步就是记录上传之前的文件状态,这一步的目的就是在传输中断之后,再次上传的时候知道是从哪个地方继续传输,该状态可以用一个对象来记录

cosnt checkPoint = {
	file,      // 上传的文件
    name,      // 上传文件的名称
    fileSize,  // 上传文件的大小
    partSize,  // 每次需要上传的文件大小
    uploadId,  // 上传的唯一标识,
    doneParts: [],  // 上传完成的数据块集合
}

开始分片上传

初始化上传任务之后,就可以开始真正的文件上传了,这个过程会给阿里云服务器发送一个请求,请求Url为http://xmdev-resource-pub.oss-cn-hangzhou.aliyuncs.com/objectName,请求方法为PUT,请求参数为partNumberuploadId;即根据指定的UploadId以及传输的数据块上传数据;

每一个上传的Part都有一个标识它的号码,即partNumberuploadId用于唯一标识上传的Part属于哪个Object。

如果使用了同一个partNumber上传了相同的数据,那么OSS上已有的partNumber对应的Part数据将被覆盖。

在上传的过程中,数据块每次上传完成之后,都会计算当前的上传进度以及数据块,并将进度以及当前数据块返回给客户端,客户端业务代码做相应的断点记录,为之后的断点续传做铺垫

// 上传完成之后
checkpont.doneParts.push({
	number: partNumber,
	etag: 每次上传成功之后服务器返回的ETag值
});

const currentProgress = checkpont.doneParts.length // 所有part的数量

// 调用客户端的progress函数
progress(currentProgress, checkpoint);

合成文件

最后在将所有数据Part都上传完成后,便会执行文件的合成工作,即将所有的part合成一个完整的文件; 在执行该操作时,必须提供有效的Part列表(包括Part号码和ETag),OSS收到用户提交的Part列表后,会逐一验证每个数据Part的有效性。当所有的数据Part验证通过后,OSS将把这些数据part组合成一个完整的Object。

到这里基本上算是完成了文件的上传,不过还有最后一步,就是我们再使用这个上传的文件的时候,需要知道这个文件的OSS地址,那么这个OSS地址其实是由自己的服务器返回来的,那么我们自己的服务器又是怎么拿到这个资源的呢?

了解OSS直传

Web 端常见的上传方法是用户在浏览器或 APP 端上传文件到应用服务器,应用服务器再把文件上传到 OSS。相对于这种上传慢、扩展性差、费用高的方式,阿里云官方更推荐将数据直传到 OSS。

阿里云 OSS 直传的三种方案:

  1. JavaScript 客户端签名后直传。
  2. 服务端签名后直传。
  3. 服务端签名后直传并设置上传回调。

我们的系统采用的是第三种上传方式

enter image description here

可以看到,Web 端需要做的只有两步:

向应用服务器请求上传 授权和回调。可以在这一步做一些初始化上传文件信息的操作,以及获取业务逻辑需要使用的数据。 向 OSS 发送文件上传请求,接收 OSS 服务器的响应。

什么是断点续传


所谓断点续传,也就是要从文件上传中断的地方开始继续上传,在HTTP1.1之前是不支持断点续传的,HTTP1.1之后,HTTP协议默认支持断点续传,主要是在header中增加了两个字段Range和contentRange。

Reange:用于请求头中,指定第一个字节的位置和最后一个字节的位置,比如

// 请求5001-10000字节
Range:bytes=5001-10000

// 请求5001字节之后全部的
Range:bytes=50001-

针对范围请求,通过HTTP响应返回状态码为206 Partial Content 的响应报文

Content-Range:用于响应头,指定整个实体中的一部分的插入位置,他也指示了整个实体的长度。在服务器向客户返回一个部分响应,它必须描述响应覆盖的范围和整个实体长度。

浏览器端使用


请求上传授权

客户端想OSS服务器上传文件之前需要获取上传授权,包括accessKeyId,accessKeySecret,region,bucket,stsToken,这些都是可以调应用服务器的接口拿到的,获取到授权之后,就可以创建一个OSS实例,用来执行分片上传和断点续传

Service.post('mfs/anon/mfs/multuPartUpload')
	.then((res) => {
		const { result = {} } = res;
		const {
			bucket,
			callBack,    // 回调函数
			resourceId,  // 资源占位符
			accessKeyId,
			accessKeySecret,
			callbackBody, // 回调函数body
			ossUri, // 后端给的上传地址
			securityToken,
		} = result;
		
		const ossClient = new OSS({
			bucket,
			accessKeyId,
			accessKeySecret,
			region: 'oss-cn-hangzhou',
			stsToken: securityToken
		});
	})

分片上传

ossClient.multipartUpload(ossUri, file, {
	callback: {
		url: callBack,
		body: callbackBody,
		contentType: 'application/josn'
	},
	parallel: 1024*1024, // 分片大小
	partSize: 5,         // 同时上传的分片数量
	progress: onMultipartUploadProgress,  // 上传进度
}).then(() => {
	// 成功之后的处理
}).catch(() => {
	// 失败之后的处理
});

const onMultipartUploadProgress = (progess, checkpoint) => {
	// 记录文件的进度以及断点处理
	const checkpoints = {};
	checkpoints[checkpoint.uploadId] = checkpoint;
}

断点续传

Object.keys(checkpoints).forEach(checkpoint => {
	ossClient.multipartUpload(ossUri, file => {
		checkpoint,  // 增加断点
		callback: {
			url: callBack,
			body: callbackBody,
			contentType: 'application/josn'
		},
		parallel: 1024*1024, // 分片大小
		partSize: 5,         // 同时上传的分片数量
		progress: onMultipartUploadProgress,  // 上传进度
	})
})

刚开始看文档的时候还觉得挺简单的,以为只要使用一个API就可以完成功能,但过程中其实遇到了比较多的问题,让我印象深刻的是下面两个问题

1. 上传没有超过100KB的文件无法记录断点

一开始以为是自己使用API的姿势不对,一直对着文档找不同的地方,最后还是不明白问题出现在哪,被这个问题阻塞了一天之后,只能去看ali-oss这个npm里面的具体实现,找到multipartUpload这个函数,看到有这么一个逻辑

proto.multipartUpload = async function multipartUpload(name, file, options) {
 
  const minPartSize = 100 * 1024;
  const fileSize = await this._getFileSize(file);
  
  // 如果文件大小小于100kb,那么就走普通上传
  if (fileSize < minPartSize) {
    if (options && options.progress) {
      await options.progress(1);
    }
    const result = await this.putStream(name, stream, options);
    const ret = {
	   res: result.res,
      bucket: this.options.bucket,
      name,
      etag: result.res.headers.etag
     };
    return ret;
  }

  const checkpoint = {
    file,
    name,
    fileSize,
    partSize,
    uploadId,
    doneParts: []
  };

  if (options && options.progress) {
    await options.progress(0, checkpoint, initResult.res);
  }

  return await this._resumeMultipart(checkpoint, options);
};

multipartUpload函数里面有一个判断就是当需要被上传的文件大小小于100kb的时候,直接用普通的文件上传,所有执行客户端的progress回调函数的时候,只传了一个参数,且值为1,所以当我们在progress回调函数里面进行一些有关于断点checkpoint的操作的时候,就会报错

2. 文件上传完成之后调用后端给的回调函数失败

文件上传完成之后,需要再调用应用服务器的回调函数,这样应用服务器才知道文件已经上传成功了,可以根据resourceId从对应的bucket里面获取资源并返回给客户端了,这个问题主要是后端那边去处理的,前端的问题在于续传的时候又重新请求授权了,,这样会使得一个文件有两个resourceId,结果就会导致文件找不到了。