跳到主要内容

分片上传组件

基于elementUI的el-upload封装文件分片上传组件


image.png

1. 概念

文件分片的核心是利用Blod.prototype.slice方法,将文件切分为一个个的切片,借助 http 的可并发性,同时上传多个切片

2. 实现

分片组件是基于elementUI的el-upload组件上的二次封装

<template>
<div>
<el-upload class="uploadComponent" ref="uploadFile" :drag="!disabled"
v-bind="$attrs" :on-change="handleUpload" :disabled="disabled"
>
<i class="el-icon-upload" v-show="!disabled"></i>
</el-upload>
</div>
</template>

通过属性透传v-bind=“$attrs”实现绑定在组件上的属性传递给el-upload组件,因此可以在组件上绑定el-upload组件已实现的属性

<file-chunks-upload-component 
:disabled="!isEdit" ref="videoUpload" :file-list="videoFileList"
action="" :auto-upload="false" list-type="fileList"
accept=".mp4, .avi, .wmv, .rmvb, .rm" validType="video"
validTypeTip="上传文件只能是mp4, avi, wmv, rmvb格式"
:getFileChunks="handleUpload" :sliceSize="100 * 1024 * 1024"
:on-preview="handleVideoPreview" :before-remove="handleVideoRemove"
/>

组件除了接收el-upload组件的属性,还额外接收几个属性:

  • disabled:设置组件是否可编辑
  • validType:文件校验类型,单个检验类型的话可传入字符串,如’video’、’image’,多个检验类型的话可传入数组,如[‘video’, ‘image’]
  • validTypeTip:文件检验不通过时的提示文本
  • chunkSize:每个分片大小
  • getFileChunks:获取分片信息
  • sliceSize:启动文件分片的临界点

在el-upload组件上绑定on-change属性监听文件变化,拿到文件信息,进行一系列的处理

处理过程:

  1. 判断文件大小是否大于sliceSize,是,则继续实现分片处理,否,则返回文件
// 文件大小小于sliceSize时,不进行分片,直接返回文件
if(this.sliceSize && file.size <= this.sliceSize){
this.getFileChunks && this.getFileChunks({file})
return
}
  1. 检验文件类型 通过文件头信息方式检验文件类型,封装成一个工具方法
/***
* 通过文件头信息方式检验文件类型
* https://blog.csdn.net/tjcwt2011/article/details/120333846?utm_medium=distribute.pc_relevant.none-task-blog-2%7Edefault%7ECTRLIST%7Edefault-3.no_search_link&depth_1-utm_source=distribute.pc_relevant.none-task-blog-2%7Edefault%7ECTRLIST%7Edefault-3.no_search_link
*/

const fileType = Object.assign(Object.create(null), {
'isMP4': str => ['00 00 00 14', '00 00 00 18', '00 00 00 1c', '00 00 00 20'].some(s => str.indexOf(s) === 0),
'isAVI': str => str.indexOf('52 49 46 46') === 0,
'isWMV': str => str.indexOf('30 26 b2 75') === 0,
'isRMVB': str => str.indexOf('2e 52 4d 46') === 0,
'isJPG': str => str.indexOf('ff d8 ff') === 0,
'isPNG': str => str.indexOf('89 50 4e 47') === 0,
'isGIF': str => str.indexOf('47 49 46 38') === 0
})

const validFileType = Object.assign(Object.create(null), {
'video': str => fileType['isMP4'](str) || fileType['isAVI'](str) || fileType['isWMV'](str) || fileType['isRMVB'](str),
'image': str => fileType['isJPG'](str) || fileType['isPNG'](str) || fileType['isGIF'](str)
})

const bufferToString = async buffer => {
let str = [...new Uint8Array(buffer)].map(b => b.toString(16).toLowerCase().padStart(2, '0')).join(' ')
return str
}

const readBuffer = (file, start = 0, end = 4) => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
resolve(reader.result)
}
reader.onerror = reject
reader.readAsArrayBuffer(file.slice(start, end))
})
}

const validator = async (type, file) => {
const buffer = await readBuffer(file)
const str = await bufferToString(buffer)

switch(Object.prototype.toString.call(type)){
case '[object String]':
return validFileType[type](str)
case '[object Array]':
return [...type].some(t => validFileType[t](str))
}
}

export default validator

注:各种文件类型的文件头信息可从以下链接查询 文件信息头对照表

  1. 生成切片 利用blob.prototype.slice将大文件切分成一个一个的小片段
createFileChunk(file){
let chunks = []
let cur = 0
if(file.size > this.chunkSize){
while(cur < file.size){
chunks.push({ file: file.slice(cur, cur + this.chunkSize) })
cur += this.chunkSize
}
}else{
chunks.push({ file: file })
}
return chunks
},
  1. 生成hash 为了实现断点续传、秒传的效果,需要前端生成一个唯一标识提供给后端,由于唯一标识必须保持不变,所以正确的做法是根据文件内容生成hash。这里需要用到一个库spark-md5,它可以根据文件内容计算出文件的hash值。

由于计算hash是非常耗费时间的,并且会引起 UI 的阻塞,所有这里使用web-worker在worker线程计算hash。由于实例化 web-worker 时,函数参数 URL 为指定的脚本且不能跨域,所以我们单独创建一个 hash.js 文件放在 public 目录下,然后通过importScripts 函数用于导入 spark-md5

hash.js

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

self.onmessage = e=>{
// 接受主线程传递的数据
let { chunks } = e.data
const spark = new self.SparkMD5.ArrayBuffer()

let count = 0

const loadNext = index=>{
const reader = new FileReader()
reader.readAsArrayBuffer(chunks[index].file)
reader.onload = e=>{
count ++
spark.append(e.target.result)

if(count==chunks.length){
self.postMessage({
hash:spark.end()
})
}else{
loadNext(count)
}
}
}
loadNext(0)
}

分片越多,分片越大,计算hash所耗费的时间就越久,为优化计算速度,在损失一定精度的情况下,采用抽样的方式计算hash

这里分了两种情况

1.文件大小小于50M时,对所有的分片计算hash

2.文件大小大于50M时,以偶数位的方式抽取分片,并提取每个分片10M的内容去计算hash

计算hash方法

 async calculateHashWorker(chunks, size){
var _this = this;
// 文件大小超过50M时,使用抽样方式生成hash
const MAX_FILE_SIZE = 50 * 1024 * 1024
const SAMPLE_SIZE = 10 * 1024 * 1024
if(size >= MAX_FILE_SIZE){
chunks = chunks.map((item, index) => {
if(index % 2 == 0){
return { file: item.file.slice(0, SAMPLE_SIZE)}
}
}).filter(item => item)
}

return new Promise(resolve => {
_this.worker = new Worker('/hash.js')
_this.worker.postMessage({ chunks })
_this.worker.onmessage = e => {
const { hash } = e.data
if (hash) {
resolve(hash)
}
}
})
},
  1. 返回文件切片信息 通过在组件上绑定getFileChunks属性获取到切片集合、hash值以及文件信息
async handleUpload(params){
if(!params.raw) return
let file = params.raw
// 文件大小小于sliceSize时,不进行分片,直接返回文件
if(this.sliceSize && file.size <= this.sliceSize){
this.getFileChunks && this.getFileChunks({file})
return
}
// 校验文件类型
if(this.validType && !await validator(this.validType, file)){
this.$message({
type: 'warning',
message: this.validTypeTip,
duration: 3000,
})
this.clearUploadFiles()
return
}

const loading = this.$loading({
lock: true,
text: '生成切片中,请耐心等候'
})

let chunks = this.createFileChunk(file)
let hash = await this.calculateHashWorker(chunks, file.size)

this.getFileChunks && this.getFileChunks({chunks, hash, file})

loading.close()
},

分片上传组件完整代码

<template>
<div>
<el-upload class="uploadComponent" ref="uploadFile" :drag="!disabled"
v-bind="$attrs" :on-change="handleUpload" :disabled="disabled"
>
<i class="el-icon-upload" v-show="!disabled"></i>
</el-upload>
</div>
</template>

<script>
import validator from '@/utils/fileTypeValidate'

export default {
name: 'FileChunksUploadComponent',
props: {
disabled: { // 是否可编辑
type: Boolean,
default: false
},
validType: { // 文件校验类型
type: String | Array,
default(){
return ''
}
},
validTypeTip: { // 文件检验提示
type: String,
default: '上传文件格式不正确!'
},
chunkSize: { // 分片大小
type: Number,
default: 50 * 1024 * 1024
},
getFileChunks: { // 获取分片信息方法
type: Function,
default: null
},
sliceSize: { // 文件实现分片的临界大小
type: Number,
default: 0
}
},
methods: {
async handleUpload(params){
if(!params.raw) return
let file = params.raw
// 文件大小小于sliceSize时,不进行分片,直接返回文件
if(this.sliceSize && file.size <= this.sliceSize){
this.getFileChunks && this.getFileChunks({file})
return
}
// 校验文件类型
if(this.validType && !await validator(this.validType, file)){
this.$message({
type: 'warning',
message: this.validTypeTip,
duration: 3000,
})
this.clearUploadFiles()
return
}

const loading = this.$loading({
lock: true,
text: '生成切片中,请耐心等候'
})

let chunks = this.createFileChunk(file)
let hash = await this.calculateHashWorker(chunks, file.size)

this.getFileChunks && this.getFileChunks({chunks, hash, file})

loading.close()
},
createFileChunk(file){
let chunks = []
let cur = 0
if(file.size > this.chunkSize){
while(cur < file.size){
chunks.push({ file: file.slice(cur, cur + this.chunkSize) })
cur += this.chunkSize
}
}else{
chunks.push({ file: file })
}
return chunks
},
async calculateHashWorker(chunks, size){
var _this = this;
// 文件大小超过50M时,使用抽样方式生成hash
const MAX_FILE_SIZE = 50 * 1024 * 1024
const SAMPLE_SIZE = 10 * 1024 * 1024
if(size >= MAX_FILE_SIZE){
chunks = chunks.map((item, index) => {
if(index % 2 == 0){
return { file: item.file.slice(0, SAMPLE_SIZE)}
}
}).filter(item => item)
}

return new Promise(resolve => {
_this.worker = new Worker('/hash.js')
_this.worker.postMessage({ chunks })
_this.worker.onmessage = e => {
const { hash } = e.data
if (hash) {
resolve(hash)
}
}
})
},
clearUploadFiles(){
this.$refs.uploadFile.clearFiles()
}
}
}
</script>

3. 实战篇

为了实现大文件分片上传功能,需后台提供三个接口,包括分片检查接口、分片上传接口、分片合并接口。

  • 分片检查接口:根据组件生成的hash值去检测是否有分片已上传过,文件是否已上传,实现中断续传,秒传的效果,避免文件重复上传。
  • 分片上传接口:上传未上传过的分片
  • 分片合并接口:文件全部分片上传完成,告知后台将分片合并成文件

分片合并过程是需要耗费一定的时间的,对于小文件来说,分片上传反而会增加上传时间,因此这里做了控制,在组件上绑定了sliceSize属性,控制文件小于100M时,组件只返回文件,直接调用文件上传接口。

image.png

3.1 分片上传流程

  1. 调用分片检查接口,判断文件是否已上传或部分分片已上传

image.png 2. 过滤掉已上传的分片,调用分片上传接口

image.png 3. 所有分片上传完毕后,调用分片合并接口合并文件

image.png

完整代码

<file-chunks-upload-component 
:disabled="!isEdit" ref="videoUpload" :file-list="videoFileList"
action="" :auto-upload="false" list-type="fileList"
accept=".mp4, .avi, .wmv, .rmvb, .rm" validType="video"
validTypeTip="上传文件只能是mp4, avi, wmv, rmvb格式"
:getFileChunks="handleUpload" :sliceSize="100 * 1024 * 1024"
:on-preview="handleVideoPreview" :before-remove="handleVideoRemove"
/>
async handleUpload({chunks, hash, file}){
this.loading = true
this.loadingText = '上传中,请耐心等候'
try{
let res = null
if(chunks && hash){
res = await this.sliceChunksUpload(chunks, hash, file)
}else{
res = await this.videoFileUpload(file)
}
if(res){
this.videoFileList.push({name: res, url: res})
this.$messageInfo('上传成功')
}
}catch(err){
this.$messageWarn('上传失败')
}

this.$refs.videoUpload.clearUploadFiles()
this.loading = false
this.loadingText = ''
},
// 分片视频文件上传
sliceChunksUpload(chunks, hash, file){
return new Promise(async (resolve, reject) => {
chunks = chunks.map((item, index) => {
return {
file: item.file,
name: `${index}_${hash}`,
index,
hash
}
})
try{
let {data} = await sewingCraftApi.checkChunkUpload({taskId: hash})
if(data && data.completeUpload){
resolve(data.uploadUrl)
return
}

if(data.map){
chunks = chunks.filter(({file, name}) => {
return !data.map[name] || data.map[name] != file.size
})
}

if(chunks.length){
let uploadRes = await httpUtil.multiRequest(chunks, uploadRequest)
let flag = uploadRes.every(res => res.data)
if(!flag){
reject('上传失败!')
return
}
}

let extName = file.name.substring(file.name.lastIndexOf('.') + 1)
sewingCraftApi.mergeChunk({taskId: hash, extName}).then(res => {
resolve(res.data)
})
}catch(err){
reject(err)
}

function uploadRequest({hash, index, name, file}){
let formData = new FormData()
formData.append('taskId', hash)
formData.append('chunkNumber', index)
formData.append('identifier', name)
formData.append('file', file)
return sewingCraftApi.uploadChunkUpload(formData)
}
})
}

引用

字节跳动面试官:请你实现一个大文件上传和断点续传

文件类型的终极校验

128个常见的文件头信息对照表

大文件分片上传

文件切片

谈谈前端大文件分片上传

  • 为什么要切片?

一个文件过大,上传会非常的慢,而且还可能会中途失败导致前功尽弃。要重新上传文件,非常影响用户体验。文件切片以后,可以并发上传,速度比之前更快。而且如果中途失败可以做到不用上传已经上传过的。

  • 对文件如何切片?

通过Blob.prototype.slice方法即可对文件进行切分,分片尽量不要太大,一般最大50M即可。

  • 相关实现如下
const maxChunkSize = 52428800  // 最大容量块
const chunkSum = Math.ceil(file?.size / maxChunkSize)

export const createFileChunks = async ({file, chunkSum, setProgress}) => {
const fileChunkList = [];
const chunkSize = Math.ceil(file?.size / chunkSum);
let start = 0;
for (let i = 0; i < chunkSum; i++) {
const end = start + chunkSize;
fileChunkList.push({
index: i,
filename: file?.name,
file: file.slice(start, end)
});
start = end;
}
const result = await getFileHash({chunks: fileChunkList, setProgress});
fileChunkList.map((item, index) => {
item.key = result;
});
return fileChunkList;
};

计算文件hash

  • 为什么要计算文件的hash?

通过对文件的内容进行一些算法加密运算得出文件的hash,文件的内容和hash是一一对应的。当我们修改文件内容时,hash就会变化。我们通过hash来判断是否是同一个文件。通过判别文件hash,可以知道哪些文件或者文件切片的已经上传完成。

  • 如何计算文件的hash?

首先,我们上传的是一个大文件,所以计算文件的hash是非常的耗时的。我们都知道JS是单线程的,如果用来计算这么耗时的任务肯定是不妥的。解决方法有两个,如下

1、 通过Worker模拟JS多线程来处理耗时任务。

self.importScripts("https://cdn.bootcdn.net/ajax/libs/spark-md5/3.0.0/spark-md5.min.js");
self.onmessage = event => {
console.log('worker接收的数据:',event.data)
const chunks = event.data
const spark = new self.SparkMD5.ArrayBuffer();
const appendToSpark = async file => new Promise(resolve => {
const reader = new FileReader();
reader.readAsArrayBuffer(file);
reader.onload = e => {
console.log(e.target.result)
spark.append(e.target.result);
resolve();
};
});
let count = 0;
const workLoop = async () => {
while (count < chunks.length) {
await appendToSpark(chunks[count].file);
count++;
if (count >= chunks.length){
self.postMessage(spark.end())
}
}
};
workLoop()
}

2、 在浏览器的空闲时间去计算文件的hash。

  • 具体实现如下
export const getFileHash = async ({chunks, setProgress}) => new Promise(resolve => {
const spark = new sparkMD5.ArrayBuffer();
const appendToSpark = async file => new Promise(resolve => {
const reader = new FileReader();
reader.readAsArrayBuffer(file);
reader.onload = e => {
spark.append(e.target.result);
resolve();
};
});
let count = 0;
const workLoop = async deadline => {
// 块数没有计算完,并且当前帧还没结束
while (count < chunks.length && deadline.timeRemaining() > 1) {
await appendToSpark(chunks[count].file);
count++;
setProgress(Number(((100 * count) / chunks.length).toFixed(2)));
if (count >= chunks.length) resolve(spark.end());
}
window.requestIdleCallback(workLoop);
};
window.requestIdleCallback(workLoop);
});
  • 如何快速计算文件hash?

计算文件md5值的作用,无非就是为了判定文件是否存在,我们可以考虑设计一个抽样的hash,牺牲一些命中率的同时,提升效率,设计思路如下

  1. 文件切成2M的切片

  2. 第一个和最后一个切片全部内容,其他切片的取 首中尾三个地方各2个字节

  3. 合并后的内容,计算md5,称之为影分身Hash

  4. 这个hash的结果,有小概率误判。如果被抽样的样本都相同,而未被抽样的内容不同则会造成误判。

img

 function createFileChunks({ file,setProgress }){
const fileChunkList = [];
const chunkSum = Math.ceil(file?.size / maxChunkSize)
const chunkSize = Math.ceil(file?.size / chunkSum);
let start = 0;
for (let i = 0; i < chunkSum; i++) {
const end = start + chunkSize;
if(i == 0 || i == chunkSum - 1) {
fileChunkList.push({
index: i,
filename: file?.name,
file: file.slice(start, end),
});
} else {
fileChunkList.push({
index: i,
filename: file?.name,
file:sample(file,start,end),
});
}
start = end;
}
return fileChunkList;
};
// 抽样函数
function sample(file,start,end) {
const pre = file.slice(start, start + 1024 * 2)
const after = file.slice(end - 1024 * 2, end)
const merge = new Blob([pre, after])
return merge
}

requestIdleCallback

window.requestIdleCallback()方法插入一个函数,这个函数将在浏览器空闲时期被调用。

页面是一帧一帧绘制出来的,当每秒绘制的帧数(FPS)达到 60 时,页面是流畅的,小于这个值时,用户会感觉到卡顿。1s 60帧,所以每一帧分到的时间是 1000/60 ≈ 16 ms。

  • 浏览器一帧都做了啥?
  1. 处理用户的交互
  2. JS 解析执行
  3. 帧开始。窗口尺寸变更,页面滚去等的处理
  4. requestAnimationFrame(rAF)
  5. 布局
  6. 绘制

img img

就是说requestIdleCallback()会在一帧16ms中的最后执行,等到页面绘制完成以后看是否有空闲时间执行。如果有就执行,否则不执行。因为DOM已经绘制完成,所以不要在requestIdleCallback里面去操作DOM,这样容易引起会导致重新计算布局和视图的绘制,操作DOM建议在requestAnimationFrame中。

var handle = window.requestIdleCallback(callback[, options])
const cb = ({didTimeout, timeRemaining()}) => {}
const op = {timeout: 3000}
  • didTimeout 任务是否超时

  • timeRemaining() 当前帧剩余的时间

  • timeout 超时强制执行

详细见requestIdleCallback

requestAnimationFrame

window.requestAnimationFrame() 告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行。

requestAnimationFrame 方法不同与 setTimeout 或 setInterval,它是由系统来决定回调函数的执行时机的,会请求浏览器在下一次重新渲染之前执行回调函数。无论设备的刷新率是多少,requestAnimationFrame 的时间间隔都会紧跟屏幕刷新一次所需要的时间;

setTimeout 做动画可能会卡顿,因为js是单线程的,如果前面的任务阻塞了,那么setTimeout 就会等之前的任务执行完成在执行,造成卡顿。

详细见requestAnimationFrame

并发上传

通过Promise.all()可以并发上传所有的切片,并发上传所有切片可能会导致网络请求被全部占用,而且这样上传的成功率也会大打折扣。控制切片上传的并发数来解决此问题。

1、通过mapLimit来控制并发数

详情见链接

npm install --save async
import mapLimit from 'async/mapLimit';
export const uploadChunks = ({
chunks,
url,
chunkSum,
limit,
setProgress
}) => new Promise((resolve, reject) => {
mapLimit(
chunks,
limit,
({index, key, filename, file}, cb) => {
const formData = new FormData();
formData.append('slice', index);
formData.append('guid', key);
formData.append('slices', chunkSum);
formData.append('filename', filename);
formData.append('file', file);
request({
url,
data: formData,
onProgress: e => {
setProgress(parseInt(String((e.loaded / e.total) * 100)), index);
},
cb
});
},
(err, result) => {
if (err) {
Message.error('文件上传出错,请检查以后重新上传')
reject(err)
return
}
resolve(result)
}
)
});

切片上传进度

切片的上传进度是通过XMLHttpRequest的upload.onprogress事件去监听。

export const request = ({
url,
method = 'post',
data,
headers = {},
cb = e => e,
onProgress = e => e
}) => {
const xhr = new XMLHttpRequest();
xhr.withCredentials = true
xhr.timeout = 1000 * 60 * 60
xhr.upload.onprogress = onProgress;
xhr.open(method, url);
headers = Object.assign({}, headers, {'EDR-Token': Cookie.get('auth')})
Object.keys(headers).forEach(key => xhr.setRequestHeader(key, headers[key])
);
xhr.send(data);
xhr.onload = e => {
cb(null, {
data: JSON.parse(e.target.response)
});
};
xhr.onerror = error => {
cb(error)
}
}

切片合并与断点续传

当所有的切片都上传成功之后,我们就会向后端发起合并切片的请求。

如果切片上传到一半就发生了故障或者无网络了怎么办?要重新上传所有的切片吗?

不用,我们只要上传我们还没上传的切片就好。通过localStorage记录切片上传的信息或者每次在上传切片前向后台询问该切片是否已经上传。具体流程如下

img

总结

  1. 计算文件的hash是为了判断文件的唯一性,通过hash也可以判断文件是否已经上传成功。

  2. 文件的切片是通过Blob.prototype.slice去切分文件,切片是否上传成功的判断可以在服务的处理也可以在前端保留已上传切片的信息。

  3. 计算文件的hash是一个非常耗时的任务,可以通过worker模拟多线程去处理,或者在浏览器的空闲时间requestIdleCallback去处理这个耗时的任务。为了快速的计算出文件的hash,也可以通过抽样的方式。

  4. 文件的实时上传进度可以通过XMLHttpRequest的upload.onprogress去处理,也可以通过websocket去实时监听。

  5. 所有切片都上传成功时,发起合并请求,让所有的切片合并成一个大文件。

如何实现大文件分片上传

平时在移动和客户端有普通的文件上传,但这种文件大多不大,从几k到几十兆,平时完全可以满足。但是对于有些终端系统(pc端、移动端),有时候存在文件过大,如拍摄的高清视频,导出上传不了(内存过小或响应时间过长)的问题,用户体验及不佳。这里上传不了的原因是前端也是需要将文件加载到内存后再上传。但文件过大,内存过小时问题就麻烦了。针对这种情景,特提供文件分片上传的功能。不仅可以提高上传速率,而且对普通和大文件都适用。并且对于文件实现断点续传、秒传的功能。

解决思路

 首先,前端制定分片的规则。比如对于手机移动端,当上传文件大于10M时,采用分片的方式上传,并切割一片就上传,不用等待每片响应的结果。

  对于前端,当前端上传文件时,前端边加载的时候边分割文件,每分割一片就上传。如前端加载完5M就直接上传,完成上传动作后释放上传的那块内存。防止占用内存的问题, 减少了内存占用。而分片可以采用线程、异步的方式,缩短了上传的时间,也解决了用户等待时间过长的问题。

  对于后端,每次上传的文件,获取文件的md5值,保存md5值至数据库中。对于完整的文件md5值,作为文件信息存储;对于分片文件的md5值,保存在分片信息中。当上传一个文件时,首先是根据完整的md5值查找是否有上传的记录,有则说明上传的文件有上传的记录,若成功过直接返回url(文件秒传);没有成功过,但有上传记录,则有可能之前上传过部分,则需要继续上传未上传的文件(断点续传);没有则按照完整的流程上传。上传完成后,合并分片文件,更新并保存信息。

  但是在开发的过长中,遇到几个问题:

  ①:对于文件md5值,前端如何获取到?因为文件md5值是通过文件中的内容确定的,每个不同的文件md5值是不一样的,而文件本身不可能加载全量文件再获取的。

  ②:如何判断文件是否全部上传完,并是否可以进行合并了?

  ③:上传的某片文件若出错了,怎么让该片文件重新上传?

  ④:合并文件时,如何保证合并的顺序?

  针对上述问题,在开发的过程都一一解决了。对于

  问题①:经过斟酌,做了一些取舍,舍弃了文件秒传的精确度。采用文件的属性(如文件名、类型、大小等) 加第一个分片的内容作为确定md5值;

  问题②:在后端的表结构中,会记录这个文件以及这个分片文件的状态,前端也会告诉后端分了多少个文件。当上传一个分片时,会更新分片文件的状态,同时分片文件上传的数量会+1;当文件的状态已经成功并且上传成功的数量和需要上传的数量相同时就可以进行合并了。

  问题③:在生成md5值后且在上传前,通过md5值去调用另外一个接口,获取上传信息,检测是否上传过。

  问题④:每个上传的分片文件名和第几个分片都会记录下来,合并文件的时候按照这个顺序进行合并。


前言

一个文件资源服务器,很多时候需要保存的不只是图片,文本之类的体积相对较小的文件,有时候,也会需要保存音视频之类的大文件。在上传这些大文件的时候,我们不可能一次性将这些文件数据全部发送,网络带宽很多时候不允许我们这么做,而且这样也极度浪费网络资源。

因此,对于这些大文件的上传,往往会考虑用到分片传输。

分片传输,顾名思义,也就是将文件拆分成若干个文件片段,然后一个片段一个片段的上传,服务器也一个片段一个片段的接收,最后再合并成为完整的文件。

下面我们来一起简单地实现以下如何进行大文件分片传输。

前端

拆分上传的文件流

首先,我们要知道一点:文件信息的 File 对象继承自 Blob 类,也就是说, File 对象上也存在 slice 方法,用于截取指定区间的 Buffer 数组。

通过这个方法,我们就可以在取得用户需要上传的文件流的时候,将其拆分成多个文件来上传:

<script setup lang='ts'>
import { ref } from "vue"
import { uploadLargeFile } from "@/api"

const fileInput = ref<HTMLInputElement>()

const onSubmit = () => {
// 获取文件对象
const file = onlyFile.value?.file;
if (!file) {
return
}

const fileSize = file.size; // 文件的完整大小
const range = 100 * 1024; // 每个区间的大小
let beginSide = 0; // 开始截取文件的位置

// 循环分片上传文件
while (beginSide < fileSize) {
const formData = new FormData()
formData.append(
file.name,
file.slice(beginSide, beginSide + range),
(beginSide / range).toString()
)
beginSide += range

uploadLargeFile(formData)
}
}
</script>

<template>
<input
ref="fileInput"
type="file"
placeholder="选择你的文件"
>
<button @click="onSubmit">提交</button>
</template>

我们先定义一个 onSubmit 方法来处理我们需要上传的文件。

onSubmit 中,我们先取得 ref 中的文件对象,这里我们假设每次有且仅有一个文件,我们也只处理这一个文件。

然后我们定义 一个 beginSiderange 变量,分别表示每次开始截取文件数据的位置,以及每次截取的片段的大小。

这样一来,当我们使用 file.slice(beginSide, beginSide + range) 的时候,我们就取得了这一次需要上传的对应的文件数据,之后便可以使用 FormData 封装这个文件数据,然后调用接口发送到服务器了。

接着,我们使用一个循环不断重复这一过程,直到 beginSide 超过了文件本身的大小,这时就表示这个文件的每个片段都已经上传完成了。当然,别忘了每次切完片后,将 beginSide 移动到下一个位置。

另外,需要注意的是,我们将文件的片添加到表单数据的时候,总共传入了三个参数。第二个参数没有什么好说的,是我们的文件片段,关键在于第一个和第三个参数。这两个参数都会作为 Content-Disposition 中的属性。

第一个参数,对应的字段名叫做 name ,表示的是这个数据本身对应的名称,并不区分是什么数据,因为 FormData 不只可以用作文件流的传输,也可以用作普通 JSON 数据的传输,那么这时候,这个 name 其实就是 JSON 中某个属性的 key

而第二个参数,对应的字段则是 filename ,这个其实才应该真正地叫做文件名。

我们可以使用 wireshark 捕获一下我们发送地请求以验证这一点。

wireshark.png

我们再观察上面构建 FormData 的代码,可以发现,我们 appendFormData 实例的每个文件片段,使用的 name 都是固定为这个文件的真实名称,因此,同一个文件的每个片,都会有相同的 name ,这样一来,服务器就能区分哪个片是属于哪个文件的。

filename ,使用 beginSide 除以 range 作为其值,根据上下文语意可以推出,每个片的 filename 将会是这个片的 序号 ,这是为了在后面服务端合并文件片段的时候,作为前后顺序的依据。

当然,上面的代码还有一点问题。

在循环中,我们确实是将文件切成若干个片单独发送,但是,我们知道, http 请求是异步的,它不会阻塞主线程。所以,当我们发送了一个请求之后,并不会等这个请求收到响应再继续发送下一个请求。因此,我们只是做到了将文件拆分成多个片一次性发送而已,这并不是我们想要的。

想要解决这个问题也很简单,只需要将 onSubmit 方法修改为一个异步方法,使用 await 等待每个 http 请求完成即可:

// 省略一些代码
const onSubmit = async () => {
// ......
while(beginSide < fileSize) {
// ......
await uploadLargeFile(formData)
}
}
// ......

这样一来,每个片都会等到上一个片发送完成才发送,可以在网络控制台的时间线中看到这一点:

timing.png

后端

接收文件片段

这里我们使用的 koa-body 来 处理上传的文件数据:

import Router = require("@koa/router")
import KoaBody = require("koa-body")
import { resolve } from 'path'
import { publicPath } from "../common";
import { existsSync, mkdirSync } from "fs"
import { MD5 } from "crypto-js"

const router = new Router()
const savePath = resolve(publicPath, 'assets')
const tempDirPath = resolve(publicPath, "assets", "temp")

router.post(
"/upload/largeFile",
KoaBody({
multipart: true,
formidable: {
maxFileSize: 1024 * 1024 * 2,
onFileBegin(name, file) {
const hashDir = MD5(name).toString()
const dirPath = resolve(tempDirPath, hashDir)
if (!existsSync(dirPath)) {
mkdirSync(dirPath, { recursive: true })
}
if (file.originalFilename) {
file.filepath = resolve(dirPath, file.originalFilename)
}
}
}
}),
async (ctx, next) => {
ctx.response.body = "done";
next()
}
)

我们的策略是先将同一个 name 的文件片段收集到以这个 name 进行 MD5 哈希转换后对应的文件夹名称的文件夹当中,但使用 koa-body 提供的配置项无法做到这么细致的工作,所以,我们需要使用自定义 onFileBegin ,即在文件保存之前,将我们期望的工作完成。

首先,我们拼接出我们期望的路径,并判断这个路径对应的文件夹是否已经存在,如果不存在,那么我们先创建这个文件夹。然后,我们需要修改 koa-body 传给我们的 file 对象。因为对象类型是引用类型,指向的是同一个地址空间,所以我们修改了这个 file 对象的属性, koa-body 最后获得的 file 对象也就被修改了,因此, koa-body 就能够根据我们修改的 file 对象去进行后续保存文件的操作。

这里我们因为要将保存的文件指定为我们期望的路径,所以需要修改 filepath 这个属性。

而在上文中我们提到,前端在 FormData 中传入了第三个参数(文件片段的序号),这个参数,我们可以通过 file.originalFilename 访问。这里,我们就直接使用这个序号字段作为文件片段的名称,也就是说,每个片段最终会保存到 ${tempDir}/${hashDir}/${序号} 这个文件。

由于每个文件片段没有实际意义以及用处,所以我们不需要指定后缀名。

合并文件片段

在我们合并文件之前,我们需要知道文件片段是否已经全部上传完成了,这里我们需要修改一下前端部分的 onSubmit 方法,以发送给后端这个信号:

// 省略一些代码
const onSubmit = async () => {
// ......
while(beginSide < fileSize) {
const formData = new FormData()
formData.append(
file.name,
file.slice(beginSide, beginSide + range),
(beginSide / range).toString()
)

beginSide += range

// 满足这个条件表示文件片段已经全部发送完成,此时在表单中带入结束信息
if(beginSide >= fileSize) {
formData.append("over", file.name)
}

await uploadLargeFile(formData)
}
}
// ......

为图方便,我们直接在一个接口中做传输结束的判断。判断的依据是:当 beiginSide 大于等于 fileSize 的时候,就放入一个 over 字段,并以这个文件的真实名称作为其属性值。

这样,后端代码就可以以是否存在 over 这个字段作为文件片段是否已经全部发送完成的标志:

router.post(
"/upload/largeFile",
KoaBody({
// 省略一些配置
}),
async (ctx, next) => {
if (ctx.request.body.over) { // 如果 over 存在值,那么表示文件片段已经全部上传完成了
const _fileName = ctx.request.body.over;
const ext = _fileName.split("\.")[1]
const hashedDir = MD5(_fileName).toString()
const dirPath = resolve(tempDirPath, hashedDir)
const fileList = readdirSync(dirPath);
let p = Promise.resolve(void 0)
fileList.forEach(fragmentFileName => {
p = p.then(() => new Promise((r) => {
const ws = createWriteStream(resolve(savePath, `${hashedDir}.${ext}`), { flags: "a" })
const rs = createReadStream(resolve(dirPath, fragmentFileName))
rs.pipe(ws).on("finish", () => {
ws.close()
rs.close();
r(void 0)
})
})
)
})
await p
}
ctx.response.body = "done";
next()
}
)

我们先取得这个文件真实名字的 hash ,这个也是我们之前用于存放对应文件片段使用的文件夹的名称。

接着我们获取该文件夹下的文件列表,这会是一个字符串数组(并且由于我们前期的设计逻辑,我们不需要在这里考虑文件夹的嵌套)。然后我们遍历这个数组,去拿到每个文件片段的路径,以此来创建一个读入流,再以存放合并后的文件的路径创建一个写入流(注意,此时需要带上扩展名,并且,需要设置 flags'a' ,表示追加写入),最后以管道流的方式进行传输。

但我们知道,这些使用到的流的操作都是异步回调的。可是,我们保存的文件片段彼此之间是有先后顺序的,也就是说,我们得保证在前面一个片段写入完成之后再写入下一个片段,否则文件的数据就错误了。

要实现这一点,需要使用到 Promise 这一api。

首先我们定义了一个 fulfilled 状态的 Promise 变量 p ,也就是说,这个 p 变量的 then 方法将在下一个微任务事件的调用时间点直接被执行。

接着,我们在遍历文件片段列表的时候,不直接进行读写,而是把读写操作放到 pthen 回调当中,并且将其封装在一个 Promsie 对象当中。在这个 Promise 对象中,我们把 resolve 方法的执行放在管道流的 finish 事件中,这表示,这个 then 回调返回的 Promise 实例,将会在一个文件片段写入完成后被修改状态。此时,我们只需要将这个 then 回调返回的 Promsie 实例赋值给 p 即可。

这样一来,在下个遍历节点,也就是处理第二个文件片段的时候,取得的 p 的值便是上一个文件片段执行完读写操作返回的 Promise 实例,而且第二个片段的执行代码会在第一个片段对应的 Promise 实例 then 方法被触发,也就是上一个片段的文件写入完成之后,再添加到微任务队列。

以此类推,每个片段都会在前一个片段写入完成之后再进行写入,保证了文件数据先后顺序的正确性。

当所有的文件片段读写完成后,我们就拿实现了将完整的文件保存到了服务器。

不过上面的还有许多可以优化的地方,比如:在合并完文件之后,删除所有的文件片段,节省磁盘空间;使用一个 Map 来保存真实文件名与 MD5 哈希值的映射关系,避免每次都进行 MD5 运算等等。但这里只是给出了简单的实习,具体的优化还请根据实际需求进行调整。

总结

  • 使用 slice 方法可以截取 file 对象的片段,分次发送文件片段;
  • 使用 koa-body 保存每个文件片段到一个指定的暂存文件夹,在文件片段全部发送完成之后,将片段合并。