本文主要讲解关于前后端实现大文件断点续传、分片上传、秒传的完整实例相关内容,让我们来一起学习下吧!
1、前言
文件上传在项目开发很常见,很多项目都会涉及到图片,音频,文件的上传。在现代技术中,基本都是通过组件库封装的组件进行上传,比如 element-ui ,antdesign等。这些ui库封装了上传的一些基础方法,可以满足大多的上传场景。但是如果遇到很大的文件,比如1G,2G这种大体量的文件,ui库封装的一些组件就难以满足实际的场景。有很多的问题需要我们去关注比如:
痛点1: 1G的文件按照正常的20M的带宽(上行20M已经属于很快的带宽),我们可能需要在页面停留大约9分钟(大概时间,甚至更久)。正常用户都无法忍受9分钟什么都不做,等着文件上传
痛点2: 由于文件过大,上传时间过长,中途断网或者浏览器崩溃,都会导致上传中断。可能就差几M就成功的文件,导致下次又要继续从头开始上传
痛点3:同一个文件多次上传,浪费服务器资源
本文主要通过一个Demo从前端(vue3)、后端用实战代码演示大文件分片上传、断点续传、秒传的开发原理。
前端代码:
<template>
<div>
<div @click.native="hanldeClick" class="upload_container">
<input name="请上传文件" type="file" ref="uploadRef" @change="handleChange" :multiple="multiple" :accept="accept"/>
</div>
<div ref="uploadSubmit" @click="handleUpload()">上传</div>
<div><span ref="uploadResultRef"></span></div>
<div>md5Value:{{fileSparkMD5}}</div>
</div>
</template>
<script setup>
import { ref,onMounted } from 'vue'
import { ElMessage, } from 'element-plus'
import SparkMD5 from 'spark-md5';
import { makePostRequest } from './axios.js';
defineProps({
multiple:{
type:Boolean,
default:true
},
accept:{
type:Array,
default:[]
}
})
const uploadRef = ref(); // input 的ref
const uploadResultRef = ref(null); //上传结果展示
const fileSparkMD5 = ref([]); // 文件MD5 唯一标识
const fileChuncks = ref([]); // 文件分片list
const chunckSize = ref(1*1024*1024); // 分片大小
const promiseArr = []; // 分片上传promise集合
const isUploadChuncks = ref([]); // 返回 [1,1,1,0,0,1] 格式数组(这里需要服务端返回的值是按照索引正序排列),标识对应下标上传状态 已上传:1 ,未上传:0
const uploadProgress = ref(0); // 上传进度
const uploadQuantity = ref(0); // 总上传数量
//检测文件是否上传过,
const checkFile = async (md5) => {
const data = await makePostRequest('http://127.0.0.1:3000/checkChuncks', {md5});
if (data.length === 0) {
return false;
}
const {file_hash:fileHash,chunck_total_number:chunckTotal} = data[0]; // 文件的信息,hash值,分片总数,每条分片都是一致的内容
if(fileHash === md5) {
const allChunckStatusList = new Array(Number(chunckTotal)).fill(0); // 文件所有分片状态list,默认都填充为0(0: 未上传,1:已上传)
const chunckNumberArr = data.map(item => item.chunck_number); // 遍历已上传的分片,获取已上传分片对应的索引 (chunck_number为每个文件分片的索引)
chunckNumberArr.forEach((item,index) => { // 遍历已上传分片的索引,将对应索引赋值为1,代表已上传的分片 (注意这里,服务端返回的值是按照索引正序排列)
allChunckStatusList[item] = 1
});
isUploadChuncks.value = [...allChunckStatusList];
return true; // 返回是否上传过,为下面的秒传,断点续传做铺垫
}else {
return false;
}
}
//文件上传function
const handleUpload = async () => {
const fileInput = uploadRef.value;
const file = fileInput.files[0];
// 未选择文件
if (!file) {
ElMessage({message:'请选择文件',type:'warning'});
return
}
//循环计算文件MD5,多文件上传时候
const fileArr = fileInput.files;
for(let i = 0; i < fileArr.length; i++){
const data = await getFileMD5(fileArr[i]);
fileSparkMD5.value.push({md5Value:data,fileKey:fileArr[i].name});
sliceFile(fileArr[i]);
}
//检测文件是否已上传过
const {md5Value} = fileSparkMD5.value[0]; // 这里已单文件做示例,默认取第一个文件
const isFlag = await checkFile(md5Value); //是否上传过
if(isFlag) {
const hasEmptyChunk = isUploadChuncks.value.findIndex(item => item === 0); //在所有的分片状态中找到未上传的分片,如果没有表示已完整上传
//上传过,并且已经完整上传,直接提示上传成功(秒传)
if(hasEmptyChunk === -1) {
ElMessage({message:'上传成功',type:'success'});
return;
}else {
//上传缺失的分片文件,注意这里的索引,就是文件上传的序号
for(let k = 0; k < isUploadChuncks.value.length; k++) {
if(isUploadChuncks.value[k] !== 1) {
const {md5Value,fileKey} = fileSparkMD5.value[0]; //单文件处理,多文件需要遍历匹配对应的文件
let data = new FormData();
data.append('totalNumber',fileChuncks.value.length); // 分片总数
data.append("chunkSize",chunckSize.value); // 分片大小
data.append("chunckNumber",k); // 分片序号
data.append('md5',md5Value); // 文件唯一标识
data.append('name',fileKey); // 文件名称
data.append('file',new File([fileChuncks.value[k].fileChuncks],fileKey)) //分片文件
httpRequest(data,k,fileChuncks.value.length);
}
}
}
}else {
//未上传,执行完整上传逻辑
fileChuncks.value.forEach((e, i)=>{
const {md5Value,fileKey} = fileSparkMD5.value.find(item => item.fileKey === e.fileName);
let data = new FormData();
data.append('totalNumber',fileChuncks.value.length);
data.append("chunkSize",chunckSize.value);
data.append("chunckNumber",i);
data.append('md5',md5Value); //文件唯一标识
data.append('name',fileKey);
data.append('file',new File([e.fileChuncks],fileKey))
httpRequest(data,i,fileChuncks.value.length);
})
}
let uploadResult = uploadResultRef.value;
Promise.all(promiseArr).then((e)=>{
uploadResult.innerHTML = '上传成功';
// pormise all 机制,所有上传完毕,执行正常回调,开启合并文件操作
mergeFile(fileSparkMD5.value,fileChuncks.value.length);
}).catch(e=>{
ElMessage({message:'文件未上传完整,请继续上传',type:'error'});
uploadResult.innerHTML = '上传失败';
})
}
//file:文件内容,nowChunck:当前切片的排序,totalChunck:总的切片数量
const httpRequest = (file,nowChunck,totalChunck) => {
const curPormise = new Promise((resolve,reject)=>{
let uploadResult = uploadResultRef.value;
let xhr = new XMLHttpRequest();
// 当上传完成时调用
xhr.onload = function() {
if (xhr.status === 200) {
// uploadResult.innerHTML = '上传成功'+ xhr.responseText;
//大文件上传进度
uploadQuantity.value ++;
// 注意这里,因为是分片,所以进度除以总数就是当前上传的进度
uploadProgress.value = uploadQuantity.value / totalChunck * 100;
uploadResult.innerHTML='上传进度:' + uploadProgress.value + '%';
return resolve(nowChunck)
}
}
xhr.onerror = function(e) {
return reject(e)
}
// 发送请求
xhr.open('POST', 'http://127.0.0.1:3000/upload', true);
xhr.send(file);
})
// 将所有请求推入pormise集合中
promiseArr.push(curPormise);
}
//获取文件MD5,注意这里谷歌浏览器有最大文件限制当文件大于2G时浏览器无法读取文件
const getFileMD5 = (file) => {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = (e) =>{
const fileMd5 = SparkMD5.ArrayBuffer.hash(e.target.result)
resolve(fileMd5)
}
fileReader.onerror = (e) =>{
reject('文件读取失败',e)
}
fileReader.readAsArrayBuffer(file);
})
}
//文件切片
const sliceFile = (file) => {
//文件分片之后的集合
const chuncks = [];
let start = 0 ;
let end;
while(start < file.size) {
end = Math.min(start + chunckSize.value,file.size);
//slice 截取文件字节
chuncks.push({fileChuncks:file.slice(start,end),fileName:file.name});
start = end;
}
fileChuncks.value = [...chuncks];
}
//合并文件
const mergeFile = async (fileInfo,chunckTotal) => {
const { md5Value,fileKey } = fileInfo[0];
const params = {
totalNumber:chunckTotal,
md5:md5Value,
name:fileKey
}
const response = await makePostRequest('http://127.0.0.1:3000/merge', params);
ElMessage({message:'上传成功',type:'success'});
}
</script>
大文件上传,前端主要分为3个步骤
graph TD
计算文件MD --> 文件分片 --> 调用服务端判断上传的文件MD5是否一致
- 计算文件MD5,需要用到 ‘spark-md5’依赖包,直接 npm i spark-md5 即可
- 文件分片,用到文件的 “slice” api,将文件进行切割
- 调用服务端主要是为了确认相同文件是否上传,如果已有MD5正面已经上传过,接着上传之前失败的分片
前端httpRequest上传文件方法,调用 upload 接口参数包括:
参数 | 参数说明 |
---|---|
file | 分片文件 |
chunckNumber | 文件分片的序号(索引),主要通过它来判断断点续传有哪些分片未上传 |
MD5 | 文件hash唯一值,合并文件用到 |
totalNumber | 分片总数,在续传的时候,需要重置等长数组用到 |
chunkSize | 分片大小,保留字段,判断文件分片是否相同 |
name | 文件名称 |
前端checkFile 方法,调用 checkChuncks 接口参数包括
参数 | 参数说明 |
---|---|
MD5 | 服务端查询数据库对应MD5的文件分片到前端 |
checkFile方法是断点续传的关键,前端需要注意,要求服务端按照分片的序号正序排列,返回给前端。因为这里前端需要按照索引去判断所有片段中,已上传和未上传的进行区分,然后只上传分片状态为 0 的分片达到断点续传的效果。这里给个前端处理完分片状态列表后的格式,大概是这样:[1,1,1,1,1,0,0,0,1,0,1,0],0即是上传失败的片段,遍历拿到索引,然后通过索引取文件里面对应的分片进行上传
最后秒传相对简单,一样通过 checkFile 方法拿到文件的MD5,请求服务端获取对应文件的分片,然后遍历分片的状态是否有 0 的,如果没有就是已存在完整文件,直接提示上传成功即可。
前端 mergeFile 方法合并文件,调用 merge 接口参数包括
参数 | 参数说明 |
---|---|
totalNumber | 文件分片的总数,服务端读取对应目录下的总数和前端传的总数对比,如果不同表示分片有问题 |
MD5 | 文件hash值,服务端会根据这个唯一的值生成一个文件将,用来存储这个文件下所有的分片文件 |
name | 文件名称,服务端合并完之后,给文件命名 |
当所有文件上传成功之后,需要将每个分片小文件,合成为一个完整的文件。至此前端所有的工作完整
服务端代码:
这里用node.js 代替java在本地模拟一个服务端。下面是node的所需依赖
"dependencies": {
"fs-extra": "^11.2.0",
"koa": "^2.14.2",
"koa-body": "^6.0.1",
"koa-multer": "^1.0.2",
"koa-router": "^12.0.0",
"koa-static": "^5.0.0",
"koa2-cors": "^2.0.6",
"mysql": "^2.18.1",
"nodemon": "^2.0.22"
}
这里后端主要有3个接口分别为:/upload,/merge,/checkChuncks
/upload 接口:负责上传文件到服务器,创建以 md5 为名称的唯一文件夹,将每个分片上传到对应的文件夹,并且每成功上传一个分片,就往数据库插入一条分片的数据,失败则不入库当前分片信息!数据库字段为
参数 | 参数说明 |
---|---|
file_hash | 文件唯一hash值,检查文件完整性用到 |
chunck_number | 每一个分片的序号,前端区分,分片的上传状态,这里服务端只会入库上传成功的分片 |
chunck_total_number | 总分片大小,合并文件时会校验分片是否缺失 |
file_name | 文件名称,文件合并命名文件名称时用 |
chunck_size | 文件分片大小 |
const insertFile = async (md5,name,totalNumber,chunkSize,chunckNumber) => {
const sql = `INSERT INTO fileupload.chunck_list (file_hash,chunck_number,chunck_size,chunck_total_number,file_name) VALUES ('${md5}','${chunckNumber}','${chunkSize}','${totalNumber}','${name}')`;
const result = await connection.query(sql);
console.log(result + '数据插入成功')
}
router.post("/upload",upload.single("file"), async (ctx, next) => {
const {
totalNumber, // 分片总数
chunckNumber, // 分片序号
chunkSize, // 分片大小
md5, // 文件hash值(唯一)
name // 文件名称
} = ctx.req.body;
//指定hash文件路径
const chunckPath = path.join(uploadPath, md5,'/');
if(!fs.existsSync(chunckPath)){
fs.mkdirSync(chunckPath);
}
//移动文件到指定目录
fs.renameSync(ctx.req.file.path,chunckPath + md5 + '-' + chunckNumber);
insertFile(md5,name,totalNumber,chunkSize,chunckNumber)
ctx.status = 200;
ctx.res.end('Success');
})
/merge 接口:负责合并文件,思路是,读取对应MD5文件夹下所有的文件,判断分片总数是否和文件实际分片总数相同,相同则合并。在上传目录创建空文件,接着读取对应MD5文件夹,下面分片文件,将分片依次写入刚创建的空文件,最后移除已合并的文件碎片
router.post("/merge", async (ctx, next) => {
const {totalNumber,md5,name} = ctx.request.body;
try {
//分片存储得文件夹路径
const chunckPath = path.join(uploadPath, md5, '/');
//创建合并后的文件
console.log(name+'我是视频地址')
const filePath = path.join(uploadPath, name);
//读取对应hash文件夹下的所有分片文件名称
const chunckList = fs.existsSync(chunckPath) ? fs.readdirSync(chunckPath) : [];
console.log(chunckList+'我是视频地址')
//创建储存文件
fs.writeFileSync(filePath,'');
//判断切片是否完整
console.log(chunckList.length,totalNumber,'我是总地址,和分片地址')
if(chunckList.length !== totalNumber){
ctx.status = 500;
ctx.message = 'Merge failed, missing file slices';
// ctx.res.end('error');
process.exit();
}
for(let i = 0; i < totalNumber; i++){
const chunck = fs.readFileSync(chunckPath +md5+ '-' + i);
//写入当前切片
fs.appendFileSync(filePath,chunck);
//删除已合并的切片
fs.unlinkSync(chunckPath + md5 + '-' + i);
}
//删除空文件夹
fs.rmdirSync(chunckPath);
ctx.status = 200;
ctx.message = 'success';
}catch (e) {
ctx.status = 500;
ctx.res.end('合并失败');
}
})
/checkChuncks 接口:主要查询对应md5文件,已上传的分片,然后正序排列 chunck_number 返回给前端,思路是之前插入了对应MD5,上传成功的分片。并记录了 chunck_number 字段为上传成功的索引,现在只用查出来给前端,前端去分辨接下来需要上传哪些片段
router.post("/checkChuncks", async (ctx, next) => {
try {
const {md5} = ctx.request.body;
const queryResult = await new Promise((resolve,reject)=>{
const query = `SELECT (SELECT count(*) FROM chunck_list WHERE file_hash = '${md5}') as all_count, id as chunck_id,file_hash,chunck_number,chunck_total_number FROM chunck_list WHERE file_hash = '${md5}' GROUP BY id ORDER BY chunck_number`;
connection.query(query,async (error,results,fields)=>{
if(error) reject(error);
resolve(results || []);
});
})
ctx.status = 200;
ctx.body = queryResult;
}catch (e) {
ctx.status = 500;
ctx.res.end('error');
}
})
自此服务端代码完结,这里只是一个简单的demo,接口缺乏严谨性,小伙伴可以自行完善!
最后贴一下数据库的表结构,就一张简单的表:
CREATE TABLE `chunck_list` (
`id` int NOT NULL AUTO_INCREMENT,
`file_hash` varchar(255) DEFAULT NULL COMMENT '文件的唯一以hash值',
`chunck_number` int DEFAULT NULL COMMENT '分片序号',
`chunck_size` varchar(255) DEFAULT NULL COMMENT '分片大小',
`file_name` varchar(255) DEFAULT NULL COMMENT '文件名称',
`chunck_total_number` varchar(255) DEFAULT NULL COMMENT '总分片数',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=55902 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
以上就是关于前后端实现大文件断点续传、分片上传、秒传的完整实例相关的全部内容,希望对你有帮助。欢迎持续关注潘子夜个人博客(www.panziye.com),学习愉快哦!