大文件上传技术实现
需求分析
针对大文件上传,我们希望最少做到一下几点
- 大文件切割,分片上传
- 如果有部分切片上传失败了,我们希望提醒用户重新上传,并且上传成功不需要上传
- 最好能有上传的进度提示
项目架构
本次项目前台我们打算使用 vite+vue3+element-plus,后台使用koa框架
vite 创建 vue3 项目
// https://cn.vitejs.dev/guide/#scaffolding-your-first-vite-project
// 执行下面一条语句就创建完成了 好快啊
yarn create vite big-upload --template vue
cd big-upload-ui
// 安装需要的库
yarn add element-plus
koa脚手架 创建后台项目
// koa2脚手架
npm install koa-generator -g
// 脚手架创建项目
koa2 server
cd server
yarn
// 安装对应的库
yarn add koa-body fs-extra
// 删去一些不需要使用的文件 全局引入koa-body 并且配置 创建upload路由
大文件分片上传
前端思路
文件切片
我们选择文件是使用的input
输入框,获取到选择的值也很简单。不熟悉的同学请补补课,文件切片的核心就是文件对象的slice
方法,类似数组,我们可以调用这个方法获取到文件的某一段,不熟悉file对象的同学请补补课。
文件唯一值
现在有一个比较大的问题,我们如何告诉后端,我们上传的两个文件是不是同一个文件,显然,如果使用文件名作为唯一标识肯定不太好。这个时候我们想到可以使用md5
对文件加密获取唯一的hash
值。
生成hash值的方法我们是调用 spark-md5
这个库,在计算hash
的时候是非常消耗计算机的CPU
的会造成浏览器的卡顿,为了优化体验我们使用 web-worker
在 worker
线程计算 hash
,不熟悉的同学请补补课。其实我也不太熟悉 ╯︿╰
新建hash.js
文件用来计算hash
// 导入脚本
self.importScripts('/spark-md5.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);
};
复制代码
在 worker
线程中,接受文件切片 fileChunkList
,利用 FileReader
读取每个切片的 ArrayBuffer
并不断传入 spark-md5
中,每计算完一个切片通过 postMessage
向主线程发送一个进度事件,全部完成后将最终的 hash
发送给主线程
文件上传
假设我们的文件切片全部是上传成功了,这个时候服务端是不会主动的帮助我们合并切片,我们需要在发送一个合并切片的请求通知服务端帮助我们发送切片。
定义数据结构
ok,现在我们可以获取到文件也可以分片并且确保文件的唯一性,这个时候为了保证项目的完整性,我们来定义一下数据结构。
文件对象:
+ 我们就使用 原生的File 对象,不做过多的改变
切片(chunk)对象:
+ chunk :对应我们file.slice返回的切片
+ size :chunk.size
+ index:当前块是文件中的下标
+ fileHash :文件的hash值
+ chunkHash :分片之后片的hash值-->这里我们使用 `${fileHash}-${index}` 作为块的hash值
+ percentage:当前块上传的进度
复制代码
代码实现
<template>
<h1>大文件上传</h1>
<input type="file" @change="handleFileChange" />
<el-button @click="handleUpload">上传</el-button>
</template>
<script>
const SIZE = 3 * 1024 * 1024; // 定义切片的大小
export default {
data() {
return {
file: null, // 文件
hash: '', // 文件的hash
chunkList: [], // 切片列表
};
},
methods: {
handleFileChange(e) {
const [file] = e.target.files;
if (!file) {
this.file = null;
return;
}
this.file = file;
},
// 生成文件切片
createFileChunk(file, size = SIZE) {
const fileChunkList = [];
let cur = 0;
while (cur < file.size) {
// file.slice 返回一个 blob对象
fileChunkList.push({ file: file.slice(cur, cur + size) });
cur += size;
}
return fileChunkList;
},
// 上传文件切片
async uploadChunks(uploadedList = []) {
// 构造请求列表
const requestList = this.chunkList
.map(({ chunk, chunkHash, index, fileHash }) => {
const formData = new FormData();
formData.append('chunk', chunk);
formData.append('chunkHash', chunkHash);
formData.append('fileHash', fileHash);
return { formData, index };
})
.map(async ({ formData, index }) =>
this.request({
url: 'http://localhost:8080/upload-chunk',
method: 'post',
data: formData,
})
);
await Promise.all(requestList); // 并发切片
await this.mergeRequest(); // 合并切片
},
// 通知服务的合并切片
async mergeRequest() {
await this.request({
url: 'http://localhost:8080/merge',
method: 'post',
headers: { 'content-type': 'application/json' },
data: JSON.stringify({ filename: this.file.name, fileSize: this.file.size, size: SIZE, hash: this.hash }),
});
},
// 上传按钮点击事件
async handleUpload() {
if (!this.file) {
console.log('请选择一个文件吧');
return;
}
// 文件分片
const fileChunkList = this.createFileChunk(this.file);
// 计算文件hash
this.hash = await this.calculateHash(fileChunkList);
// 构建 chunkList 添加下标以及 上传进度(是每一个chunk的上传进度)
this.chunkList = fileChunkList.map(({ file }, index) => ({
chunk: file,
size: file.size,
chunkHash: `${this.hash}-${index}`,
fileHash: this.hash,
index,
percentage: uploadedList.includes(`${this.hash}-${index}`) ? 100 : 0,
}));
// 上传 chunk
await this.uploadChunks(uploadedList);
},
},
};
</script>
后端思路
上面的分析可知,我们后端首先是要接受传过来的 chunk
将他们存在指定的目录下,最后在接收到合并请求的时候需要将chunks
合并成一个原始的文件
这里我们规定一个文件上传成功之后的最终目录结构将会是一下结构
+ target
+ fileHash-chunks
+ chunkHash
file
我们将所有的文件保存在 target目录下,以fileHash-chunks命名一个文件加来存放我们一个文件对应的所有的chunk,在合并之后,将所有的 chunk 合并成 以 fileHash命名的文件,(PS:文件夹我们加了一个后缀是因为系统不允许有同名的文件和文件夹,一开始在这踩坑了好久)。如果没看懂我的描述的话,就看下面的代码吧,上面主要是对逻辑的一些约束。
配置 koa-body
app.use(koaBody({ multipart: true, formidable: { maxFileSize: 200 * 1024 * 1024 } }));
upload 路由
const router = require('koa-router')();
const path = require('path');
const fse = require('fs-extra');
// 大文件存储目录
const UPLOAD_DIR = path.resolve(__dirname, '..', 'target');
// 提取文件后缀名
const extractExt = (filename) => filename.slice(filename.lastIndexOf('.'), filename.length);
/**
* 针对 path 创建 readStream 并写入 writeStream,写入完成之后删除文件
* @param {String} path
* @param {String} writeStream
*/
const pipeStream = (path, writeStream) =>
new Promise((resolve) => {
const readStream = fse.createReadStream(path);
readStream.on('end', () => {
fse.unlinkSync(path);
resolve();
});
readStream.pipe(writeStream);
});
/**
* 读取所有的 chunk 合并到 filePath 中
* @param {String} filePath 文件存储路径
* @param {String} chunkDir chunk存储文件夹名称
* @param {String} size 每一个chunk的大小
*/
async function mergeFileChunk(filePath, chunkDir, size) {
// 获取chunk列表
const chunkPaths = await fse.readdir(chunkDir);
// 根据切片下标进行排序 否则直接读取目录的获得的顺序可能会错乱
chunkPaths.sort((a, b) => a.split('-')[1] - b.split('-')[1]);
await Promise.all(
chunkPaths.map((chunkPath, index) =>
pipeStream(
path.resolve(chunkDir, chunkPath),
// 指定位置创建可写流
fse.createWriteStream(filePath, {
start: index * size,
end: (index + 1) * size,
})
)
)
);
fse.rmdirSync(chunkDir); // 合并后删除保存切片的目录
}
// 上传 chunk
router.post('/upload-chunk', async (ctx, next) => {
const { chunkHash, fileHash } = ctx.request.body;
const { chunk } = ctx.request.files;
const chunkDir = path.resolve(UPLOAD_DIR, `${fileHash}-chunks`);
// 切片目录不存在,创建切片目录
if (!fse.existsSync(chunkDir)) {
await fse.mkdirs(chunkDir);
}
await fse.move(chunk.path, `${chunkDir}/${chunkHash}`);
ctx.body = { code: 0, data: '', msg: '上传成功' };
});
// 合并
router.post('/merge', async (ctx, next) => {
const { filename, fileSize, size, hash } = ctx.request.body;
const ext = extractExt(filename);
const filePath = path.resolve(UPLOAD_DIR, `${hash}${ext}`);
const chunkDir = path.resolve(UPLOAD_DIR, `${hash}-chunks`);
await mergeFileChunk(filePath, chunkDir, size);
ctx.body = { code: 0, data: '', msg: '合并成功' };
});
至此一个简单的大文件上传就完成了。
进度条功能
上传进度分两种,一个是每个切片的上传进度,另一个是整个文件的上传进度,而整个文件的上传进度是基于每个切片上传进度计算而来,所以我们先实现切片的上传进度
切片进度条
XMLHttpRequest 原生支持上传进度的监听,只需要监听 upload.onprogress 即可,我们在原来的 request 基础上传入 onProgress 参数,给 XMLHttpRequest 注册监听事件
每一个切片都需要对应一个上传的进度,这个时候应该写一个方法针对切片对象进行进度条的除了,
// item是我们的chunk对象
createProgressHandler(item) {
return (e) => {
item.percentage = parseInt(String((e.loaded / e.total) * 100));
};
},
// 在上传切片的时候 给 onProgress参数绑定上这个方法就可以了
文件的进度条
将每个切片已上传的部分累加,除以整个文件的大小,就能得出当前文件的上传进度,所以这里使用 Vue 计算属性
computed: {
// 针对每一个 chunk的进度 计算出总的上传进度
uploadPercentage() {
if (!this.file || !this.chunkList.length) return 0;
const loaded = this.chunkList.map((item) => item.size * item.percentage).reduce((acc, cur) => acc + cur);
return parseInt((loaded / this.file.size).toFixed(2));
},
},
大文件上传的基本功能就差不多完成了。
之后的功能我就不贴代码了 写一下代码规范和思路,具体代码我之后在贴出来。
文件秒传
这个功能的意思就是说,我们在文件上传之前,去问一下服务器,你有没有这个文件呀,你没有的话我就开始上传,你要是有的话我就偷个懒,用你有的我就不上传了。
所以需要实现一个检测接口(verify),去询问服务器有没有这个文件,因为我们之前是计算过文件的 hash的,能保证文件的唯一性。就用这个hash就能唯一的判断这个文件。所以这个接口的思路也很简单,就是判断我们的 target
目录下是否存在这个文件。
断点续传
断点续传的意思就是我们上传的时候如果文件上传失败了,我们之后在上传一次的时候,只上传我们之前失败的文件,成功的文件我们就跳过。
暂停上传
我们先自己手动实现一个按钮,点击之后就停止当前的上传情况。模拟了上传失败
这个思路肯定就是要改装我们的 request
方法,在改装之前我们需要知道 XMLHttpRequest
对象是可以自己主动停止当前的网络连接的,不知道的同学补补课。
这样我们只需要使用一个公共的数组,每一次发请求的时候都保存我们当前的这个XMLHttpRequest
对象,当请求成功之后,我们就移除这个对象,当点击暂停按钮的时候我们就遍历这个数组调用每一个XMLHttpRequest
的abrot
方法就可以取消上传了。
恢复上传
恢复上传其实也就是重新开始上传,只不过我们上传的chunk数组需要是服务器中之前没有上传成功的。这就有了两点需求
- 知道服务器上传成功了哪些chunk
- 上传chunk之前需要将成功的chunk移除
针对需求1我们改装之前的妙传接口,在这个接口不仅要告诉我们服务器是否存在这个文件,还需要告诉我们当前文件的块上传了成功多少。换而言之也就是获取到 target下的 fileHash-chunks 文件夹中的文件名称列表 并返回。
需求2我们只需要在构建chunk数组的时候判断当前chunk是否上传了,上传了的需要修改 进度为100。在上传chunk的时候,只有当前chunk没有上传的时候才发起request。
总结
至此我们的大文件上传就完成了。
提出问题
- 没有处理切片上传失败的情况
- 上传文件的时候 切片太多导致发送的网络请求太多的话浏览器可能崩溃
- 文件太大的话计算hash就会十分十分卡顿 哪怕我们使用了 web worker
- 能不能做一个反向的(超大文件下载)
优化
超大文件 hash计算时间过长问题
一开始是借鉴 React
中Fiber
的实现,将计算hash值的过程使用requestIdleCallback
进行改进,发现文件太大了的话还是会卡顿很久。最终,我们打算使用抽样思路来计算hash
,放弃一部的准确度来换取时间
思路:设置一个小一点的大小比如 2M
- 我们在计算hash的时候,将超大文件以2M进行分割获得到另一个chunks数组,
- 第一个元素(chunks[0])和最后一个元素(chunks[-1])我们全要了
- 其他的元素(chunks[1,2,3,4....])我们再次进行一个分割,这个时候的分割是一个超小的大小比如2kb,我们取每一个元素的头部,尾部,中间的2kb。
- 最终将它们组成一个新的文件,我们全量计算这个新的文件的hash值。
这个时候计算超大文件的hash就不会特别耗时了,注意计算hash是用的抽样计算,但是上传我们还是用的之前的切片方法
改造后计算 hash的方法
// 使用 web-worker 计算 hash
calculateHash(fileChunkList) {
return new Promise((resolve) => {
// 添加 worker 属性
// this.worker = new Worker('/hash.js');
// this.worker.postMessage({ fileChunkList });
// this.worker.onmessage = (e) => {
// const { percentage, hash } = e.data;
// this.hashPercentage = percentage;
// if (hash) {
// resolve(hash);
// }
// };
const spark = new SparkMD5.ArrayBuffer();
const reader = new FileReader();
const file = this.file;
// 文件大小
const size = this.file.size;
let offset = 2 * 1024 * 1024;
let chunks = [file.slice(0, offset)];
// 前面100K
let cur = offset;
while (cur < size) {
// 最后一块全部加进来
if (cur + offset >= size) {
chunks.push(file.slice(cur, cur + offset));
} else {
// 中间的 前中后去两个字节
const mid = cur + offset / 2;
const end = cur + offset;
chunks.push(file.slice(cur, cur + 2));
chunks.push(file.slice(mid, mid + 2));
chunks.push(file.slice(end - 2, end));
}
// 前取两个字节
cur += offset;
}
// 拼接
reader.readAsArrayBuffer(new Blob(chunks));
reader.onload = (e) => {
spark.append(e.target.result);
this.hashPercentage = 100;
resolve(spark.end());
};
});
},
文件切片过多导致并发http请求过多问题
一开始是使用 Promise.all(requestList)
,如果有100个网络请求就会导致在那一瞬间浏览器要创建100个网络请求,浏览器就会十分卡顿。
这个时候我们需要控制并发数量,在同一时刻,我们最多只能发送 max个网络请求,用一个while循环,每一个发送一个网络请求max--,只有max大于0 的时候才继续发送,当请求得到响应之后,有两种情况,一个是我们已经发送的请求数量不够,那我们就继续发送网络请求,另一种是需要发送的网络请求发送完了,那我们就直接结束这个while循环了
修改后的代码如下
// 控制并发数量
async sendRequest(forms, max = 4) {
return new Promise((resolve) => {
const len = forms.length;
let idx = 0;
let counter = 0;
const start = async () => {
// 有请求,有通道
while (idx < len && max > 0) {
max--; // 占用通道
console.log(idx, 'start');
let { formData, index } = forms[idx];
idx++;
await this.request({
url: 'http://localhost:8080/upload-chunk',
method: 'post',
data: formData,
onProgress: this.createProgressHandler(this.chunkList[index]),
requestList: this.requestList,
}).then(() => {
max++; // 释放通道
counter++;
if (counter === len) {
resolve();
} else {
start();
}
});
}
};
start();
});
},
// 上传文件切片
async uploadChunks(uploadedList = []) {
// 构造请求列表
const requestList = this.chunkList
.filter((chunk) => !uploadedList.includes(chunk.chunkHash))
.map(({ chunk, chunkHash, index, fileHash }) => {
const formData = new FormData();
formData.append('chunk', chunk);
formData.append('chunkHash', chunkHash);
formData.append('fileHash', fileHash);
return { formData, index };
});
// .map(async ({ formData, index }) =>
// this.request({
// url: 'http://localhost:8080/upload-chunk',
// method: 'post',
// data: formData,
// onProgress: this.createProgressHandler(this.chunkList[index]),
// requestList: this.requestList,
// })
// );
// 等待全部发送完成
// await Promise.all(requestList); // 并发切片
// 控制并发
await this.sendRequest(requestList, 4);
// chunk 全部发送完成了需要通知后台去合并切片
if (uploadedList.length + requestList.length === this.chunkList.length) {
await this.mergeRequest();
}
},
切片上传失败问题
需求:
- 第一次发送错误之后需要有发送失败提示
- 第一次发送失败之后我们再进行3次的重传-->也就是一个请求最多发送4次
- 3次重传失败需要有提示
- 需要将所有的请求都经过上面做法之后才能只能下一步
第一步,我们定义一个状态,每一个请求都对应这下面的4种状态中的一个,一开始所有的请求都是 wait等待状态,发生错误时候变成error状态,3次重传都失败了之后变成fail状态,请求成功变成done完成状态
const Status = { wait: 1, error: 2, done: 3, fail: 4 };
这里有一个等量关系一定要记牢,所有的请求数 = wait+error+done+fail,而且最终状态只有 done和fail
下面来编写代码,首先改造一下我们的request方法,因为这个方法选择请求失败了并不会给我们错误反馈,请求失败了我们就reject一下
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
// 响应成功
} else {
// 控制进度
onProgress({ loaded: 0, total: 100 });
// 错误处理
reject(xhr.statusText);
}
}
};
接下来,我们改造我们的并发请求的方法,让他能够进行重试,因为需要知道重试的次数,我们需要定义一个数组保存请求的重试次数,retryArr[1]=2 表示我们第一个网络请求发送了2次重试,在一开始,我们就不遍历form数组了,我们针对状态进行查找,只要是error和wait状态的请求我们就进行发送,当找不到了就表明我们现在所有的状态已经变成done和fail了,我们就reject,然后我们在 catch中进行捕获并标记重传次数,超过3次就将状态标记成失败状态。
async sendRequest(forms, max = 4) {
return new Promise((resolve, reject) => {
const len = forms.length;
let counter = 0; // 已经发送成功的请求
const retryArr = []; // 记录错误的次数
// 一开始将所有的表单状态置为等待
forms.forEach((item) => (item.status = Status.wait));
const start = async () => {
// 有请求,有通道
while (counter < len && max > 0) {
max--; // 占用通道
// 只要是没有完成的我们就重发
let idx = forms.findIndex((v) => v.status == Status.wait || v.status == Status.error);
if (idx == -1) {
// 找不到失败状态和等待状态
return reject();
}
let { formData, index } = forms[idx];
await this.request({
url: 'http://localhost:8080/upload-chunk',
method: 'post',
data: formData,
onProgress: this.createProgressHandler(this.chunkList[index]),
requestList: this.requestList,
})
.then(() => {
forms[idx].status = Status.done;
max++; // 释放通道
counter++;
if (counter === len) {
resolve();
}
})
.catch(() => {
forms[idx].status = Status.error;
if (typeof retryArr[index] !== 'number') {
this.$message.info(`第 ${index} 个块上传失败,系统准备重试`);
retryArr[index] = 0;
}
// 次数累加
retryArr[index]++;
// 一个请求报错3次的
if (retryArr[index] > 3) {
this.$message.error(`第 ${index} 个块重试多次无效,放弃上传`);
forms[idx].status = Status.fail;
}
max++; // 释放通道
});
}
};
start();
});
},
整体代码就在上面了,还是十分推荐大家重新自己写一下的,因为我这个菜鸟在写的时候还踩了好多的坑点,写了好久才写完的。
chunk定期清理
很多同学肯定会问,chunk不是合并之后就全部删除掉了吗,为什么还要定期清理了,经过项目的长时间应用之后我们发现,有的同学测试我们这个代码的时候,上传大文件失败了就不管他了,导致这个大文件因为没有完全上传成功就没有进行合并和chunk清理。
所以需求就来了,需要我们检测文件的改动时间,才一段时间没有改动的话我们就认为这个chunk是用户不在需要的,我们就会主动清理他。
整体:定时任务,文件检测,文件操作
直接上代码了,程序是只执行的,只要在app.js中引入一下就行了。
const fse = require('fs-extra');
const path = require('path');
const schedule = require('node-schedule');
// 大文件存储目录
const UPLOAD_DIR = path.resolve(__dirname, '..', 'target');
// 空文件删除
function remove(file, stats) {
const now = new Date().getTime();
const offset = now - stats.ctimeMs;
if (offset > 1000 * 60) {
// 大于60秒的碎片
fse.unlinkSync(file);
console.log(file, '文件以过期,删除完毕');
}
}
async function scan(dir, callback) {
const files = fse.readdirSync(dir);
files.forEach((filename) => {
const fileDir = path.resolve(dir, filename);
const stats = fse.statSync(fileDir);
if (stats.isDirectory()) {
// 删除文件
scan(fileDir, remove);
// 删除空的文件夹
if (fse.readdirSync(fileDir).length == 0) {
fse.rmdirSync(fileDir);
}
return;
}
if (callback) {
callback(fileDir, stats);
}
});
}
// * * * * * *
// ┬ ┬ ┬ ┬ ┬ ┬
// │ │ │ │ │ │
// │ │ │ │ │ └ day of week (0 - 7) (0 or 7 is Sun)
// │ │ │ │ └───── month (1 - 12)
// │ │ │ └────────── day of month (1 - 31)
// │ │ └─────────────── hour (0 - 23)
// │ └──────────────────── minute (0 - 59)
// └───────────────────────── second (0 - 59, OPTIONAL)
let start = function () {
// 每5秒
schedule.scheduleJob('*/5 * * * * *', function () {
console.log('定时清理chunks开始');
scan(UPLOAD_DIR);
});
};
start();