切片上传
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:
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 会快很多。
可以参考以下代码:
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. 暂停上传
这里使用 axios.CancelToken 中断请求
在切片上传请求的配置中添加 cancelToken
// 暂停(继续)上传
async handlePause() {
this.upload = !this.upload
if (!this.upload) {
source.cancel('终止上传!')
source = CancelToken.source()
} else {
await this.getUploaded(this.hash)
this.uploadFileChunks(this.uploadedList)
}
},
这里要注意一个问题,当终止请求的时候,后端会断开,此时需要改进后端接口的代码
后端输出错误,但服务还没断开,可以继续发送请求
6. 恢复上传
首先发起请求获取已经上传的切片列表,然后进行过滤,得到未上传的切片并进行上传操作
// 获取已上传成功的切片列表
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…