断点续传

一、前端大文件上传文件的痛点

1、文件过大会导致带宽资源紧张,请求速度下降 ;
2、如果上传过程中服务中断、网络中断 、页面崩溃,可能会导致文件重新开始上传。

二、痛点的分析

前端选择文件后上传,后端在处理文件过程中,首先会将文件加载到运行内存中,之后再调用相应的API进行写入硬盘内存的操作,完成整个文件的上传。

但这样直接上传文件,可能会因为某个环节出了问题导致整个流程的雪崩,所以大文件直接上传是不可取的。

解决问题最好办法是分片断点续传,该方式主要是针对大文件(比如100M以上的文件)

三、断点续传的原理

顾名思义就是断点续传

什么是断点?

在文件上传过程中,将一个要上传的文件分成N块,然后使用多线程并发多块上传,因为某种原因导致上传被中断或暂停,此时中断或暂停的位置就成为断点

前端每上传一片,将会被加载到运行内存中,加载完毕后再写入硬盘,此时运行内存的临时变量会被释放,然后此临时变量会被下一片占用,再进行写入,释放...

什么是续传?

意思是指从中断的位置继续上传剩下的部分文件,而不是从头开始上传。

上传完毕后,在服务端进行合并(合并的操作是在后端进行的,前端只是调用接口,合并的方式是由后端决定的,到底是上传一片就合并一片,或者是上传所有的之后整体进行合并)。

断点续传的实现

1)分片的实现

方式:
html5z之前的方式是flashactiveX
html5提供了文件二进制流进行分割的slice方法。

const chunks = Math.ceil(file.size / eachSize)

文件的分片,一般在2-5M之间。这一步得到了每一片文件的内容、每一块的序号、每一块的大小、总块数等数据。

2)续传的实现
  1. 续传首先要确定需要继续上传的是哪一个文件,而确定一个文件的方式是对文件进行加密,只要某个文件内容发生变化,就需要重新上传。可以对文件进行MD5加密作为文件唯一的标识符,MD5加密是不可逆的。
    要注意:对整个大文件进行加密,可能会导致页面崩溃,需要对文件进行分片加密。
    spark-md5插件支持文件分片加密
    基于elementUI的spark-md5的使用

  2. 在第一片文件上传之前,需要用文件名称 + 此文件唯一标识符 +当前片数来查询文件是否上传过。通过服务器返回的已经上传的结果,我们可以通过分片的结果获取剩余部分进行上传。如果页面重新加载或者上传中断,只需要在重新上传之前在哪一片中断便可以继续上传。

  3. 在上传完毕后,请求合并接口(合并接口也可以不请求,后端拿到所有文件后自己进行合并),在服务端将文件进行合并,此时整个文件上传结束。

  4. 在上传过程中,可以根据服务器返回的当前上传成功的片数和总片数对前端进度条进行展示

element-ui中Upload spark-md5的使用

//template
<el-upload
  :http-request="chunkedUpload"
  :ref="chunkedUpload"
  :action="uploadUrl"
  :data="uploadData"
  :on-error="onError"
  :before-remove="beforeRemove"
  name="file">
//js部分
import chunkedUpload from './chunkedUpload'
export default {
  data() {
    return {
      uploadData: {
        //这里面放额外携带的参数
      },
      //文件上传的路径
      uploadUrl: process.env.BASE_API + '/oss/oss/uploadChunkFile', //文件上传的路径
      chunkedUpload: chunkedUpload // 分片上传自定义方法,在头部引入了
    }
  },
  methods: {
    onError(err, file, fileList) {
      this.$store.getters.chunkedUploadXhr.forEach(item => {
        item.abort()
      })
      this.$alert('文件上传失败,请重试', '错误', {
        confirmButtonText: '确定'
      })
    },
    beforeRemove(file) {
      // 如果正在分片上传,则取消分片上传
      if (file.percentage !== 100) {
        this.$store.getters.chunkedUploadXhr.forEach(item => {
          item.abort()
        })
      }
    }
  }
}

//chunkedUpload.js
import SparkMD5 from 'spark-md5'
import axios from 'axios'
import store from '@/store'
// 如果上传错误,获取报错信息
function getError(action, option, xhr) {
  let msg
  if (xhr.response) {
    msg = `${xhr.response.error || xhr.response}`
  } else if (xhr.responseText) {
    msg = `${xhr.responseText}`
  } else {
    msg = `fail to post ${action} ${xhr.status}`
  }
  const err = new Error(msg)
  err.status = xhr.status
  err.method = 'post'
  err.url = action
  return err
}
// 上传成功完成合并之后,获取服务器返回的信息
function getBody(xhr) {
  const text = xhr.responseText || xhr.response
  if (!text) {
    return text
  }
  try {
    return JSON.parse(text)
  } catch (e) {
    return text
  }
}

// 分片上传的自定义请求,以下请求会覆盖element的默认上传行为
export default function upload(option) {
  if (typeof XMLHttpRequest === 'undefined') {
    return
  }
  const spark = new SparkMD5.ArrayBuffer()// md5的ArrayBuffer加密类
  const fileReader = new FileReader()// 文件读取类
  const action = option.action // 文件上传上传路径
  const chunkSize = 1024 * 1024 * 30 // 单个分片大小
  let md5 = ''// 文件的唯一标识
  const optionFile = option.file // 需要分片的文件
  let fileChunkedList = [] // 文件分片完成之后的数组
  const percentage = [] // 文件上传进度的数组,单项就是一个分片的进度

  // 文件开始分片,push到fileChunkedList数组中, 并用第一个分片去计算文件的md5
  for (let i = 0; i < optionFile.size; i = i + chunkSize) {
    const tmp = optionFile.slice(i, Math.min((i + chunkSize), optionFile.size))
    if (i === 0) {
      fileReader.readAsArrayBuffer(tmp)
    }
    fileChunkedList.push(tmp)
  }

  // 在文件读取完毕之后,开始计算文件md5,作为文件唯一标识
  fileReader.onload = async(e) => {
    spark.append(e.target.result)
    md5 = spark.end() + new Date().getTime()
    console.log('文件md5为--------', md5)
    // 将fileChunkedList转成FormData对象,并加入上传时需要的数据
    fileChunkedList = fileChunkedList.map((item, index) => {
      const formData = new FormData()
      if (option.data) {
        // 额外加入外面传入的data数据
        Object.keys(option.data).forEach(key => {
          formData.append(key, option.data[key])
        })
        // 这些字段看后端需要哪些,就传哪些,也可以自己追加额外参数
        formData.append(option.filename, item, option.file.name)// 文件
        formData.append('chunkNumber', index + 1)// 当前文件块
        formData.append('chunkSize', chunkSize)// 单个分块大小
        formData.append('currentChunkSize', item.size)// 当前分块大小
        formData.append('totalSize', optionFile.size)// 文件总大小
        formData.append('identifier', md5)// 文件标识
        formData.append('filename', option.file.name)// 文件名
        formData.append('totalChunks', fileChunkedList.length)// 总块数
      }
      return { formData: formData, index: index }
    })

    // 更新上传进度条百分比的方法
    const updataPercentage = (e) => {
      let loaded = 0// 当前已经上传文件的总大小
      percentage.forEach(item => {
        loaded += item
      })
      e.percent = loaded / optionFile.size * 100
      option.onProgress(e)
    }

    // 创建队列上传任务,limit是上传并发数
    function sendRequest(chunks, limit = 3) {
      return new Promise((resolve, reject) => {
        const len = chunks.length
        let counter = 0
        let isStop = false
        const start = async() => {
          if (isStop) {
            return
          }
          const item = chunks.shift()
          console.log()
          if (item) {
            const xhr = new XMLHttpRequest()
            const index = item.index
            // 分片上传失败回调
            xhr.onerror = function error(e) {
              isStop = true
              reject(e)
            }
            // 分片上传成功回调
            xhr.onload = function onload() {
              if (xhr.status < 200 || xhr.status >= 300) {
                isStop = true
                reject(getError(action, option, xhr))
              }
              if (counter === len - 1) {
                // 最后一个上传完成
                resolve()
              } else {
                counter++
                start()
              }
            }
            // 分片上传中回调
            if (xhr.upload) {
              xhr.upload.onprogress = function progress(e) {
                if (e.total > 0) {
                  e.percent = e.loaded / e.total * 100
                }
                percentage[index] = e.loaded
                console.log(index)
                updataPercentage(e)
              }
            }
            xhr.open('post', action, true)
            if (option.withCredentials && 'withCredentials' in xhr) {
              xhr.withCredentials = true
            }
            const headers = option.headers || {}
            for (const item in headers) {
              if (headers.hasOwnProperty(item) && headers[item] !== null) {
                xhr.setRequestHeader(item, headers[item])
              }
            }
            // 文件开始上传
            xhr.send(item.formData)
            //这里是把所有分片上传的xhr存到全局中,如果用户手动取消上传,或者上传出现错误,则要调用xhr.abort()把store中所有xhr的停止,不然文件还会继续上传
            store.commit('SET_CHUNKEDUPLOADXHR', xhr)
          }
        }
        while (limit > 0) {
          setTimeout(() => {
            start()
          }, Math.random() * 1000)
          limit -= 1
        }
      })
    }

    try {
      // 调用上传队列方法 等待所有文件上传完成
      await sendRequest(fileChunkedList, 3)
      // 这里的参数根据自己实际情况写
      const data = {
        identifier: md5,
        filename: option.file.name,
        totalSize: optionFile.size
      }
      // 给后端发送文件合并请求
      const fileInfo = await axios({
        method: 'post',
        url: '/api/oss/oss/mergeChunkFile',
        data: data
      })
      // 这个8200是我们oss存储成功的code,根据自己实际情况可以变
      if (fileInfo.data.code === 8200) {
        const success = getBody(fileInfo.request)
        option.onSuccess(success)
        return
      }
    } catch (error) {
      option.onError(error)
    }
  }
}

前端通过spark-md5.js计算本地文件md5

这里提供了两个方法;一种是用SparkMD5.hashBinary( ) 直接将整个文件的二进制码传入直接返回文件的md5、这种方法对于小文件会比较有优势——简单并且速度快。
另一种方法是利用js中File对象的slice( )方法(File.prototype.slice( ))将文件分片后逐个传入spark.appendBinary( )方法来计算、最后通过spark.end( )方法输出结果,很明显,这种方法对于大型文件会非常有利——不容易出错,并且能够提供计算的进度信息

第一种方式:

 var running = false;    //running用于判断是否正在计算md5
            function doNormalTest( input ) {    //这里假设直接将文件选择框的dom引用传入
                
                if (running) {    // 如果正在计算、不允许开始下一次计算
                    return;
                }
 
                var fileReader = new FileReader(),    //创建FileReader实例
                    time;
 
                fileReader.onload = function (e) {    //FileReader的load事件,当文件读取完毕时触发
                    running = false;
 
                    // e.target指向上面的fileReader实例
                    if (file.size != e.target.result.length) {    //如果两者不一致说明读取出错
                       alert("ERROR:Browser reported success but could not read the file until the end.");
                    } else {
                        console.log(Finished loading!success!!);
                         return SparkMD5.hashBinary(e.target.result);    //计算md5并返回结果
                         
                    }
                };
 
                fileReader.onerror = function () {    //如果读取文件出错,取消读取状态并弹框报错
                    running = false;
                    alert("ERROR:FileReader onerror was triggered, maybe the browser aborted due to high memory usage.");
                };
 
                running = true;
                fileReader.readAsBinaryString( input.files[0] );    //通过fileReader读取文件二进制码
            };

第二种方式

function doIncrementalTest( input ) {    //这里假设直接将文件选择框的dom引用传入
                if (running) {
                    return;
                }
 
                //这里需要用到File的slice( )方法,以下是兼容写法
                var blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice,
                    file = input.files[0],
                    chunkSize = 2097152,                           // 以每片2MB大小来逐次读取
                    chunks = Math.ceil(file.size / chunkSize),
                    currentChunk = 0,
                    spark = new SparkMD5(),    //创建SparkMD5的实例
                    time,
                    fileReader = new FileReader();
 
                fileReader.onload = function (e) {
 
                    console("Read chunk number (currentChunk + 1) of  chunks ");
 
                    spark.appendBinary(e.target.result);                 // append array buffer
                    currentChunk += 1;
 
                    if (currentChunk < chunks) {
                        loadNext();
                    } else {
                        running = false;
                        console.log("Finished loading!");
                        return spark.end();     // 完成计算,返回结果
                    }
                };
 
                fileReader.onerror = function () {
                    running = false;
                    console.log("something went wrong");
                };
 
                function loadNext() {
                    var start = currentChunk * chunkSize,
                        end = start + chunkSize >= file.size ? file.size : start + chunkSize;
 
                    fileReader.readAsBinaryString(blobSlice.call(file, start, end));
                }
 
                running = true;
                loadNext();
            }
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,042评论 6 490
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 89,996评论 2 384
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 156,674评论 0 345
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,340评论 1 283
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,404评论 5 384
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,749评论 1 289
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,902评论 3 405
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,662评论 0 266
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,110评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,451评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,577评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,258评论 4 328
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,848评论 3 312
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,726评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,952评论 1 264
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,271评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,452评论 2 348