彻底搞懂大文件分片上传的实现

1,110 阅读5分钟

参考文章

git代码库

学习AbortController

AbortController 接口表示一个控制器对象,允许你根据需要中止一个或多个 Web 请求。

可以通过这个来实现取消或者中断请求的功能。
axios.abort();底层实现就是这个。

认识了
fse = require('fs-extra')

概述

实现文件分片上传的原理就是通过将文件的ArrayBlob形式,通过file(blob)的方法slice,来实现将文件拆成几个部分,然后排上顺序传到服务端,最后传完了调用服务端的merge合并接口,服务端将文件合并。

服务端实现原理,上传前创建文件夹,命名注意了,可以参考给到的代码。然后将获得的文件通过fse的写入方法,写入到我们创建的文件夹中,并且有相应的排序。最后我们通过合并方法将文件合并到一个文件。

详细学习

Client端

getChunkListAndFileMd5函数

这个函数用来创建分片数组,以及生成Hash签名。

创建分片列表数组,我们使用的方法是file.prototype.slice当然这里我们为了解决兼容性问题,我们使用的代码是:

export function getBlobSlice() {
    return (File.prototype.slice ||
        File.prototype.mozSlice ||
        File.prototype.webkitSlice);
}

创建分片:getBlobSlice.call(file,start,end)。这里start、end分别是起始位置和终点位置,我们确认好分片大小就可以计算start和end。

好,那么我们开始了,我们定义size是 5M 代码如下

const DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024;

我们定义一个对象保存默认配置:

const DEFAULT_OPTIONS = {
    chunkSize: DEFAULT_CHUNK_SIZE,
};

我们来定义分片的编号,初始值肯定是 0。我们定义chunkSize来保存我们上边定义的常量分片大小。

let currentChunk = 0;
const chunkSize = this.fileUploaderClientOptions.chunkSize; 

接下来我们需要计算需要多少个分片,很简单:文件总的大小/每个分片的大小,最终结果可能是个小数,但是为了保证分片的完整肯定是向上取整。

const chunks = Math.ceil(file.size / chunkSize);

定义一个函数来加载分片,代码如下:

function loadNextChunk() {
    const start = currentChunk * chunkSize;
    const end = start + chunkSize >= file.size ? file.size : start + chunkSize;
    const chunk = blobSlice.call(file, start, end);
    chunkList.push(chunk);
    fileReader.readAsArrayBuffer(chunk);
}

拆解这个函数:

  1. 计算start位置,这个不难理解。

  2. 计算end位置,这里需要判断一下,主要是针对两种情况:

    1. 文件尺寸很小,小于分片大小,直接取文件的尺寸。
    2. 最后一个切片,大小可能没有切片尺寸大,我们直接取文件大小的位置。

3.调用我们上边说的函数来实现分片,并且获取当前的分片。
4.将当前的分片push到我们的数组中。
5.调用fileReader.readAsArrayBuffer方法来读取分片。(其实当前的分片是一个blob实例,我们通过这个方法可以读取到里边的内容)。

接下来我们来看看fileReader是怎么回事。


认识FileReader

不懂fileReader的可以先看看文档,了解下里边的方法。

FileReader 对象允许 Web 应用程序异步读取存储在用户计算机上的文件(或原始数据缓冲区)的内容,使用 FileBlob 对象指定要读取的文件或数据。

其中 File 对象可以是

  • 来自用户在一个 元素上选择文件后返回的FileList对象
  • 也可以来自拖放操作生成的 DataTransfer对象
  • 还可以是来自在一个HTMLCanvasElement执行mozGetAsFile()方法后返回结果。

FileReader可以在Web Worker中使用。 (重要,可以很大成都解决性能问题)

这里因为用到了FileReader的方法,所以重点讲讲这个对象的方法。

  • FileReader.abort(); 中止读取操作。
  • FileReader.readAsArrayBuffer(); 开始读取指定的 Blob中的内容,一旦完成,result 属性中保存的将是被读取文件的 ArrayBuffer 数据对象。
  • FileReader.readAsDataURL(); 开始读取指定的Blob中的内容。一旦完成,result属性中将包含一个data: URL 格式的 Base64 字符串以表示所读取文件的内容。
  • FileReader.readAsText();开始读取指定的Blob中的内容。一旦完成,result属性中将包含一个字符串以表示所读取的文件内容。

我们目前需要读取分片的内容,并且需要ArrayBuffer的格式,所以我们创建一个FileReader对象实例,并且使用readAsArrayBuffer方法来读取文件,通过onload事件来监听,获取最终结果。代码如下:

const fileReader = new FileReader();
fileReader.onload = function (e) {
    // 我们读取到的结果是 e.target.result
}
fileReader.onerror = function (e) {
    // 这里表示读取失败
}

上边loadNextChunk函数中调用了这个方法。

fileReader.readAsArrayBuffer(chunk);

使用md5来实现签名

用到了 spark-md5 这个三方库。主要使用的方法是生成md5的编码。

npm install --save spark-md5

npmjs中的spark-md5

github

这个就是一个处理分片的一个库。给出了方法:

const spark = new SparkMD5.ArrayBuffer();

将分片 v 添加到对象中

spark.append(v)

最终结果返回

const result = spark.end()

uploadFile函数

这个函数用来上传文件的函数。

首先我们要获取上边我们函数得到的值 md5chunkList

const { md5, chunkList } = yield this.getChunkListAndFileMd5(file);

上传文件前,需要调用接口来初始化文件分片上传,这里我们其实就是调用初始化文件分片上传的接口requestOptions.initFilePartUploadFunc

yield requestOptions.initFilePartUploadFunc();

这个接口在后端的实现其实就是创建好一个文件夹用来保存分片,这里就不多说了,之后在学习Server代码的时候我们细讲。

接下来就是开始上传我们的分片了,上传方法requestOptions.uploadPartFileFunc

for (let index = 0; index < chunkList.length; index++) {
    try {
        yield requestOptions.uploadPartFileFunc(chunkList[index], index);
    }
    catch (e) {
        console.warn(`${index} part upload failed`);
        retryList.push(index);
    }
}

注意了:我们不能保证全部顺利上传,如果中间出现问题中断了上传等问题,我们如何处理?
这里我们使用retryTimes来获取需要重新上传的列表。

for (let retry = 0; retry < requestOptions.retryTimes; retry++) {
    if (retryList.length > 0) {
        console.log(`retry start, times: ${retry}`);
        for (let a = 0; a < retryList.length; a++) {
            const blobIndex = retryList[a];
            try {
                yield requestOptions.uploadPartFileFunc(chunkList[blobIndex], blobIndex);
                retryList.splice(a, 1);
            }
            catch (e) {
                console.warn(`${blobIndex} part retry upload failed, times: ${retry}`);
            }
        }
    }
}

最后我们调用上传结束的接口requestOptions.finishFilePartUploadFunc(md5),其实这个接口主要是通知服务端,分片都上传完了,服务端可以进行文件合并了,最终将分片合并成一个文件。

if (retryList.length === 0) {
    return yield requestOptions.finishFilePartUploadFunc(md5);
}
else {
    throw Error(`upload failed, some chunks upload failed: ${JSON.stringify(retryList)}`);
}

至此,客户端的操作完毕!

完整代码