大文件分片上传、断点续传

698 阅读5分钟

微信图片_20220828142311.jpg 照片由作者摄于四川黄龙风景区,海拔4000米

前言

最近工作中遇到了一个需求:文件上传,这算是后台系统中很常见的一个功能了。由于项目是React的,我想都没想便用antd的Upload组件很快速的完成了,直到有一天公司服务器崩了,带宽不够,上传大文件时疯狂转圈圈,由此,我才发现之前做的时候考虑得非常片面。 对于大文件的上传,利用spark-md5生成文件的hash值,进行切片上传与断点续传,这也是很常见的一个处理方式。

为什么分片上传

首先,我们得了解一下文件上传中要做的几件事:

  • 获取文件
  • 判断文件类型及大小
  • 获取请求地址
  • 往服务器端推送文件

其次,以antd的Upload组件为例,文件的上传只进行了一次请求,请求的主体是FormData。当文件比较小的时候,请求成功率高且速度快;当文件比较大的时候,会导致上传过程中耗时过久,大量占用带宽资源,则很有可能出现上传失败的情况。 所以,对于大文件的上传,我们不能一次性将这些数据全部发送出去,这样会浪费网络资源,且上传速度过于缓慢,影响交互体验,对此,分片上传结合断点续传是一种解决方案。

分片上传的原理

分片上传,也就是将文件切割成若干文件片段,按次序一个个上传,服务器端也是一个个接收,并存储在一个临时文件夹中,等全部分片上传完毕之后,客户端再发起合并分片的请求,由服务器端合并分片,得到一个完整的文件,并删除之前的分片文件夹,返回文件hash值给客户端,以示上传成功。 通常情况下,File对象是由用户在input元素上选择文件后返回的fileList对象 每个file对象包含:

image.png

这边穿插一个基于原生JS+Node实现的单文件上传demo,主要为了帮助大家回顾一下基于FormData实现的文件上传,可以直接跳到如何分片

<div class="item">
            <h3>单一文件上传「FORM-DATA」</h3>
            <section class="upload_box" id="upload1">
                <!-- accept=".png" 限定上传文件的格式 -->
                <input type="file" class="upload_inp" accept=".png,.jpg,.jpeg">
                <div class="upload_button_box">
                    <button class="upload_button select">选择文件</button>
                    <button class="upload_button upload">上传到服务器</button>
                </div>
                <div class="upload_tip">只能上传 PNG/JPG/JPEG 格式图片,且大小不能超过2MB</div>
                <ul class="upload_list">
                    <!-- <li>
                        <span>文件:...</span>
                        <span><em>移除</em></span>
                    </li> -->
                </ul>
            </section>
        </div>

    //基于FormData实现的文件上传
(function () {
    //1.获取要用的DOM元素
    let upload = document.querySelector('#upload1'),
    upload_inp = upload.querySelector('.upload_inp'),
    upload_button_select = upload.querySelector('.upload_button.select'),
    upload_button_upload = upload.querySelector('.upload_button.upload'),
    upload_tip = upload.querySelector('.upload_tip'),
    upload_list = upload.querySelector('.upload_list');
    //将第3步中获取到的file对象,提取出来作为该闭包的全局对象
    let _file = null;

    //2.点击选择文件按钮,触发上传文件的input框选择文件的行为
    upload_button_select.addEventListener('click',function(){
        //7.如果当前按钮处于不可用状态,则不进行后续处理
        if(upload_button_select.classList.contains('disable') || upload_button_upload.classList.contains('loading')){
            return;
        }
        upload_inp.click();
    });

    //3.监听用户选择文件的操作
    upload_inp.addEventListener('change',function(){
        //如何获取用户选中的文件:upload_inp.files,此处为单文件上传,所以只获取数组第一项
        console.log('fileList',upload_inp.files);
        let file = upload_inp.files[0];
        if(!file) return;
        /**
         * file对象:
         *  -name:文件名
         *  -size:文件大小
         *  -type:文件类型
         */

        //3.1控制文件上传的格式
        //方案一:基于JS->限制文件上传的格式,如果file的type中不包含这几个类型之一,则代表上传的类型不符合这三种
        // if(!/(PNG|JPG|JPEG)/i.test(file.type)){
        //     alert('上传的文件只能是 PNG/JPG/JPEG 三种类型');
        // }

        //方案二:在input的accept属性里面进行限制,<input type="file" class="upload_inp" accept=".png,.jpg,.jpeg">

        //3.2.限制文件上传的大小
        if(file.size > 2 * 1024 * 1024){
            alert('上传的文件大小不能超过2MB');
            return;
        }

        //确定文件符合规范后,转存到_file变量中,方便其他方法使用
        _file = file;

        //3.3显示上传的文件
        upload_tip.style.display = 'none';  //隐藏文件提示
        upload_list.style.display = 'block';  //展示文件列表
        upload_list.innerHTML = `<li>
        <span>文件:${file.name}</span>
        <span><em>移除</em></span>
        </li>`;

    });

    
    /**
     * 4.实现移除方法
     *  根据冒泡机制原理,当我们触发移除这个DOM的点击事件时,会逐层冒泡到其祖先元素上
     *  为了更便捷的处理,我们可以直接监听其祖先元素file_list的点击事件,这种操作一般我们称为事件委托
     *  此处用事件委托进行处理,还有几个原因是:
     *      -em这个DOM是我们动态添加的,也就是说一开始初始化的时候并没有这个DOM
     *      -后续对于多个文件上传的功能,会存在多个em,而为每个em都绑定click事件监听并不合理
     */
    upload_list.addEventListener('click',function(e){
        let target = e.target;
        if(target.tagName === 'EM'){  //判断点击的是不是移除按钮
            //移除文件并切换DOM显示状态
            handleClear();
        }
    });

    //将清空文件,即按需展示元素的功能封装起来,以便移除方法和上传成功与失败时执行
   const handleClear = () =>{
        _file = null;  //清除文件
        upload_tip.style.display = 'block';  //显示文件提示
        upload_list.style.display = 'none';  //隐藏文件列表
        upload_list.innerHTML = ``;  //清空列表
    }

    /**
     * 5.使用axios发请求,上传文件到服务器
    */
   upload_button_upload.addEventListener('click',function(){
        //8.如果当前按钮处于不可用状态,则不进行后续处理
        if(upload_button_select.classList.contains('disable') || upload_button_upload.classList.contains('loading')){
            return;
        }
        if(!_file){  //直接点击上传,没有点击上传文件
            alert('请先选择要上传的文件');
            return;
        }

        //6.1开始上传,则让按钮变为不可操作的状态
        handleDisable(true);

        /**
         * 把文件传递给服务器的两种格式:
         *  -formData(此处用formData)
         *  -Base64
         * 结合服务端提供的api文档配置请求:(上传成功的文件在服务器端项目的upload文件夹中可以查看)
         * 1.单文件上传处理「FORM-DATA」:由服务器自动生成文件的名字
            url:/upload_single
            method:POST
            params:multipart/form-data
                file:文件对象
                filename:文件名字
            return:application/json
                code:0成功 1失败,
                codeText:状态描述,
                originalFilename:文件原始名称,
                servicePath:文件服务器地址
        */
        let formData = new FormData();
        formData.append('file',_file);
        formData.append('filename',_file.name);
        //instance是在instance.js文件中通过axios库创建的一个实例对象,进行了二次封装
        instance.post('/upload_single',formData).then(data=>{
            //此处data即为response.data,因为已提前在instance配置项中使用axios的拦截器interceptors,帮助我们过滤掉不需要的数据,只留下主体信息data
            if(+data.code === 0){ //code强制转换为number类型
                alert(`上传成功,可在 ${data.servicePath} 中查看 `);
                return;
            }
            return Promise.reject(data.codeText); //返回一个新的Promise对象,状态是rejected,在catch中捕获
        }).catch(reason=>{
            alert('文件上传失败,请稍后再试');
        }).finally(()=>{  //不论成功或者失败,最后都会执行的函数
            //移除文件并切换DOM显示状态
            handleClear();
            //6.2上传结束,恢复按钮的可操作功能
            handleDisable(false);
        });

   });

})();

顺便简单看一下服务器端是如何处理的,不是本文重点,简单了解:

// 延迟函数
const delay = function delay(interval) {
    typeof interval !== "number" ? interval = 1000 : null;
    return new Promise(resolve => {
        setTimeout(() => {
            resolve();
        }, interval);
    });
};

// 基于multiparty插件实现文件上传处理 & form-data解析
const uploadDir = `${__dirname}/upload`;

/**
 * 
 * @param {*} req  客户端传递的信息
 * @param {*} auto  是否允许插件自动对文件进行处理
 * @returns 
 * multiparty的缺点:无法对重复图片进行处理
 */
const multiparty_upload = function multiparty_upload(req, auto) {
    typeof auto !== "boolean" ? auto = false : null;
    let config = {
        maxFieldsSize: 200 * 1024 * 1024,  //限制文件大小为200MB
    };
    //如果设为自动处理,会自动把文件存放到uploadDir定义的目录结构下
    if (auto) config.uploadDir = uploadDir;
    return new Promise(async (resolve, reject) => {
        await delay(); //为了测试效果更明显,手动加了个延迟函数,默认值是1s
        //multiparty插件提供一个Form方法,用来根据config配置项,把req里面传递过来的formData数据进行解析parse
        new multiparty.Form(config)
            .parse(req, (err, fields, files) => {
                if (err) {
                    reject(err); //通过reject()方法将Promise状态设置为rejected
                    return;
                }
                //解析成功:
                resolve({
                    fields,  //传过来的filename
                    files  //传过来的file
                });
                //通过插件处理,会把传过来的文件名称处理成特殊值,避免多客户端传递同名文件
            });
    });
};

// 单文件上传处理「FORM-DATA」
/**
 * req:request客户端传过来的信息
 * res:response对象提供了一些方法让我们可以给客户端返回一些信息
 */
app.post('/upload_single', async (req, res) => {
    try {
        let {
            files
        } = await multiparty_upload(req, true);
        let file = (files.file && files.file[0]) || {};
        res.send({
            code: 0,
            codeText: 'upload success',
            originalFilename: file.originalFilename,
            //将服务器中存储文件的地址返回
            servicePath: file.path.replace(__dirname, HOSTNAME)
        });
    } catch (err) {
        //上传失败
        res.send({
            code: 1,
            codeText: err
        });
    }
});

PS:以上源码,已包含在我的git项目中,本文结尾提供了git地址。

回到本文的重点,文件分片的核心是利用:file.prototype.slice()方法 file本身没有slice()方法,熟悉JS的朋友会知道,在对象身上有prototype属性,里面是它继承自父类的东西,file从Blob接口继承了slice()方法。

file.slice(a,b); //代表从a位置切到b位置

而对于文件进行切片,还得思考几个问题:

  1. 每个文件都需要切片吗?
  2. 切片的标准是什么?固定大小还是固定数量?

结论是:

  1. 对于比较小的文件,就没必要分片;超过了某个指定大小才进行分片。
  2. 切片的标准是:先以固定大小为主,如果获得的切片数量很大,超过了限制的数量,则转为固定数量。

如何分片并赋予分片hash值

举个栗子: 我们要上传一个文件大小为101MB,固定的切片大小为1MB,则按固定大小会被切成101片,而我们要求最多不能超过100片,也就是固定数量为100片,那可以通过判断进行处理,当切片数量>100时,直接用文件大小 / 100,得到每个切片的大小。

let max = 1* 1024 * 1024; //每个切片最大1MB
let count = Math.ceil(file.size / max);  //向上取整,获取切片数量
if(count > 100){  //如果切片数量超过100个,则设为100个,固定大小和固定数量的结合
    max = file.size / 100;
    count = 100;
}

当我们把一个文件切成很多切片,每个切片都需要一个名字。首先,我们根据文件内容生成一个hash值,那每个切片的名字就是hash值_第几个切片.文件后缀,最后合成的文件夹名字就是hash。之后,我们只需要发送hash值给服务端,服务端就知道把哪些切片合成文件。

那么,如何获取文件的hash值呢? 首先我们得获取到这个文件的md5,即通过某种hash算法,得这个文件独一无二的hash值,对于不同名的文件,内容相同的情况下,它们的hash值也是相同的,这个hash值也常用于后端用来判断该文件是否已上传,以减轻服务器端存储资源的压力。

使用js-spark-md5计算文件的md5

npm install --save spark-md5

封装一个方法:该方法传递一个file进去,利用FileReader把获取到的文件数据读取为arrayBuffer字节数组,由于FileReader进行读取和转换的过程是异步的,所以需要在onLoad()中进行后续操作,通过spark.end()方法拿到该hash值。

    //file->Buffer
    const changeToBuffer = file =>{
        return new Promise(resolve =>{
            let fileReader = new FileReader();
            //读取成Buffer类型
            fileReader.readAsArrayBuffer(file);
            fileReader.onload = e =>{
                let buffer = e.target.result,
                spark = new SparkMD5.ArrayBuffer(),
                hash;
                spark.append(buffer);
                hash = spark.end();  //spark拿到buffer处理成hash
                suffix = /\.([A-z]+)$/.exec(file.name)[1];  //在文件里面获取文件名,即以.xxx结尾的部分
                //最终生成的是hash加上文件名后缀
                resolve({
                    buffer,
                    hash,
                    suffix,
                    filename: `${hash}.${suffix}`
                })
            }
        })
    }

在分片过程中,给予每个分片一个唯一的标识,也就是:文件的hash值_index.文件后缀

let index = 0; //初始位置0
let chunks = [];  //存放所有的切片
while(index < count){
    /**
     * index为0:0 ~ max
     * index为1:max ~ max+max / max*2
     * index为2: max*2 ~ max*3 
     *           ...
     *           index*max ~ (index+1)*max
     */
    chunks.push({
        file: file.slice(index*max, (index+1)*max),
        filename: `${HASH}_${index+1}.${suffix}`
    })
    index++;
}

断点续传

  • 为什么要断点续传?

比如一开始切成10片,只有7个传成功了,需要服务器提供一个接口,告诉我当前已经上传成功的切片,那我们只需要上传剩下的切片。或者,我们依旧上传全部切片给服务器,由服务器自行判断当前切片是否已经存在,并返回给客户端。 举个栗子: 后端同学提供一个already_upload接口,接收参数为文件的hash值,返回数据类型如下:

data:['xxx_1.png','xxx_2.png','xxx_3.png'...]

前端只需上传剩余切片,已经上传的切片无需再上传

如何上传

循环遍历分片的数组,发起请求:

//把每个切片都上传到服务器上
        chunks.forEach(chunk=>{
            //已经上传的切片无需再上传
            //already为后端返回的已经上传的切片数据
            if(already.length > 0 && already.includes(chunk.filename)){
                complete(); //另外封装的方法,用来操作进度条,并在全部分片上传完毕后,发起合并分片的请求
                return;
            }
            let fm = new FormData;  //以FormData的数据形式进行上传
            fm.append('file',chunk.file);
            fm.append('filename',chunk.filename);
            //instance为二次封装后的axios
            instance.post('/upload_chunk',fm).then(data=>{
                if(+data.code === 0){
                    //当前切片上传成功
                    complete();
                    return;
                }
                return Promise.reject(data.codeText); //返回一个失败状态的Promise对象
            }).catch(err=>{
                alert('当前切片上传失败');
                clear();  //另外封装的方法,用来处理页面DOM元素的loading状态及进度条状态等
            })
        })

总结

  1. 使用file.slice()对文件进行切片
  2. 使用spark-md5计算文件hash值,并给予每个切片唯一标识
  3. 上传切片,并在所有切片上传成功后,发起合并切片的请求
  4. 可以监听axios的onUploadProgress方法,获取文件上传的进度(loaded/total)
  5. 如果存在已上传的切片,则进行续传

以上是前端基于原生JS和Node实现的大致思路,主要是为了自己做个记录,也希望能对大家有些帮助。当然,还有很多没有考虑到的方面及更好的解决方案,比如采用Web Workers进行性能优化,对Web Workers感兴趣的小伙伴可以看看阮一峰老师的日志

源码

file_upload_server

file_upload_client

参考链接:

前端上传大文件怎么处理

从零开始手写一个「开箱即用的大文件分片上传库」