大文件上传断点续传具体实现

是什么

不管怎样简单的需求,在量级达到一定层次时,都会变得异常复杂

文件上传简单,文件变大就复杂

上传大文件时,以下几个变量会影响我们的用户体验

  • 服务器处理数据的能力
  • 请求超时
  • 网络波动
    上传时间会变长,高频次文件上传失败,失败后又需要重新上传等等

为了解决上述问题,我们需要对大文件上传单独处理

这里涉及到分片上传断点续传两个概念

分片上传

分片上传,就是将所要上传的文件,按照一定的大小,将整个文件分隔成多个数据块(Part)来进行分片上传

如下图

上传完之后再由服务端对所有上传的文件进行汇总整合成原始的文件

大致流程如下:

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

断点续传

断点续传指的是在下载或上传时,将下载或上传任务人为的划分为几个部分

每一个部分采用一个线程进行上传或下载,如果碰到网络故障,可以从已经上传或下载的部分开始继续上传下载未完成的部分,而没有必要从头开始上传下载。用户可以节省时间,提高速度

一般实现方式有两种:

  1. 服务器端返回,告知从哪开始
  2. 浏览器端自行处理

上传过程中将文件在服务器写为临时文件,等全部写完了(文件上传完),将此临时文件重命名为正式文件即可

如果中途上传中断过,下次上传的时候根据当前临时文件大小,作为在客户端读取文件的偏移量,从此位置继续读取文件数据块,上传到服务器从此偏移量继续写入文件即可

实现思路

整体思路比较简单,拿到文件,保存文件唯一性标识,切割文件,分段上传,每次上传一段,根据唯一性标识判断文件上传进度,直到文件的全部片段上传完毕

image-20220803184035358

下面的内容都是伪代码

读取文件内容:

handleFileChange(e){
    
    
	const [file] = e.target.files
  if(!file) return 
  this.file = file
}

核心代码

async uploadFile(){
    
    

      if(!this.file){
    
    
        return 
      }
		  // todos: 判断文件类别
      
      // 1. 文件拆片
      const chunks = this.createFileChunk(this.file)
      // const hash = await this.calculateHashWorker()
      // const hash1 = await this.calculateHashIdle()
      // console.log('文件hash',hash)
      // console.log('文件hash1',hash1)
      
      // 2. 计算文件的hash值。这hash将用来唯一标识这个文件
      const hash = await this.calculateHashSample()
      this.hash = hash

      // 3. 尝试上传
  		// 3.1 文件是否上传过? 如果上传过,就秒传成功
      // 3.2 如果没有,是否有存在的切片?
      const {
    
    data:{
    
    uploaded, uploadedList}} = await this.$http.post('/checkfile',{
    
    
        hash:this.hash,
        ext:this.file.name.split('.').pop()
      })
      if(uploaded){
    
    
        // 秒传
        return this.$message.success('秒传成功')
      }
      // 4. 上传
      await this.uploadChunks(uploadedList, chunks)

    },

createFileChunk

// 创建分片
    createFileChunk(file,size=CHUNK_SIZE){
    
    
      const chunks = [] 
      let cur = 0
      while(cur<this.file.size){
    
    
        chunks.push({
    
    index:cur, file:this.file.slice(cur,cur+size)})
        cur+=size
      }
      return chunks
    },

uploadChunks

async uploadChunks(uploadedList=[], chunks){
    
    

      this.chunks = chunks.map((chunk,index)=>{
    
    
        // 切片的名字 hash+index
        const name = hash +'-'+ index
        return {
    
    
          hash,
          name,
          index,
          chunk:chunk.file,
          // 设置进度条,已经上传的,设为100
          progress:uploadedList.indexOf(name)>-1 ?100:0
        }
      })

      const requests = this.chunks
        .filter(chunk=>uploadedList.indexOf(chunk.name)==-1)
        .map((chunk,index)=>{
    
    
          // 转成promise
          const form = new FormData()
          form.append('chunk',chunk.chunk)
          form.append('hash',chunk.hash)
          form.append('name',chunk.name)
          // form.append('index',chunk.index)
          return {
    
    form, index:chunk.index,error:0}
        })
        .map(({
     
     form,index})=> this.$http.post('/uploadfile',form,{
    
    
          onUploadProgress:progress=>{
    
    
            // 不是整体的进度条了,而是每个区块有自己的进度条,整体的进度条需要计算
            this.chunks[index].progress = Number(((progress.loaded/progress.total)*100).toFixed(2))
          }
        }))
      // @todo 并发量控制 
      // 尝试申请tcp链接过多,也会造成卡顿
      // 异步的并阿叔控制,
      // await Promise.all(requests)

      // await this.sendRequest(requests)
      await Promise.all(requests)
      await this.mergeRequest()

    }

合并文件

async mergeRequest(){
    
    
      const ret = await this.$http.post('/mergefile',{
    
    
        ext:this.file.name.split('.').pop(),
        size:CHUNK_SIZE,
        hash:this.hash
      })
    },

可以使用md5实现文件的唯一性

import sparkMD5 from 'spark-md5'

// 计算当前文件的hash
    async calculateHashSample(file){
    
    

      // 布隆过滤器  判断一个数据存在与否
      // 1个G的文件,抽样后5M以内
      // hash一样,文件不一定一样
      // hash不一样,文件一定不一样
      return new Promise(resolve=>{
    
    
        const spark = new sparkMD5.ArrayBuffer()
        const reader = new FileReader()

        const size = file.size
        const offset = 2*1024*1024
        // 第一个2M,最后一个区块数据全要
        let chunks = [file.slice(0,offset)]

        let cur = offset
        while(cur<size){
    
    
          if(cur+offset>=size){
    
    
            // 最后一个区快
            chunks.push(file.slice(cur, cur+offset))

          }else{
    
    
            // 中间的区块
            const mid = cur+offset/2
            const end = cur+offset
            chunks.push(file.slice(cur, cur+2))
            chunks.push(file.slice(mid, mid+2))
            chunks.push(file.slice(end-2, end))
          }
          cur+=offset
        }
        // 中间的,取前中后各2各字节
        reader.readAsArrayBuffer(new Blob(chunks))
        reader.onload = e=>{
    
    
          spark.append(e.target.result)
          this.hashProgress = 100
          resolve(spark.end())
        }
      })
    },
    

然后开始对文件进行分割

var reader = new FileReader();
reader.readAsArrayBuffer(file);
reader.addEventListener("load", function(e) {
    
    
    //每10M切割一段,这里只做一个切割演示,实际切割需要循环切割,
    var slice = e.target.result.slice(0, 10*1024*1024);
});

h5上传一个(一片)

const formdata = new FormData();
formdata.append('0', slice);
//这里是有一个坑的,部分设备无法获取文件名称,和文件类型,这个在最后给出解决方案
formdata.append('filename', file.filename);
var xhr = new XMLHttpRequest();
xhr.addEventListener('load', function() {
    
    
    //xhr.responseText
});
xhr.open('POST', '');
xhr.send(formdata);
xhr.addEventListener('progress', updateProgress);
xhr.upload.addEventListener('progress', updateProgress);

function updateProgress(event) {
    
    
    if (event.lengthComputable) {
    
    
        //进度条
    }
}

这里给出常见的图片和视频的文件类型判断

function checkFileType(type, file, back) {
    
    
/**
* type png jpg mp4 ...
* file input.change=> this.files[0]
* back callback(boolean)
*/
    var args = arguments;
    if (args.length != 3) {
    
    
        back(0);
    }
    var type = args[0]; // type = '(png|jpg)' , 'png'
    var file = args[1];
    var back = typeof args[2] == 'function' ? args[2] : function() {
    
    };
    if (file.type == '') {
    
    
        // 如果系统无法获取文件类型,则读取二进制流,对二进制进行解析文件类型
        var imgType = [
            'ff d8 ff', //jpg
            '89 50 4e', //png

            '0 0 0 14 66 74 79 70 69 73 6F 6D', //mp4
            '0 0 0 18 66 74 79 70 33 67 70 35', //mp4
            '0 0 0 0 66 74 79 70 33 67 70 35', //mp4
            '0 0 0 0 66 74 79 70 4D 53 4E 56', //mp4
            '0 0 0 0 66 74 79 70 69 73 6F 6D', //mp4

            '0 0 0 18 66 74 79 70 6D 70 34 32', //m4v
            '0 0 0 0 66 74 79 70 6D 70 34 32', //m4v

            '0 0 0 14 66 74 79 70 71 74 20 20', //mov
            '0 0 0 0 66 74 79 70 71 74 20 20', //mov
            '0 0 0 0 6D 6F 6F 76', //mov

            '4F 67 67 53 0 02', //ogg
            '1A 45 DF A3', //ogg

            '52 49 46 46 x x x x 41 56 49 20', //avi (RIFF fileSize fileType LIST)(52 49 46 46,DC 6C 57 09,41 56 49 20,4C 49 53 54)
        ];
        var typeName = [
            'jpg',
            'png',
            'mp4',
            'mp4',
            'mp4',
            'mp4',
            'mp4',
            'm4v',
            'm4v',
            'mov',
            'mov',
            'mov',
            'ogg',
            'ogg',
            'avi',
        ];
        var sliceSize = /png|jpg|jpeg/.test(type) ? 3 : 12;
        var reader = new FileReader();
        reader.readAsArrayBuffer(file);
        reader.addEventListener("load", function(e) {
    
    
            var slice = e.target.result.slice(0, sliceSize);
            reader = null;
            if (slice && slice.byteLength == sliceSize) {
    
    
                var view = new Uint8Array(slice);
                var arr = [];
                view.forEach(function(v) {
    
    
                    arr.push(v.toString(16));
                });
                view = null;
                var idx = arr.join(' ').indexOf(imgType);
                if (idx > -1) {
    
    
                    back(typeName[idx]);
                } else {
    
    
                    arr = arr.map(function(v) {
    
    
                        if (i > 3 && i < 8) {
    
    
                            return 'x';
                        }
                        return v;
                    });
                    var idx = arr.join(' ').indexOf(imgType);
                    if (idx > -1) {
    
    
                        back(typeName[idx]);
                    } else {
    
    
                        back(false);
                    }

                }
            } else {
    
    
                back(false);
            }

        });
    } else {
    
    
        var type = file.name.match(/\.(\w+)$/)[1];
        back(type);
    }
}

调用方法如下

checkFileType('(mov|mp4|avi)',file,function(fileType){
    
    
    // fileType = mp4,
    // 如果file的类型不在枚举之列,则返回false
});

上面上传文件的一步,可以改成:

formdata.append('filename', md5code+'.'+fileType);

有了切割上传后,也就有了文件唯一标识信息,断点续传变成了后台的一个小小的逻辑判断

后端主要做的内容为:根据前端传给后台的md5值,到服务器磁盘查找是否有之前未完成的文件合并信息(也就是未完成的半成品文件切片),取到之后根据上传切片的数量,返回数据告诉前端开始从第几节上传

如果想要暂停切片的上传,可以使用XMLHttpRequest的 abort方法

使用场景

大文件加速上传:当文件大小超过预期大小时,使用分片上传可实现并行上传多个 Part, 以加快上传速度
网络环境较差:建议使用分片上传。当出现上传失败的时候,仅需重传失败的Part
流式上传:可以在需要上传的文件大小还不确定的情况下开始上传。这种场景在视频监控等行业应用中比较常见

小结

当前的伪代码,只是提供一个简单的思路,想要把事情做到极致,我们还需要考虑到更多场景,比如

  • 切片上传失败怎么办
  • 上传过程中刷新页面怎么办
  • 如何进行并行上传
  • 切片什么时候按数量切,什么时候按大小切
  • 如何结合 Web Worker 处理大文件上传
  • 如何实现秒传

参考文献

  • https://gitee.com/fanyouf/nb/commit/80813904c644f4ed088968a32b81ebda068af345
  • https://segmentfault.com/a/1190000009448892

猜你喜欢

转载自blog.csdn.net/TongJ_/article/details/129116472