断点续传的原理及实践

Posted by XuBaoshi on April 29, 2021

前言


断点续传的理解可以分为两部分:一部分是断点,一部分是续传。断点的由来是在上传过程中,将一个上传的文件分成了多个部分,同时进行多个部分一起的上传。

当某个时间点,任务被暂停了,此时上传暂停的位置就是断点了。续传就是当一个未完成的上传任务再次开始时,会从上次的断点继续传送。通过这种方式可以大大加快大文件的上传时间。

附加的功能: 如果当前的文件已经上传过了,则触发秒传操作。所谓秒传其实通俗的理解可以为不把文件上传到服务器上直接认为上传成功。

原理

分片


断点续传其实就是将大文件拆分成一个个的切片,然后借助 http 的可并发性, 同时上传多个切片。拆分离不开对文件的分割。
浏览器中 File 对象是特殊类型的 Blob ,通过 Blob.prototype.slice 方法可以将文件按照约定好的文件大小进行分片处理,为后面的长传使用。该 api 兼容性如下:

/img/upload/1.png

createFileChunk(file, size = SIZE) {
  const fileChunkList = [];
  let cur = 0;
  while (cur < file.size) {
    fileChunkList.push({ file: file.slice(cur, cur + size) });
    cur += size;
  }
  return fileChunkList;
}

image.png

MD5 计算过程


无论是秒传还是续传都需要让服务端知道上传的文件是否之前已经被上传过,因此前端需要为后端提供上传文件的唯一标识。以下为如果计算文件唯一标识的计算过程:

读取文件(FileReader)

FileReader.readAsArrayBuffer

image.png

let fileReader = new FileReader()

loadNext()

function loadNext() {
  let start = currentChunk * chunkSize
  let end =
    start + chunkSize >= fileInfo.size ? fileInfo.size : start + chunkSize
  fileReader.readAsArrayBuffer(blobSlice.call(file, start, end))
}

md5 加密


fileReader 一边读取内容一边对已读取的内容进行加密, md5 加密的三方库有很多,这里我们使用的是 SparkMD5

npm install --save spark-md5

示例:

var spark = new SparkMD5()
spark.append('Hi')
spark.append(' there')
var hexHash = spark.end() // hex hash

接上面 fileReader 代码:

let spark = new SparkMD5.ArrayBuffer()
let fileReader = new FileReader()

fileReader.onload = (e) => {
  spark.append(e.target.result)
  if (currentChunk < chunks) {
    currentChunk++
    loadNext()
  } else {
    let md5 = spark.end()
    console.log(
      `MD5计算完毕:${fileInfo.name} \nMD5:${md5} \n分片:${chunks} 大小:${
        fileInfo.size
      } 用时:${new Date().getTime() - time} ms`
    )
  }
}
fileReader.onerror = function () {
  // ...
}

loadNext()

function loadNext() {
  let start = currentChunk * chunkSize
  let end =
    start + chunkSize >= fileInfo.size ? fileInfo.size : start + chunkSize
  fileReader.readAsArrayBuffer(blobSlice.call(file, start, end))
}

上传、暂停、恢复

上传

文件切割完成后,文件标识生成后,遍历每个分片并发送分片信息,下面为上传的一个分片的请求详情,通过封装 FormData 对象,上传分片数据,其中包含文件大小、分片总数、当前分片数。

image.png

大致代码如下:

image.png


image.png

暂停

image.png

image.png


恢复


恢复其实就是续传, 点击恢复后请求后端后端, 后端会返回已上传的切片,如果已经上传过了就不再上传了。

image.png

下图中 uploadedList 即为后端返回的已上传的分片, 上例中通过 filter 方法过滤掉已经上传的分片。

Web Worker

Web Woker 简介


javascript 语言采用的是单线程模型,所有的任务只能在一个线程下完成。如果上传的文件的过大浏览器读取文件并计算 MD5 值的这个过程是很费性能的, 可能会造成浏览器卡顿影响体验效果。

Web Worker 的作用就是为 JavaScript 创建多线程的环境, 允许主线程创建 worker 并分配 woker 线程完成耗费计算性能的任务。主线程(负责如 ui 渲染等其他任务)都顺畅的进行,不对给用户产生卡顿的现象,提高用户体验。

Web Worker 限制:

  1. 同源限制
  2. DOM 限制
  3. 通信联系(只能通过消息完成)
  4. 脚本限制

    Worker 线程不能执行 alert()方法和 confirm()方法,但可以使用 XMLHttpRequest 对象发出 AJAX 请求。

  5. 文件限制

Worker 线程无法读取本地文件,即不能打开本机的文件系统(file://)

使用方法:

主线程采用 new 命令,调用 Worker()构造函数,新建一个 Worker 线程。

var worker = new Worker('work.js')


Worker()构造函数的参数是一个脚本文件,该文件就是 Worker 线程所要执行的任务。由于 Worker 不能读取本地文件,所以这个脚本必须来自网络。如果下载没有成功(比如 404 错误),Worker 就会默默地失败。

脚本文件与主线程通信示例如下:

self.importScripts('./spark-md5.min.js')

self.onmessage = (e) => {
  // 开始 MD5 处理
  loadNext()

  fileReader.onload = (e) => {
    spark.append(e.target.result)
    if (currentChunk < chunks) {
      currentChunk++
      // ...
      loadNext()
    } else {
      let md5 = spark.end()
      self.postMessage({
        isOk: true,
        percentage: 100,
        md5,
      })
      console.log(
        `MD5计算完毕:${fileInfo.name} \nMD5:${md5} \n分片:${chunks} 大小:${
          fileInfo.size
        } 用时:${new Date().getTime() - time} ms`
      )
    }
  }

  fileReader.onerror = function () {
    self.postMessage({
      isError: true,
    })
  }
}

完整示例


ps: 将生成文件唯一标识的方法单独抽取到一个文件中,供主文件通过 webwoker 引入

md.js:

self.importScripts('./spark-md5.min.js')

self.onmessage = (e) => {
  var fileInfo = e.data.fileInfo
  var chunkSize = e.data.chunkSize
  var file = e.data.file

  let fileReader = new FileReader()
  let time = new Date().getTime()
  let blobSlice =
    File.prototype.slice ||
    File.prototype.mozSlice ||
    File.prototype.webkitSlice
  let currentChunk = 0
  let chunks = Math.ceil(fileInfo.size / chunkSize)
  let spark = new self.SparkMD5.ArrayBuffer()

  loadNext()

  fileReader.onload = (e) => {
    spark.append(e.target.result)
    if (currentChunk < chunks) {
      currentChunk++
      var percentage = ((currentChunk / chunks) * 100).toFixed(0)
      self.postMessage({
        isOk: false,
        percentage,
      })
      loadNext()
    } else {
      let md5 = spark.end()
      self.postMessage({
        isOk: true,
        percentage: 100,
        md5,
      })
      console.log(
        `MD5计算完毕:${fileInfo.name} \nMD5:${md5} \n分片:${chunks} 大小:${
          fileInfo.size
        } 用时:${new Date().getTime() - time} ms`
      )
    }
  }
  fileReader.onerror = function () {
    self.postMessage({
      isError: true,
    })
  }
  function loadNext() {
    let start = currentChunk * chunkSize
    let end =
      start + chunkSize >= fileInfo.size ? fileInfo.size : start + chunkSize
    fileReader.readAsArrayBuffer(blobSlice.call(file, start, end))
  }
}


引用:

const { chunkSize } = this

const worker = new Worker(`${this.subPath}/static/largeUpload/md5.js`)
this.worker = worker

worker.postMessage({ fileInfo: uploadFile, chunkSize, file: uploadFile.file })
worker.onmessage = (e) => {
  // 计算错误
  // ...

  // 计算进度
  // ...

  // 计算完成
  if (e.data.isOk && e.data.percentage === 100) {
    this.md5percentage = 0
    this.isMD5Loading = false
    resolve({
      md5: e.data.md5,
    })
  }
}


上例使用的文件放置在 pulic/static 中 使用起来不够优雅, 可以使用 webpack 插件更加优雅的使用 webworker。

image.png

https://github.com/GoogleChromeLabs/worker-plugin

续传


因分片大小固定,分片数量也是固定的。当上传暂停时,文件的位置就是断点了。续传就是当一个未完成的上传任务再次开始时,会从上次的断点继续传送。
在上传的文件未被推送前,需要提前计算出该文件的 MD5(唯一标识),发送校验请求,如果该文件已经上传过但没有上传完成,则服务端返回已上传的分片标识。

image.png

image.png

秒传


秒传的关键在于要为服务端提供一个文件的唯一标识, 服务端根据这标识判断文件是否已经上传过并返回消息决定文件是否需要再次传递,如果文件不需要上传则直接提示上传成功不再上传,也就是所谓的妙传。唯一标识可以采用 MD5 的计算方法。

image.png

image.png

实践


在实际应用中我们使用了 simple-uploader.js

npm install simple-uploader.js

使用方法

1.官方

var uploader = new Uploader({
  target: '/api/photo/redeem-upload-token',
  query: { upload_token: 'my_token' },
})


如果想要选择文件或者拖拽文件的话,你可以通过如下两个 API 来指定哪些 DOM 节点:

uploader.assignBrowse(document.getElementById('browseButton'))
uploader.assignDrop(document.getElementById('dropTarget'))

2. element-ui upload

<template>
  <div>
    <el-upload
      :auto-upload="false"
      :file-list="fileList"
      :on-change="fileChange"
      :on-remove="removeFile"
      :disabled="isLoading"
      class="upload-c"
      drag
    >
      <!-- 省略 -->
    </el-upload>
    <el-button type="primary" size="small" @click="handleOk"> 确 定 </el-button>
  </div>
</template>
<script>
export default {
  // ...
  methods: {
    // ...
    // 确认操作
    handleOk() {
      const { fileList } = this
      // 文件是否存在
      if (!(fileList && fileList.length > 0)) {
        this.$message({
          type: 'error',
          message: '请上传文件',
        })
        return
      }
      this.upload(fileList[0])
    },
    // 触发上传操作
    upload(file) {
      const { chunkSize } = this
      const uploader = new Uploader({
        // 上传 url
        target: this.g_Config.BASE_URL + '/file/upload',
        // 携带 cookie 信息
        withCredentials: true,
        // 分片大小
        chunkSize,
        // 是否开始分片测试开关
        testChunks: true,
        // 单文件
        singleFile: true,
        // 并发数
        simultaneousUploads: 3,
        // 校验文件上传进度
        checkChunkUploadedByResponse: function (chunk, message) {
          var objMessage = {}
          try {
            objMessage = JSON.parse(message)
          } catch (e) {}
          if (objMessage.result && objMessage.result.skipUpload === true) {
            return true
          }
          return (
            (objMessage.result.uploaded || []).indexOf(chunk.offset + 1) >= 0
          )
        },
      })

      // 添加附件
      uploader.addFile(file.raw)
      this.uploader = uploader

      // 计算文件 md5 值
      this.computeMD5(uploadFile).then((res) => {
        if (res === false || !res.md5) {
          this.isDisabled = false
          this.isLoading = false
          this.$message({
            type: 'error',
            message: `文件${uploadFile.name}读取出错,请检查该文件`,
          })
        } else {
          const md5 = res.md5
          this.computeMD5Success(md5, uploadFile)
        }
      })
      // ...
    },
    computeMD5(uploadFile) {
      const { chunkSize } = this
      this.isMD5Loading = true
      return new Promise((resolve) => {
        const worker = new Worker(`${this.subPath}/static/largeUpload/md5.js`)
        this.worker = worker

        worker.postMessage({
          fileInfo: uploadFile,
          chunkSize,
          file: uploadFile.file,
        })
        worker.onmessage = (e) => {
          // 计算错误
          // ...

          // 计算进度
          // ...

          // 计算完成
          if (e.data.isOk && e.data.percentage === 100) {
            this.md5percentage = 0
            this.isMD5Loading = false
            resolve({
              md5: e.data.md5,
            })
          }
        }
      })
    },
    // md5 计算成功
    computeMD5Success(md5, file) {
      // uniqueIdentifier(文件唯一标识) 后端用来识别续传、秒传操作
      file.uniqueIdentifier = md5

      // 注册进度条监听事件
      // ...

      // 调用 simple-uploader.js 触发上传操作
      this.uploader.upload()
    },
  },
}
</script>


其中秒传的关键在于文件的 md5 值是否计算成功

testChunks 选项表示是否开启服务器分片校验,设置为 true 后在每次上传过程的最开始,simple-uploader.js 会发送一个 get 请求,来问服务器哪些分片已经上传过了。同时在代码中由 options 中的checkChunkUploadedByResponse 控制,会根据 XHR 响应内容检测每个块是否上传成功了,成功的分片直接跳过上传,跳过的情况下返回 true 即可。

image.png
image.png

实例监听

// 文件添加 单个文件
uploader.on('fileAdded', function (file, event) {
  console.log(file, event)
})
// 单个文件上传成功
uploader.on('fileSuccess', function (rootFile, file, message) {
  console.log(rootFile, file, message)
})
// 根下的单个文件(文件夹)上传完成
uploader.on('fileComplete', function (rootFile) {
  console.log(rootFile)
})
// 某个文件上传失败了
uploader.on('fileError', function (rootFile, file, message) {
  console.log(rootFile, file, message)
})

参数详解


https://www.wenjiangs.com/article/simple-uploader-js.html