大文件上传之切片上传、断点续传、秒传

2,716 阅读3分钟

切片上传

1. 问题分析

当我们在做文件上传功能的时候,如果上传的文件过大,可能会导所需要的时间够长,且失败后需要重新上传。如果我们将这个文件拆分,将一次性上传大文件拆分成多个上传小文件的请求,因为请求是可以并发的,每个请求的时间就会缩短,且如果某个请求失败,只需要重新发送这一次请求即可,无需从头开始,这样就可以解决大文件上传的问题了!因此我们需要前后端结合的方式解决这个问题。

2. 分割大文件

文件FIle对象是Blob对象的子类,Blob对象包含一个重要的方法slice,通过这个方法,我们就可以对二进制文件进行拆分:

    // 创建切片数组
    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
    },

3. 前端发送请求

    // 上传文件
    async uploadFile() {
      if (!this.file) return
      const fileChunkList = this.createFileChunk(this.file)
      this.hash = await this.calculateHash(fileChunkList)
      await this.verifyUpload(this.file.name, this.hash)
      if (!this.verifyData.shouldUpload) {
        this.uploaded = true
        console.log('上传完成')
        return
      }
      this.fileChunks = fileChunkList.map(({ file }, index) => ({
        chunk: file,
        hash: this.hash + '-' + index,
        percentage: 0,
      }))
      this.uploadFileChunks(this.fileChunks)
    },
    // 上传文件切片
    async uploadFileChunks(list) {
      if (list.length === 0) {
        await axios({
          method: 'get',
          url: 'http://localhost:3000/merge',
          params: {
            filename: this.file.name,
            hash: this.hash,
          },
        })
        console.log('上传完成')
        this.totalPercentage = 100
        return
      }
      let pool = [] //并发池
      let max = 3 //最大并发量
      let finish = 0 //完成的数量
      let failList = [] //失败的列表
      for (let i = 0; i < list.length; i++) {
        let item = list[i]
        let formData = new FormData()
        formData.append('filename', this.file.name)
        formData.append('hash', item.hash)
        formData.append('chunk', item.chunk)
        formData.append('fileHash', this.hash)
        let task = axios({
          method: 'post',
          url: 'http://localhost:3000/upload',
          data: formData,
          onUploadProgress: this.createProgressHandler(item),
          cancelToken: source.token, // 添加 cancelToken,用于后续取消请求发送
        })
        task
          .then(res => {
            console.log(res)
            let j = pool.findIndex(t => t === task)
            pool.splice(j)
          })
          .catch(err => {
            console.log(err)
            failList.push(item)
          })
          .finally(() => {
            finish++
            if (finish === list.length) {
              this.uploadFileChunks(failList)
            }
          })
        pool.push(task)
        if (pool.length === max) {
          //每当并发池跑完一个任务,就再塞入一个任务
          await Promise.race(pool)
        }
      }
    },

4. 限制请求个数

如果一个大文件切了成百上千来个切片,一次发几百个 http 请求,容易把浏览器搞崩溃。因此就需要控制并发,也就是限制请求个数。

我们可以把异步请求放在一个队列pool里,比如并发数max是4,就先同时发起4个请求,然后有请求结束了,再发起下一个请求即可。

5. 后端合并切片

在上传完切片后,前端通知服务器做合并切片操作:

//合并切片接口
server.get('/merge', async (req, res) => {
  const { filename, hash } = req.query
  const ext = extractExt(filename)
  try {
    let len = 0
    const bufferList = fs.readdirSync(`${STATIC_TEMPORARY}/chunkDir_${hash}`).map((item,index) => {
      const buffer = fs.readFileSync(`${STATIC_TEMPORARY}/chunkDir_${hash}/${hash}-${index}`)
      len += buffer.length
      return buffer
    })
    //合并文件
    const buffer = Buffer.concat(bufferList, len)
    const ws = fs.createWriteStream(`${STATIC_FILES}/${hash}${ext}`)
    ws.write(buffer)
    ws.close()
    res.send(`切片合并完成`)
  } catch (error) {
    console.error(error)
  }
})

断点续传

1. 为什么

当多个请求中有请求发送失败,例如出现网络故障、页面关闭等,我们得对失败的请求做处理,让它们重复发送。所以我们实现断点续传,已上传的部分跳过,只传未上传的部分。

2. 原理

断点续传的原理在于前端/服务端需要记住已上传的切片,这样下次上传就可以跳过之前已上传的部分,有两种方案实现记忆的功能:

  • 前端使用 localStorage 记录已上传的切片 hash
  • 服务端保存已上传的切片 hash,前端每次上传前向服务端获取已上传的切片

前端方案有一个缺陷,如果换了个浏览器就失去了记忆的效果,所以这里选后者

3. 生成hash

如果使用文件名 + 切片下标作为切片 hash,这样做一旦文件名修改就失去了效果,而事实上只要文件内容不变,hash 就不应该变化,所以应该根据文件内容生成 hash

这里用到一个库 spark-md5 ,它可以根据文件内容计算出文件的 hash 值。

另外考虑到如果上传一个超大文件,读取文件内容计算 hash 是非常耗费时间的,并且会引起 UI 的阻塞,导致页面假死状态,所以我们使用 web-worker 在 worker 线程计算 hash,这样用户仍可以在主界面正常交互。

由于实例化 web-worker 时,参数是一个 js 文件路径且不能跨域,所以我们单独创建一个 hash.js 文件放在 public 目录下,另外在 worker 中也是不允许访问 dom 的,但它提供了importScripts 函数用于导入外部脚本,通过它导入 spark-md5

image.png

spark-md5.min.js 文件可以去网上下载并导入项目目录中

hash.js

// 导入脚本
self.importScripts('/spark-md5.min.js')

// 生成文件 hash
self.onmessage = e => {
  const { fileChunkList } = e.data
  const spark = new self.SparkMD5.ArrayBuffer()
  let percentage = 0
  let count = 0
  const loadNext = index => {
    const reader = new FileReader()
    reader.readAsArrayBuffer(fileChunkList[index].file)
    reader.onload = e => {
      count++
      spark.append(e.target.result)
      if (count === fileChunkList.length) {
        self.postMessage({
          percentage: 100,
          hash: spark.end(),
        })
        self.close()
      } else {
        percentage += 100 / fileChunkList.length
        self.postMessage({
          percentage,
        })
        loadNext(count)
      }
    }
  }
  loadNext(0)
}

Upload.vue

在这个函数中处理计算 hash 值的进度条

    // 生成hash
    calculateHash(fileChunkList) {
      return new Promise(resolve => {
        // 添加 worker 属性
        this.worker = new Worker('/hash.js')
        this.worker.postMessage({ fileChunkList })
        this.worker.onmessage = e => {
          const { hash, percentage } = e.data
          this.hashPercentage = parseInt(percentage.toFixed(2))
          if (hash) {
            resolve(hash)
          }
        }
      })
    },

上传文件前生成所要上传文件的hash

    // 上传文件
    async uploadFile() {
      if (!this.file) return
      const fileChunkList = this.createFileChunk(this.file)
      this.hash = await this.calculateHash(fileChunkList)
      this.fileChunks = fileChunkList.map(({ file }, index) => ({
        chunk: file,
        hash: this.hash + '-' + index,
        percentage: 0,
      }))
      this.uploadFileChunks(this.fileChunks)
    },

4. 抽样 hash

如果计算全量 Hash 比较慢的话,还有一种方式就是计算抽样 Hash,减少计算的字节数可以大幅度减少耗时;

在前面我们是将大文件切片后,全量传入 spark-md5.min.js 中来根据文件的二进制内容计算文件的 hash 的。

我们可以这样优化: 文件切片以后,取第一个和最后一个切片全部内容,其他切片的取 首中尾 三个地方各2kb来计算 hash,这样一来计算文件 hash 会快很多。

image.png

可以参考以下代码:

sampling(file) {
      const OFFSET = Math.floor(2 * 1024 * 1024) // 取样范围 2M
      let index = OFFSET
      // 头尾全取,中间抽2字节
      const chunks = [{ file: file.slice(0, index) }]
      while (index < file.size) {
        if (index + OFFSET > file.size) {
          chunks.push({ file: file.slice(index) })
        } else {
          const CHUNK_OFFSET = 2
          chunks.push(
            { file: file.slice(index, index + 2) },
            { file: file.slice(index + OFFSET - CHUNK_OFFSET, index + OFFSET) },
          )
        }
        index += OFFSET
      }
      return chunks
    },

该函数对文件进行抽样切片分割,并返回一个切片数组用于计算hash值(该切片数组不用于上传文件,只用于计算hash值)

// 上传文件
    async uploadFile() {
      if (!this.file) return
      const fileChunkList = this.createFileChunk(this.file)
      let samplingList = this.sampling(this.file)

      // 计算抽样hash
      console.time()
      this.hash = await this.calculateHash(samplingList)
      console.timeEnd()
      console.log(this.hash)

      // 计算全量hash
      // console.time()
      // this.hash = await this.calculateHash(fileChunkList)
      // console.timeEnd()
      // console.log(this.hash)

      await this.verifyUpload(this.file.name, this.hash)
      if (!this.verifyData.shouldUpload) {
        this.uploaded = true
        console.log('上传完成')
        return
      }
      this.fileChunks = fileChunkList.map(({ file }, index) => ({
        chunk: file,
        hash: this.hash + '-' + index,
        percentage: 0,
      }))
      this.uploadFileChunks(this.fileChunks)
    },

5. 暂停上传

image.png

这里使用 axios.CancelToken 中断请求

image.png

在切片上传请求的配置中添加 cancelToken image.png

    // 暂停(继续)上传
    async handlePause() {
      this.upload = !this.upload
      if (!this.upload) {
        source.cancel('终止上传!')
        source = CancelToken.source()
      } else {
        await this.getUploaded(this.hash)
        this.uploadFileChunks(this.uploadedList)
      }
    },

QQ截图20230105194551.png

这里要注意一个问题,当终止请求的时候,后端会断开,此时需要改进后端接口的代码

image.png

后端输出错误,但服务还没断开,可以继续发送请求

image.png

6. 恢复上传

image.png

首先发起请求获取已经上传的切片列表,然后进行过滤,得到未上传的切片并进行上传操作

    // 获取已上传成功的切片列表
    async getUploaded(fileHash) {
      await axios({
        method: 'get',
        url: 'http://localhost:3000/getUploaded',
        params: {
          hash: fileHash,
        },
      }).then(res => {
        if (res.data.code === 2) {
          const arr = res.data.uploaded
          let list = []
          for (let i = 0; i < this.fileChunks.length; i++) {
            if (arr.indexOf(this.fileChunks[i].hash) === -1) {
              list.push(this.fileChunks[i])
            }
          }
          this.uploadedList = list
        }
      })
    },
    // 暂停(继续)上传
    async handlePause() {
      this.upload = !this.upload
      if (!this.upload) {
        source.cancel('终止上传!')
        source = CancelToken.source()
      } else {
        await this.getUploaded(this.hash)
        this.uploadFileChunks(this.uploadedList)
      }
    },

秒传

1. 是什么

所谓的文件秒传,即在服务端已经存在了上传的资源,所以当用户再次上传时会直接提示上传成功,秒传其实就是给用户看的障眼法,实质上根本没有上传

2. 怎么做

文件秒传需要依赖断点续传生成的 hash,即在上传前,先计算出文件 hash,并把 hash 发送给服务端进行验证,由于 hash 的唯一性,所以一旦服务端能找到 hash 相同的文件,则直接返回上传成功的信息即可

3. 前端

    // 校验文件是否已存在
    async verifyUpload(fileName, fileHash) {
      await axios({
        method: 'get',
        url: 'http://localhost:3000/verify',
        params: {
          filename: fileName,
          hash: fileHash,
        },
      }).then(res => {
        this.verifyData = res.data
      })
    },

在上传文件之前获取到hash值进行校验操作

    // 上传文件
    async uploadFile() {
      if (!this.file) return
      const fileChunkList = this.createFileChunk(this.file)
      this.hash = await this.calculateHash(fileChunkList)
      await this.verifyUpload(this.file.name, this.hash)
      if (!this.verifyData.shouldUpload) {
        console.log('上传完成')
        return
      }
      this.fileChunks = fileChunkList.map(({ file }, index) => ({
        chunk: file,
        hash: this.hash + '-' + index,
        percentage: 0,
      }))
      this.uploadFileChunks(this.fileChunks)
    },

4. 后端

 // 提取后缀名
const extractExt = filename => filename.slice(filename.lastIndexOf("."), filename.length)

// 校验切片
server.get('/verify', async (req, res) => {
	const { filename, hash } = req.query
	const ext = extractExt(filename)
	let dir = `${STATIC_FILES}/${hash}${ext}`
	try {
		if (fs.existsSync(dir)) {
			res.send({shouldUpload: false})
		} else {
			res.send({shouldUpload: true})
		}
	}catch (error) {
      console.error(error)
      res.status(500).send(`校验失败`)
    }
})

完整代码

1. 前端

<template>
  <div class="upload-container">
    <div class="wrap">
      <input type="file" @change="openFile" class="ipt" />
      <span style="font-size: 12px; color: #666">文件大小为:{{ fileSize }}</span>
      <el-progress :percentage="hashPercentage" :format="format"></el-progress>
      <el-progress
        v-if="!uploaded"
        :percentage="totalPercentage > 100 ? 100 : totalPercentage"
        :format="format"
      ></el-progress>
      <span v-else style="display: block; font-size: 15px; color: #999; text-align: center">文件上传成功</span>
      <div class="btn">
        <el-button type="primary" size="small" @click="uploadFile" :disabled="disabled">上传</el-button>
        <el-button type="warning" size="small" @click="handlePause">{{ upload ? '暂停' : '继续' }}</el-button>
      </div>
    </div>
  </div>
</template>

<script>
import axios from 'axios'
const SIZE = 5 * 1024 * 1024
const CancelToken = axios.CancelToken
let source = CancelToken.source()

export default {
  name: 'UploadUpload',
  data() {
    return {
      file: null, // 选择的文件
      fileChunks: [], // 切片数组
      worker: null,
      hash: null, // 所上传图片的hash值
      verifyData: null, // 后端校验返回的数据
      disabled: false, // 按钮是否禁用
      upload: true,
      hashPercentage: 0, // 计算hash值的进度条
      uploaded: false,
      totalPercentage: 0, // 文件上传的进度条
      uploadedList: [], // 已上传的切片列表
    }
  },
  methods: {
    // 格式化百分比
    format(percentage) {
      return percentage === 100 ? '已完成' : `${percentage}%`
    },
    // 打开文件
    openFile(e) {
      this.file = e.target.files[0]
    },
    // 上传文件切片
    async uploadFileChunks(list) {
      if (list.length === 0) {
        await axios({
          method: 'get',
          url: 'http://localhost:3000/merge',
          params: {
            filename: this.file.name,
            hash: this.hash,
          },
        })
        console.log('上传完成')
        this.totalPercentage = 100
        return
      }
      let pool = [] //并发池
      let max = 3 //最大并发量
      let finish = 0 //完成的数量
      let failList = [] //失败的列表
      for (let i = 0; i < list.length; i++) {
        let item = list[i]
        let formData = new FormData()
        formData.append('filename', this.file.name)
        formData.append('hash', item.hash)
        formData.append('chunk', item.chunk)
        formData.append('fileHash', this.hash)
        let task = axios({
          method: 'post',
          url: 'http://localhost:3000/upload',
          data: formData,
          onUploadProgress: this.createProgressHandler(item),
          cancelToken: source.token, // 添加 cancelToken,用于后续取消请求发送
        })
        task
          .then(res => {
            console.log(res)
            let j = pool.findIndex(t => t === task)
            pool.splice(j)
          })
          .catch(err => {
            console.log(err)
            failList.push(item)
          })
          .finally(() => {
            finish++
            if (finish === list.length) {
              this.uploadFileChunks(failList)
            }
          })
        pool.push(task)
        if (pool.length === max) {
          //每当并发池跑完一个任务,就再塞入一个任务
          await Promise.race(pool)
        }
      }
    },
    // 创建切片数组
    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
    },
    // 上传文件
    async uploadFile() {
      if (!this.file) return
      const fileChunkList = this.createFileChunk(this.file)
      this.hash = await this.calculateHash(fileChunkList)
      await this.verifyUpload(this.file.name, this.hash)
      if (!this.verifyData.shouldUpload) {
        this.uploaded = true
        console.log('上传完成')
        return
      }
      this.fileChunks = fileChunkList.map(({ file }, index) => ({
        chunk: file,
        hash: this.hash + '-' + index,
        percentage: 0,
      }))
      this.uploadFileChunks(this.fileChunks)
    },
    // 暂停上传
    async handlePause() {
      this.upload = !this.upload
      if (!this.upload) {
        source.cancel('终止上传!')
        source = CancelToken.source()
      } else {
        await this.getUploaded(this.hash)
        this.uploadFileChunks(this.uploadedList)
      }
    },
    // 生成hash
    calculateHash(fileChunkList) {
      return new Promise(resolve => {
        // 添加 worker 属性
        this.worker = new Worker('/hash.js')
        this.worker.postMessage({ fileChunkList })
        this.worker.onmessage = e => {
          const { hash, percentage } = e.data
          this.hashPercentage = parseInt(percentage.toFixed(2))
          if (hash) {
            resolve(hash)
          }
        }
      })
    },
    // 校验文件是否已存在
    async verifyUpload(fileName, fileHash) {
      await axios({
        method: 'get',
        url: 'http://localhost:3000/verify',
        params: {
          filename: fileName,
          hash: fileHash,
        },
      }).then(res => {
        this.verifyData = res.data
      })
    },
    // 获取已上传成功的切片列表
    async getUploaded(fileHash) {
      await axios({
        method: 'get',
        url: 'http://localhost:3000/getUploaded',
        params: {
          hash: fileHash,
        },
      }).then(res => {
        if (res.data.code === 2) {
          const arr = res.data.uploaded
          let list = []
          for (let i = 0; i < this.fileChunks.length; i++) {
            if (arr.indexOf(this.fileChunks[i].hash) === -1) {
              list.push(this.fileChunks[i])
            }
          }
          this.uploadedList = list
        }
      })
    },
    createProgressHandler(item) {
      return e => {
        item.percentage = parseInt(String((e.loaded / e.total) * 100))
      }
    },
  },
  computed: {
    // 文件大小
    fileSize() {
      if (this.file === null) {
        return ''
      } else {
        if (this.file.size > 100 * 1024 * 1024) {
          return (this.file.size / 1024 / 1024 / 1000).toFixed(2) + 'G'
        }
        return (this.file.size / 1024 / 1024).toFixed(2) + 'M'
      }
    },
    totalPercent() {
      if (this.fileChunks.length === 0) return 0
      const loaded = this.fileChunks.map(item => item.chunk.size * item.percentage).reduce((acc, cur) => acc + cur)
      return parseInt((loaded / this.file.size).toFixed(2))
    },
  },
  watch: {
    totalPercent(oldVal, newVal) {
      this.totalPercentage = newVal
    },
  },
}
</script>

<style lang="css">
.upload-container {
  display: flex;
  justify-content: center;
  align-items: center;
  width: 400px;
  height: 200px;
  margin: 100px;
  border: 1px #666 solid;
}
.wrap {
  width: 80%;
  height: 80%;
}
.ipt {
  margin-bottom: 10px;
  width: 100%;
}
.btn {
  display: flex;
  justify-content: space-between;
  align-items: center;
  width: 265px;
  height: 60px;
}
</style>

2. 后端

const express = require('express')
const multiparty = require('multiparty')
const fs = require('fs')
const path = require('path')
const { Buffer } = require('buffer')
const fse = require("fs-extra")

// 上传文件最终路径
const STATIC_FILES = path.join(__dirname, './static/files')
// 上传文件临时路径
const STATIC_TEMPORARY = path.join(__dirname, './static/temporary')

 // 提取后缀名
const extractExt = filename => filename.slice(filename.lastIndexOf("."), filename.length)

const server = express()

const cors = require('cors')
server.use(cors())
// 静态文件托管
server.use(express.static(path.join(__dirname, './dist')))
// 切片上传的接口
server.post('/upload', (req, res) => {
  const form = new multiparty.Form()
  form.parse(req, function (err, fields, files) {
	  if (err) {
            console.log('文件切片上传失败:', err);
            res.send({
                code: 0,
                message: '文件切片上传失败'
            });
            return;
        }
    try {
		let filename = fields.filename[0]
		let hash = fields.hash[0]
		let chunk = files.chunk[0]
		let fileHash = fields.fileHash[0]
		let dir = `${STATIC_TEMPORARY}/chunkDir_${fileHash}`
      if (!fs.existsSync(dir)) fs.mkdirSync(dir)
      const buffer = fs.readFileSync(chunk.path)
      const ws = fs.createWriteStream(`${dir}/${hash}`)
      ws.write(buffer)
      ws.close()
	  res.send({
		  code: 200,
		  message: `${filename}-${hash} 切片上传成功`
	  })
    } catch (error) {
      console.error(error)
	  res.send({
		  code: 500,
		  message: `${filename}-${hash} 切片上传失败`
	  })
    }
  })
})
//合并切片接口
server.get('/merge', async (req, res) => {
  const { filename, hash } = req.query
  const ext = extractExt(filename)
  try {
    let len = 0
    const bufferList = fs.readdirSync(`${STATIC_TEMPORARY}/chunkDir_${hash}`).map((item,index) => {
      const buffer = fs.readFileSync(`${STATIC_TEMPORARY}/chunkDir_${hash}/${hash}-${index}`)
      len += buffer.length
      return buffer
    })
    //合并文件
    const buffer = Buffer.concat(bufferList, len)
    const ws = fs.createWriteStream(`${STATIC_FILES}/${hash}${ext}`)
    ws.write(buffer)
    ws.close()
    res.send(`切片合并完成`)
  } catch (error) {
    console.error(error)
  }
})

// 校验切片
server.get('/verify', async (req, res) => {
	const { filename, hash } = req.query
	const ext = extractExt(filename)
	let dir = `${STATIC_FILES}/${hash}${ext}`
	try {
		if (fs.existsSync(dir)) {
			res.send({shouldUpload: false})
		} else {
			res.send({shouldUpload: true})
		}
	}catch (error) {
      console.error(error)
      res.status(500).send(`校验失败`)
    }
})

// 获取已上传成功的切片列表
server.get('/getUploaded', async (req, res) => {
	const { hash } = req.query
	let dir = `${STATIC_TEMPORARY}/chunkDir_${hash}`
	try {
		if (fs.existsSync(dir)) {
			// 目录存在,则说明文件之前有上传过一部分,但是没有完整上传成功
			// 读取之前已上传的所有切片文件名
			const uploaded = await fse.readdir(dir)
			res.send({
				code: 2,
				message: '该文件有部分上传数据',
				uploaded
			})
		} else {
			res.send({
				code: 0
			})
		}
	}catch (error) {
      console.error(error)
      res.status(500).send(`获取失败`)
    }
})

server.listen(3000, _ => {
  console.log('http://localhost:3000/')
})

该项目的 github 地址为:github.com/huang244/up…