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


# 大文件分片上传和断点续传

# 场景描述

文件上传是一个很常见的需求,在文件相对比较小的情况下,可以直接把文件转化为字节流上传到服务器,但在文件比较大的情况下,用普通的方式进行上传就不是一个好的办法了。毕竟很少有人能忍受,当文件上传到一半中断后,继续上传却只能重头开始上传,这种体验很不友好。

本文将从零搭建前端和服务端,实现一个大文件分片上传和断点续传的小案例。

# 整体思路

# 前端

# 大文件上传

  • 将大文件转换成二进制流的格式
  • 利用流可以切割的属性,将二进制流切割成多份
  • 借助 http 的可并发性,同时上传多个切片(比起传一个大文件可以减少上传时间)
  • 等监听到所有请求都成功发出去以后,再给服务端发出一个合并的信号

# 断点续传

  • 为每一个文件切割块添加不同的标识
  • 当上传成功的之后,记录上传成功的标识
  • 当我们暂停或者发送失败后,可以重新发送没有上传成功的切割文件

# 后端

  • 接收每一个切割文件,并在接收成功后,存到指定位置,并告诉前端接收成功
  • 收到合并信号,将所有的切割文件排序、合并,生成最终的大文件,然后删除切割小文件,并告知前端大文件的地址

# 前端代码

前端使用 Vue + ElementUI,代码比较清晰,虽然原生也可以,但要多写很多代码。

# 上传控件

首先创建上传控件和进度条控件,因为要自定义一个上传的实现,所以 el-upload 组件的 auto-upload 要设定为 falseaction 为必选参数,此处可以不填值。

提示

ElementUI 的上传组件,默认是基于文件流的:

  • 数据格式:form-data
  • 传递的数据: file 文件流信息;filename 文件名字

代码如下:

<template>
  <div id="app">
    <div class="file-upload">
      <!-- 上传组件 -->
      <el-upload action="#" :auto-upload="false" :show-file-list="false" :on-change="handleChange">
        <el-button slot="trigger" size="small" type="primary">选取文件</el-button>
        <el-button style="margin-left: 10px;" size="small" type="success" @click="handleUpload">上传到服务器</el-button>
        <div slot="tip" class="el-upload__tip" v-if="file">待上传文件:{{ file.name }}</div>
      </el-upload>
      <!-- 进度显示 -->
      <div class="progress-box">
        <span>上传进度:{{ percent.toFixed() }}%</span>
        <el-button type="primary" size="mini" @click="handleClickBtn">{{ upload | btnTextFilter}}</el-button>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: 'App',
  filters: {
    btnTextFilter(val) {
      return val ? '暂停' : '继续'
    }
  },
  data() {
    return {
      file: null,
      chunkList: [],
      hash: '',
      percentCount: 0,
      percent: 0,
      upload: true
    }
  },
  methods: {
    // 提交文件后触发
    handleChange(file) {
      Object.assign(this.$data, this.$options.data()) // 将 data 重置为初始状态
      this.file = file
    },
    // 点击上传按钮后触发
    async handleUpload() {
    },
    // 将 File 对象转为 ArrayBuffer
    fileToBuffer() {
    },
    // 生成文件切片
    createChunks() {
    },
    // 上传文件切片
    uploadChunks() {
    },
    // 发送合并指令
    mergeUpload() {
    },
    // 按下暂停按钮
    handleClickBtn() {
    }
  }
}
</script>

<style>
.file-upload {
  margin-top: 50px;
  margin-left: 50px;
}

.progress-box {
  box-sizing: border-box;
  width: 360px;
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-top: 10px;
  padding: 8px 10px;
  background-color: #ecf5ff;
  font-size: 14px;
  border-radius: 4px;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83

# 转二进制

转成 ArrayBuffer 是因为后面要用 SparkMD5 这个库生成 hash 值,对文件进行命名。

JS 常见的二进制格式有 Blob,ArrayBuffer 和 Buffer,如果对二进制流不了解,可以查看这篇文章 (opens new window)

这里采用 ArrayBuffer,并且因为解析过程可能会比较久,所以我们采用 promise 异步处理的方式。

代码如下:

// 在 ElementUI 中, 自带方法中的 file 并不是 File 对象
// 要获取 File 对象需要通过 file.raw, 以下所有的 fileObj = file.raw
fileToBuffer(fileObj) {
  return new Promise((resolve, reject) => {
    const fileReader = new FileReader()
    fileReader.onload = event => {
      resolve(event.target.result)
    }
    fileReader.readAsArrayBuffer(fileObj)
    fileReader.onerror = () => {
      reject(new Error('转换文件格式发生错误'))
    }
  })
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 创建切片

接下来将大文件按固定大小(10M)进行切片,就像操作数组一样(注意此处同时声明了多个常量)。

我们在拆分切片大文件的时候,还要考虑大文件的合并,所以我们的拆分必须有规律,比如 1_11_21_31_5 这样的,到时候服务端拿到切片数据,当接收到合并信号的时候,就可以将这些切片排序合并了。

同时,为了避免同一个文件(改名字)多次上传,我们引入了 spark-md5,它能根据具体文件内容,生成 hash 值。

这么一来,为每一个切片命名的时候,也就成了 hash_1hash_2 这种形式。

切割文件用到的是 Blob.slice() (opens new window)

代码如下:

const SIZE = 10 * 1024 * 1024 // 切片大小

createChunks(buffer, fileObj, chunkSize = SIZE) {
  // 声明几个变量, 后面切分文件要用
  const chunkList = [] // 保存所有切片的数组
  const chunkListLength = Math.ceil(fileObj.size / chunkSize) // 计算总共多个切片
  const suffix = /\.([0-9A-z]+)$/.exec(fileObj.name)[1] // 文件后缀名(文件格式)

  // 根据文件内容生成 hash 值
  const spark = new SparkMD5.ArrayBuffer()
  spark.append(buffer)
  const hash = spark.end()
  
  // 生成切片, 这里后端要求传递的参数为字节数据块(chunk)和每个数据块的文件名(fileName)
  let cur = 0 // 切片时的初始位置
  for (let i = 0; i < chunkListLength; i++) {
    const item = {
      chunk: fileObj.slice(cur, cur + chunkSize),
      fileName: `${hash}_${i}.${suffix}` // 文件名规则按照 hash_1.jpg 命名
    }
    cur += chunkSize
    chunkList.push(item)
  }
  console.log('切片完后的数组:', chunkList)
  this.chunkList = chunkList // uploadChunks 要用到
  this.hash = hash           // uploadChunks 要用到
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

提示

分割大文件的时候,一般可以采用「定切片数量」和「定切片大小」两种方式。

为了避免由于 JS 使用的 IEEE754 二进制浮点数算术标准可能导致的误差,这里采用定切片大小的方式,规定每个切片 10MB,也就是说 100 MB 的文件会被分成 10 个切片。

# 发送请求

上传切片的请求可以是并行的或是串行的,这里选择串行发送。每个切片都新建一个请求,为了后面能实现断点续传,将请求封装到函数 fn 里,用一个数组 requestList 来保存请求集合,然后封装一个 send 函数,用于请求发送,这样一旦按下暂停键,可以方便的终止上传。

切片发送完成后,何时合并它们呢,一般有两种思路:

  • 前端在每个切片中都携带切片最大数量的信息,当服务端接受到这个数量的切片时自动合并
  • 前端额外发一个请求,主动通知服务端进行合并,服务端接受到这个请求时主动合并切片

这里采用第二种方式,即前端主动通知服务端进行合并。为此需要再发送一个 get 请求并把文件的 hash 值传给服务器,我们定义一个 complete 方法来实现。

代码如下:

const BaseUrl = 'http://localhost:5000'

uploadChunks() {
  const requestList = [] // 请求集合
  this.chunkList.forEach((item, index) => {
    const fn = () => {
      const formData = new FormData()
      formData.append('hash', this.hash)
      formData.append('chunk', item.chunk)
      formData.append('filename', item.fileName)
      return axios({
        url: BaseUrl + '/api/upload/',
        method: 'post',
        headers: { 'Content-Type': 'multipart/form-data' },
        data: formData
      }).then(res => {
        if (res.data.code === 0) { // 成功
          if (this.percentCount === 0) { // 避免上传成功后会删除切片改变 chunkList 的长度影响到 percentCount 的值
            this.percentCount = 100 / this.chunkList.length
          }
          this.percent += this.percentCount // 改变进度
          this.chunkList.splice(index, 1)   // 一旦上传成功就删除这一个 chunk, 方便断点续传
        }
      }).catch(error => {
        console.log('上传失败:', error)
        this.$message.error('上传失败,请检查服务端是否正常')
      })
    }
    requestList.push(fn)
  })

  let i = 0 // 记录发送的请求个数
  // 文件切片全部发送完毕后, 需要请求 merge 接口, 把文件的 hash 传递给服务器
  const complete = () => {
    axios({
      url: BaseUrl + '/api/merge/',
      method: 'post',
      data: { hash: this.hash, filename: this.file.name, size: this.chunkList.length}
    }).then(res => {
      if (res.data.code === 0) { // 请求发送成功
        this.$message({
          message: res.data.message,
          type: 'success'
        })
      } else {
        this.$message({
          message: res.data.message,
          type: 'error'
        })
      }
    })
  }
  const send = async () => {
    if (i >= requestList.length) {
      // 全部发送完毕
      complete()
      return
    }
    await requestList[i]()
    i++
    send()
  }
  send() // 发送请求
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64

提示

这里需要注意的就是,我们发出去的数据采用的是 FormData 数据格式。(为什么 (opens new window)

# 断点续传

暂停按钮文字的处理,用了一个过滤器,如果 upload 值为 true 则显示「暂停」,否则显示「继续」:

filters: {
  btnTextFilter(val) {
    return val ? '暂停' : '继续'
  }
}
1
2
3
4
5

当按下暂停按钮,触发 handleClickBtn() 方法:

handleClickBtn() {
  this.upload = !this.upload
  // 如果不暂停则继续上传
  if (this.upload) this.uploadChunks()
}
1
2
3
4
5

同时需要在 send() 方法里增加判断是否暂停的逻辑:只要 upload 这个变量为 false 就不会继续上传了。

为了在暂停完后可以继续发送,需要在每次成功发送一个切片后将这个切片从 chunkList 数组里删除 this.chunkList.splice(index, 1)(所以前面在写上传接口的时候有了这么一行代码)。

代码中增加一行如下:


 










const send = async () => {
  if (!this.upload) return
  if (i >= requestList.length) {
    // 全部发送完毕
    complete()
    return
  }
  await requestList[i]()
  i++
  send()
}
1
2
3
4
5
6
7
8
9
10
11

# 后端代码

简单使用 http 模块搭建服务端,主要实现两个接口的处理逻辑:

  • 上传切片(/api/upload/
  • 合并切片(/api/merge/

代码比较简单,主要是一些第三方和内置模块的使用,关键的地方加了一些注释,参见 file-upload/backend/ (opens new window)

# 问题总结

当前的例子,基于前端大文件分片上传和断点续传的场景,总结了实现思路,并用代码进行了简单的实现。

如果是在复杂的生产环境中,可能会有更多的问题需要考虑,下面是我能想到的一些以及思路:

  • 断网(或者电脑重启)后,再次选择文件,如何续传?

思路:前端把已经上传的信息存在 Local Storage 里,或者向后端请求接口去获得,更偏向于让后端来存这个信息。

  • 基于上面的问题,如何判别新的上次文件,是新建上传还是续传文件?

思路:根据 SparkMD5 生成的 hash 来判断(这个 hash 值是依据文件内容来的)。

  • 多人同时上传同一文件冲突、换电脑之后再次上传同一文件处理。

思路:多人上传可以考虑用用户 token 来区分,或者从生成浏览器唯一 id 的思路出发,id 结合文件 hash 来标识这个文件。

  • 更大的文件(比如 100G)上传时,计算 MD5 切片时会遇到资源不够用的问题,浏览器会卡死。

JS 单线程逻辑异步的效果不会很明显,整个过程(计算 Md5 - 获取切片 - 上传切片 - 文件合并)可以用 worker api,开多线程调用 CPU 另外的核去做,主线程只负责接收 Message,这样性能和体验应该会好很多。但因为 V8 对内存的限制,并没有完美的解决方案。

  • 多文件上传的优化思路

大文件用 worker 切片保证线程不卡,但多个大文件内存肯定不够用,所以只能尽可能优化。

# 不错的项目

实际业务中,可能对大文件上传有更细化的需求,并且需要兼容和考虑很多种情况,因此可以借鉴现成的轮子,经过调研,我发现了几个不错的项目:

(完)