前端实现大文件上传(文件分片、文件hash、并发上传、断点续传、进度监控和错误处理,含nodejs)
大文件分片上传是前端一种常见的技术,用于提高大文件上传的效率和可靠性。主要原理和步骤如下
- 文件分片
- 确定分片大小:确定合适的分片大小。通常分片大小在 1MB 到 5MB 之间
- 使用 Blob.slice 方法:将文件分割成多个分片。每个分片可以使用 Blob.slice 方法从文件对象中切出
- 文件哈希
计算哈希值:- 使用 Web Workers 来计算每个分片的哈希值,以避免阻塞主线程。(可以根据业务方向进行选择)
- 使用 spark-md5 库来计算 MD5 哈希值
- 并发上传
使用 Promise.all 或 async/await 来同时上传多个分片,或者使用plimit进行并发管理 - 断点续传
- 记录已上传的分片:使用本地存储(如 localStorage 或 IndexedDB)记录已上传的分片信息(根据业务情况而定)
- 在上传前,向服务器查询已上传的分片,只上传未完成的分片
- 重试机制:对于上传失败的分片,可以设置重试次数,并在重试失败后提示用户 (根据业务情况而定)
- 进度监控
监听上传进度:- 使用 XMLHttpRequest 的 upload.onprogress 事件或 Fetch API 的 ReadableStream 来监听上传进度,或者通过后端返回已上传内容进行计算
- 计算每个分片的上传进度,并累加到总进度中
- 错误处理
在上传过程中捕获网络错误、服务器错误等,并进行相应的处理
大文件上传源码及其解析
示例代码和上面原理步骤实现可能有点不同(根据业务情况进行修改),但整体流程一致
HTML布局
<div class="kh-idx">
<div class="kh-idx-banner">
{{ msg }}
</div>
<form id="uploadForm" class="kh-idx-form">
<input
ref="fileInput"
type="file"
name="file"
accept="application/pdf"
>
<button
type="button"
@click="uploadFile"
>
Upload File
</button>
</form>
<progress v-if="processVal" :value="processVal" max="100"></progress>
</div>
CSS
.kh-idx {
&-banner {
background-color: brown;
color: aliceblue;
text-align: center;
}
&-form {
margin-top: 20px;
}
}
TS 逻辑
import { defineComponent } from 'vue';
import sparkMD5 from 'spark-md5';
import pLimit from 'p-limit';
import { postUploadFile, postUploadFileCheck } from '@client/api/index';
import axios, { CancelTokenSource } from 'axios';
/**
* 前端大文件上传技术点
* 1.文件切片(Chunking):将大文件分割成多个小片段(切片),这样可以减少单次上传的数据量,降低上传失败的概率,并支持断点续传。
* 2.文件hash:助验证文件的完整性和唯一性
* 3.并发上传:利用JavaScript的异步特性,可以同时上传多个文件切片,提高上传效率。
* 4.断点续传:在上传过程中,如果发生中断,下次再上传可以从中断点继续上传,而不是重新上传整个文件。这通常通过记录已上传的切片索引来实现。
* 5.进度监控:通过监听上传事件,可以实时获取上传进度,并显示给用户。
* 6.错误处理:在上传过程中,要及时处理可能出现的错误,如网络错误、服务器错误等
*/
export default defineComponent({
name: 'KhIndex',
data() {
return {
msg: '文件上传demo',
chunkSize: 5 * 1024 * 1024, // 设置分片大小 5 MB
processVal: 0
};
},
methods: {
// 分割文件
splitFileByChunkSize(file: File, chunkSize: number) {
let splitedFileArr = [];
let fileSize = file.size; // 获取文件大小
let totalChunkNumber = Math.ceil(fileSize / chunkSize); // 向上取整获取 chunk 数量
for (let i = 0; i < totalChunkNumber; i++) {
// File类型继承Blob
splitedFileArr.push(file.slice(i * chunkSize, (i + 1) * chunkSize));
}
return {
originFile: file,
name: file.name,
splitedFile: splitedFileArr
}
},
// 计算分割后的文件 hash 值
calcuateFileHash(splitedFiles: Array<Blob>, chunkSize: number): Promise<string> {
let spark = new sparkMD5.ArrayBuffer();
let chunks: Blob[] = [];
splitedFiles.forEach((chunk, idx) => {
if (
idx === 0 ||
idx === splitedFiles.length - 1
) {
chunks.push(chunk);
} else {
// 中间剩余切片分别在前面、后面和中间取2个字节参与计算
chunks.push(chunk.slice(0, 2)); // 前面的2字节
chunks.push(chunk.slice(chunkSize / 2, (chunkSize / 2) + 2)); // 中间的2字节
chunks.push(chunk.slice(chunkSize - 2, chunkSize)); // 后面的2字节
}
});
return new Promise((resolve, reject) => {
let reader = new FileReader(); //异步 API
reader.readAsArrayBuffer(new Blob(chunks));
reader.onload = (e: Event) => {
spark.append((e.target as any).result as ArrayBuffer);
resolve(spark.end());
};
reader.onerror = () => {
reject('');
};
})
},
// 生成 formData
genFormDataByChunkInfo(chunkList: Array<{
fileName: string,
fileHash: string,
index: number,
chunk: Blob,
chunkHash: string,
size: number,
chunkTotal: number
}>) {
return chunkList.map(({
fileName,
fileHash,
index,
chunk,
chunkHash,
chunkTotal,
size
}) => {
let formData = new FormData();
formData.append('chunk', chunk);
formData.append('chunkHash', chunkHash);
formData.append('size', String(size));
formData.append('chunkTotal', String(chunkTotal));
formData.append('fileName', fileName);
formData.append('fileHash', fileHash);
formData.append('index', String(index));
return formData;
});
},
// 取消请求
createReqControl() {
let cancelToken = axios.CancelToken;
let cancelReq: CancelTokenSource[] = [];
return {
addCancelReq(req: CancelTokenSource) {
cancelReq.push(req);
},
cancelAllReq(msg = '已取消请求') {
cancelReq.forEach((req) => {
req.cancel(msg); // 全部取消后续请求
})
},
createSource() {
return cancelToken.source();
},
print() {
console.log(cancelReq);
}
}
},
// 上传文件前的检查
async uploadFileCheck(
splitedFileObj: {
originFile: File,
name: string,
splitedFile: Array<Blob>
},
fileMd5: string
): Promise<{
isError: boolean
isFileExist: boolean,
uploadedChunks: []
}> {
try {
let { data } = await postUploadFileCheck({
fileHash: fileMd5,
chunkTotal: splitedFileObj.splitedFile.length,
fileName: splitedFileObj.name
});
if (
data.code === 200
&& !(data.result?.isFileExist)
) {
return {
isError: false,
isFileExist: data.result?.isFileExist,
uploadedChunks: data.result.uploadedChunks
};
}
return {
isError: true,
isFileExist: false,
uploadedChunks: []
};
} catch (error) {
return {
isError: true,
isFileExist: false,
uploadedChunks: []
}
}
},
// 并发请求
async uploadFilesConcurrently(
splitedFileObj: {
originFile: File,
name: string,
splitedFile: Array<Blob>
},
fileMd5: string,
concurrentNum = 3,
uploadedChunks: Array<number>
) {
let cancelControlReq = this.createReqControl();
const LIMIT_FUN = pLimit(concurrentNum); // 初始化并发限制
let fileName = splitedFileObj.name; // 文件名
let chunkTotalNum = splitedFileObj.splitedFile.length;
let chunkList = splitedFileObj.splitedFile
.map((chunk, idx) => {
if (uploadedChunks.includes(idx)) return null;
return {
fileName,
fileHash: fileMd5,
index: idx,
chunk,
chunkTotal: chunkTotalNum,
chunkHash: `${ fileMd5 }-${ idx }`,
size: chunk.size
}
}).filter((fileInfo) => fileInfo != null); // 过滤掉已经上传的chunk
let formDataArr = this.genFormDataByChunkInfo(chunkList);
let allPromises = formDataArr.map((formData) => {
let source = cancelControlReq.createSource(); // 生成source
cancelControlReq.addCancelReq(source); //添加 source
return LIMIT_FUN(() => new Promise(async (resolve, reject) => {
try {
let result = await postUploadFile(formData, source.token);
if (result.data.code === 100) {
cancelControlReq.cancelAllReq(); // 取消后续全部请求
}
if (
result.data.code === 201
|| result.data.code === 200
) {
let data = result.data.result;
this.setPropress(Number(data.uploadedChunks.length), Number(data.chunkTotal));
}
resolve(result);
} catch (error) {
this.setPropress(0, 0); // 关闭进度条
// 报错后取消后续请求
cancelControlReq.cancelAllReq(); // 取消后续全部请求
reject(error);
}
}));
})
return await Promise.all(allPromises);
},
// 设置进度条
setPropress(uploadedChunks: number, chunkTotal: number) {
this.processVal = (uploadedChunks / chunkTotal) * 100;
},
// 文件上传
async uploadFile() {
// 获取文件输入元素中的文件列表
let files = (this.$refs.fileInput as HTMLInputElement).files || [];
if (files.length <= 0) return;
// 将选择的文件按照指定的分片大小进行分片处理
let fileSplitedObj = this.splitFileByChunkSize(files[0], this.chunkSize);
// 计算整个文件的哈希值,用于后续的文件校验和秒传功能
let fileMd5 = await this.calcuateFileHash(fileSplitedObj.splitedFile, this.chunkSize);
// 检查服务器上是否已存在该文件的分片以及整个文件
let uploadedChunksObj = await this.uploadFileCheck(fileSplitedObj, fileMd5);
// 如果检查过程中发生错误,或者文件已存在,则直接返回
if (
!(!uploadedChunksObj.isError
&& !uploadedChunksObj.isFileExist)
) return;
// 并发上传文件分片,最多同时上传3个分片
let uploadFileResultArr = await this.uploadFilesConcurrently(
fileSplitedObj,
fileMd5,
3,
uploadedChunksObj.uploadedChunks
);
// 上传成功后,重置进度条
if (
uploadFileResultArr
&& Array.isArray(uploadFileResultArr)
) {
this.setPropress(0, 0);
}
}
}
});
uploadFile函数逻辑分析
检查是否选择了要上传的文件
let files = (this.$refs.fileInput as HTMLInputElement).files || [];
if (files.length <= 0) return; // 没有选择文件,后续就不走
文件分片
let fileSplitedObj = this.splitFileByChunkSize(files[0], this.chunkSize);
// 分割文件
splitFileByChunkSize(file: File, chunkSize: number) {
let splitedFileArr = [];
let fileSize = file.size; // 获取文件大小
let totalChunkNumber = Math.ceil(fileSize / chunkSize); // 向上取整获取 chunk 数量
for (let i = 0; i < totalChunkNumber; i++) {
// File类型继承Blob
splitedFileArr.push(file.slice(i * chunkSize, (i + 1) * chunkSize));
}
return {
originFile: file,
name: file.name,
splitedFile: splitedFileArr
}
},
splitFileByChunkSize 函数功能分析
- 初始化变量:
- splitedFileArr:用于存储分割后的文件分片数组。
- fileSize:获取文件的总大小。
- totalChunkNumber:计算文件需要被分割成的分片数量。通过文件大小除以每个分片的大小,然后向上取整得到。
- 文件分片:
- 使用 for 循环遍历每个分片。
- 在循环中,使用 file.slice 方法从文件中切出每个分片。file.slice 方法接受两个参数:起始位置和结束位置,分别对应当前分片的开始和结束字节。
- 将每个分片添加到 splitedFileArr 数组中。
- 返回结果:返回一个对象,包含原始文件、文件名和分割后的文件分片数组。
生成文件MD5
let fileMd5 = await this.calcuateFileHash(fileSplitedObj.splitedFile, this.chunkSize);
// 计算分割后的文件 hash 值
calcuateFileHash(splitedFiles: Array<Blob>, chunkSize: number): Promise<string> {
let spark = new sparkMD5.ArrayBuffer();
let chunks: Blob[] = [];
splitedFiles.forEach((chunk, idx) => {
if (
idx === 0 ||
idx === splitedFiles.length - 1
) {
chunks.push(chunk);
} else {
// 中间剩余切片分别在前面、后面和中间取2个字节参与计算
chunks.push(chunk.slice(0, 2)); // 前面的2字节
chunks.push(chunk.slice(chunkSize / 2, (chunkSize / 2) + 2)); // 中间的2字节
chunks.push(chunk.slice(chunkSize - 2, chunkSize)); // 后面的2字节
}
});
return new Promise((resolve, reject) => {
let reader = new FileReader(); //异步 API
reader.readAsArrayBuffer(new Blob(chunks));
reader.onload = (e: Event) => {
spark.append((e.target as any).result as ArrayBuffer);
resolve(spark.end());
};
reader.onerror = () => {
reject('');
};
})
},
calcuateFileHash 函数功能分析
- 初始化变量:
- spark:创建一个 sparkMD5.ArrayBuffer 实例,用于计算文件的 MD5 哈希值。
- chunks:初始化一个数组,用于存储参与哈希计算的文件片段。
- 选择文件片段:
- 遍历 splitedFiles 数组,该数组包含了文件的所有分片。
- 对于第一个和最后一个分片,直接将它们添加到 chunks 数组中。
- 对于中间的分片,只选择每个分片的前 2 个字节、中间的 2 个字节和最后的 2 个字节参与哈希计算。这样可以减少计算量,同时保持一定的哈希准确性。
- 读取文件片段:
- 创建一个 FileReader 实例,用于读取文件片段。
- 使用 FileReader.readAsArrayBuffer 方法将 chunks 数组中的文件片段读取为 ArrayBuffer 格式。
- 计算哈希值:
- 在 FileReader 的 onload 事件中,将读取到的 ArrayBuffer 数据添加到 spark 实例中。
- 调用 spark.end() 方法计算最终的 MD5 哈希值,并通过 resolve 回调函数返回该哈希值。
- 错误处理:
在 FileReader 的 onerror 事件中,如果读取文件片段发生错误,则通过 reject 回调函数返回一个空字符串,表示哈希计算失败。
检查是否已存在该文件的分片以及整个文件
let uploadedChunksObj = await this.uploadFileCheck(fileSplitedObj, fileMd5);
// 如果检查过程中发生错误,或者文件已存在,则直接返回
if (
!(!uploadedChunksObj.isError
&& !uploadedChunksObj.isFileExist)
) return;
// 上传文件前的检查
async uploadFileCheck(
splitedFileObj: {
originFile: File,
name: string,
splitedFile: Array<Blob>
},
fileMd5: string
): Promise<{
isError: boolean
isFileExist: boolean,
uploadedChunks: []
}> {
try {
let { data } = await postUploadFileCheck({
fileHash: fileMd5,
chunkTotal: splitedFileObj.splitedFile.length,
fileName: splitedFileObj.name
});
if (
data.code === 200
&& !(data.result?.isFileExist)
) {
return {
isError: false,
isFileExist: data.result?.isFileExist,
uploadedChunks: data.result.uploadedChunks
};
}
return {
isError: true,
isFileExist: false,
uploadedChunks: []
};
} catch (error) {
return {
isError: true,
isFileExist: false,
uploadedChunks: []
}
}
},
uploadFileCheck 函数功能分析
- 参数接收:
- splitedFileObj:包含原始文件信息和分割后的文件分片数组的对象。
- originFile:原始文件对象。
- name:文件名。
- splitedFile:分割后的文件分片数组。
- fileMd5:文件的哈希值。
- splitedFileObj:包含原始文件信息和分割后的文件分片数组的对象。
- 发送请求:
使用 postUploadFileCheck 函数(假设这是一个封装好的 HTTP POST 请求函数)向服务器发送文件上传前的检查请求。
请求体中包含文件的哈希值 fileHash、分片总数 chunkTotal 和文件名 fileName。 - 处理响应:
- 如果服务器返回的状态码为 200 且文件不存在(data.result?.isFileExist 为 false),则返回一个对象,表示没有错误发生,文件不存在,以及已上传的分片列表 uploadedChunks。
- 如果服务器返回的状态码不是 200 或文件已存在,则返回一个对象,表示发生了错误,文件不存在,已上传的分片列表为空。
- 错误处理:
如果在发送请求或处理响应过程中发生错误(例如网络错误或服务器错误),则捕获错误并返回一个对象,表示发生了错误,文件不存在,已上传的分片列表为空。
并发上传
// 上传文件
let uploadFileResultArr = await this.uploadFilesConcurrently(
fileSplitedObj,
fileMd5,
3,
uploadedChunksObj.uploadedChunks
);
// 并发请求
async uploadFilesConcurrently(
splitedFileObj: {
originFile: File,
name: string,
splitedFile: Array<Blob>
},
fileMd5: string,
concurrentNum = 3,
uploadedChunks: Array<number>
) {
let cancelControlReq = this.createReqControl();
const LIMIT_FUN = pLimit(concurrentNum); // 初始化并发限制
let fileName = splitedFileObj.name; // 文件名
let chunkTotalNum = splitedFileObj.splitedFile.length;
let chunkList = splitedFileObj.splitedFile
.map((chunk, idx) => {
if (uploadedChunks.includes(idx)) return null;
return {
fileName,
fileHash: fileMd5,
index: idx,
chunk,
chunkTotal: chunkTotalNum,
chunkHash: `${ fileMd5 }-${ idx }`,
size: chunk.size
}
}).filter((fileInfo) => fileInfo != null); // 过滤掉已经上传的chunk
let formDataArr = this.genFormDataByChunkInfo(chunkList);
let allPromises = formDataArr.map((formData) => {
let source = cancelControlReq.createSource(); // 生成source
cancelControlReq.addCancelReq(source); //添加 source
return LIMIT_FUN(() => new Promise(async (resolve, reject) => {
try {
let result = await postUploadFile(formData, source.token);
if (result.data.code === 100) {
cancelControlReq.cancelAllReq(); // 取消后续全部请求
}
if (
result.data.code === 201
|| result.data.code === 200
) {
let data = result.data.result;
this.setPropress(Number(data.uploadedChunks.length), Number(data.chunkTotal));
}
resolve(result);
} catch (error) {
this.setPropress(0, 0); // 关闭进度条
// 报错后取消后续请求
cancelControlReq.cancelAllReq(); // 取消后续全部请求
reject(error);
}
}));
})
return await Promise.all(allPromises);
},
uploadFilesConcurrently 函数功能分析
- 初始化并发控制:
- cancelControlReq:创建一个请求控制对象,用于管理上传请求的取消操作。
- LIMIT_FUN:使用 pLimit 函数初始化并发限制,concurrentNum 指定了同时上传的最大分片数量,默认为 3。
- 准备上传数据:
- fileName:获取文件名。
- chunkTotalNum:获取分片总数。
- chunkList:将分片数组映射为包含上传所需信息的对象数组。每个对象包含文件名、文件哈希值、分片索引、分片数据、分片总数、分片哈希值和分片大小。过滤掉已上传的分片。
- 生成表单数据:
- formDataArr:调用 genFormDataByChunkInfo 方法,根据分片信息生成对应的 FormData 对象数组。
- 创建并发上传任务:
- 使用 map 方法遍历 formDataArr,为每个分片创建一个上传任务。
- source:为每个上传任务生成一个取消令牌 source,并将其添加到请求控制对象中。
- LIMIT_FUN:使用 pLimit 函数限制并发上传的数量。
- 在每个上传任务中,使用 postUploadFile 函数发送上传请求,并传递 FormData 和取消令牌。
- 如果上传成功,更新上传进度。如果上传失败,取消后续所有上传请求,并返回错误。
- 等待所有上传任务完成:
使用 Promise.all 等待所有上传任务完成,返回一个包含所有上传结果的数组。
nodeJs 逻辑
index入口文件
const EXPRESS = require('express');
const PATH = require('path');
const HISTORY = require('connect-history-api-fallback');
const COMPRESSION = require('compression');
const REQUEST = require('./routes/request');
const ENV = require('./config/env');
const APP = EXPRESS();
const PORT = 3000;
APP.use(COMPRESSION());// 开启gzip压缩
// 设置静态资源缓存
const SERVE = (path, maxAge) => EXPRESS.static(path, { maxAge });
APP.use(EXPRESS.json());
APP.all('*', (req, res, next) => {
res.header("Access-Control-Allow-Origin","*");
res.header("Access-Control-Allow-Headers","Content-Type");
res.header("Access-Control-Allow-Methods","*");
next()
});
APP.use(REQUEST);
APP.use(HISTORY());// 重置单页面路由
APP.use('/dist', SERVE(PATH.resolve(__dirname, '../dist'), ENV.maxAge));
//根据环境变量使用不同环境配置
APP.use(require(ENV.router));
APP.listen(PORT, () => {
console.log(`APP listening at http://localhost:${PORT}\n`);
});
request处理请求
const express = require('express');
const requestRouter = express.Router();
const { resolve, join } = require('path');
const multer = require('multer');
const UPLOAD_DIR = resolve(__dirname, '../upload');
const UPLOAD_FILE_DIR = join(UPLOAD_DIR, 'files');
const UPLOAD_MULTER_TEMP_DIR = join(UPLOAD_DIR, 'multerTemp');
const upload = multer({ dest: UPLOAD_MULTER_TEMP_DIR });
const fse = require('fs/promises');
const fs = require('fs');
require('events').EventEmitter.defaultMaxListeners = 20; // 将默认限制增加到
// 合并chunks
function mergeChunks(
fileName,
tempChunkDir,
destDir,
fileHash,
chunks,
cb
) {
let writeStream = fs.createWriteStream(`${ destDir }/${ fileHash }-${ fileName }`);
writeStream.on('finish', async () => {
writeStream.close(); // 关闭
try {
await fse.rm(tempChunkDir, { recursive: true, force: true });
} catch (error) {
console.error(tempChunkDir, error);
}
})
let readStreamFun = function(chunks, cb) {
try {
let val = chunks.shift();
let path = join(tempChunkDir, `${ fileHash }-${ val }`);
let readStream = fs.createReadStream(path);
readStream.pipe(writeStream, { end: false });
readStream.once('end', () => {
console.log('path', path);
if(fs.existsSync(path)) {
fs.unlinkSync(path);
}
if (chunks.length > 0) {
readStreamFun(chunks, cb);
} else {
cb();
}
});
} catch (error) {
console.error( error);
}
}
readStreamFun(chunks, () => {
cb();
writeStream.end();
});
}
// 判断当前文件是否已经存在
function isFileOrDirInExist(filePath) {
return fs.existsSync(filePath);
};
// 删除文件夹内的内容胆保留文件夹
function rmDirContents(dirPath) {
fs.readdirSync(dirPath).forEach(file => {
let curPath = join(dirPath, file);
if (fs.lstatSync(curPath).isDirectory()) {
rmDirContents(curPath);
} else {
fs.unlinkSync(curPath);
}
});
}
// 获取已上传chunks序号
async function getUploadedChunksIdx(tempChunkDir, fileHash) {
if (!isFileOrDirInExist(tempChunkDir)) return []; // 不存在直接返回[]
let uploadedChunks = await fse.readdir(tempChunkDir);
let uploadedChunkArr = uploadedChunks.filter(file => file.startsWith(fileHash + '-'))
.map(file => parseInt(file.split('-')[1], 10));
return [ ...(new Set(uploadedChunkArr.sort((a, b) => a - b))) ];
}
requestRouter.post('/api/upload/check', async function(req, res) {
try {
let fileHash = req.body?.fileHash;
let chunkTotal = req.body?.chunkTotal;
let fileName = req.body?.fileName;
let tempChunkDir = join(UPLOAD_DIR, 'temp', fileHash); // 存储切片的临时文件夹
if (!fileHash || chunkTotal == null) {
return res.status(200).json({
code: 400,
massage: '缺少必要的参数',
result: null
});
}
let isFileExist = fs.existsSync(join(UPLOAD_FILE_DIR, `${ fileHash }-${ fileName }`));
// 如果文件存在,则清除temp中临时文件和文件夹
if (isFileExist) {
// 当前文件夹存在
if (fs.existsSync(tempChunkDir)){
fs.rmSync(tempChunkDir, { recursive: true, force: true });
}
return res.status(200).json({
code: 200,
massage: '成功',
result: {
uploadedChunks: [],
isFileExist
}
})
}
let duplicateUploadedChunks = await getUploadedChunksIdx(tempChunkDir, fileHash);
return res.status(200).json({
code: 200,
massage: '成功',
result: {
uploadedChunks: duplicateUploadedChunks,
isFileExist
}
});
} catch (error) {
console.error(error);
rmDirContents(UPLOAD_MULTER_TEMP_DIR); // 删除临时文件
return res.status(500).end();
}
});
requestRouter.post('/api/upload', upload.single('chunk'), async function (req, res) {
try {
let chunk = req.file; // 获取 chunk
let index = req.body?.index;
let fileName = req.body?.fileName;
let fileHash = req.body?.fileHash; // 文件 hash
let chunkHash = req.body?.chunkHash;
let chunkTotal = req.body?.chunkTotal; // chunk 总数
let tempChunkDir = join(UPLOAD_DIR, 'temp', fileHash); // 存储切片的临时文件夹
if (isFileOrDirInExist(join(UPLOAD_FILE_DIR, `${ fileHash }-${ fileName }`))) {
rmDirContents(UPLOAD_MULTER_TEMP_DIR); // 删除临时文件
return res.status(200).json({
code: 100,
massage: '该文件已存在',
result: fileHash
}).end();
}
// 切片目录不存在,则创建
try {
await fse.access(tempChunkDir, fse.constants.F_OK)
} catch (error) {
await fse.mkdir(tempChunkDir, { recursive: true });
}
if (!fileName || !fileHash) {
res.status(200).json({
code: 400,
massage: '缺少必要的参数',
result: null
});
}
await fse.rename(chunk.path, join(tempChunkDir, chunkHash));
let duplicateUploadedChunks = await getUploadedChunksIdx(tempChunkDir, fileHash); // 获取已上传的chunks
// 当全部chunks上传完毕后,进行文件合并
if (duplicateUploadedChunks.length === Number(chunkTotal)) {
mergeChunks(
fileName,
tempChunkDir,
UPLOAD_FILE_DIR,
fileHash,
duplicateUploadedChunks,
() => {
rmDirContents(UPLOAD_MULTER_TEMP_DIR); // 删除临时文件
res.status(200).json({
code: 200,
massage: '成功',
result: {
uploadedChunks: new Array(Number(chunkTotal)).fill().map((_, index) => index),
chunkTotal: Number(chunkTotal)
}
})
}
);
} else {
res.status(200).json({
code: 201,
massage: '部分成功',
result: {
uploadedChunks: duplicateUploadedChunks,
chunkTotal: Number(chunkTotal)
}
})
}
} catch (error) {
console.error(error);
rmDirContents(UPLOAD_MULTER_TEMP_DIR); // 删除临时文件
return res.status(500).end();
}
});
module.exports = requestRouter;
/api/upload/check接口分析
- 获取请求参数:
- 从请求体 req.body 中获取文件的哈希值 fileHash、分片总数 chunkTotal 和文件名 fileName。
- 参数验证:
- 检查是否获取到了必要的参数:fileHash 和 chunkTotal。如果缺少必要的参数,则返回状态码 200 和错误信息,提示缺少必要的参数。
- 文件存在性检查:
- 使用 fs.existsSync 方法检查服务器上是否已存在完整的文件(文件名由 fileHash 和 fileName 组成)。
- 如果文件已存在,则:
- 如果存在临时文件夹 tempChunkDir,则删除该临时文件夹及其内容。
- 返回状态码 200 和成功信息,提示文件已存在,并返回已上传的分片列表为空,以及文件存在状态为 true。
- 获取已上传的分片索引:
- 如果文件不存在,则调用 getUploadedChunksIdx 函数获取已上传的分片索引。
- 返回状态码 200 和成功信息,返回已上传的分片索引列表和文件存在状态为 false。
- 错误处理:
如果在处理过程中发生错误(例如文件系统操作失败),则捕获错误,删除临时文件夹 UPLOAD_MULTER_TEMP_DIR 的内容,并返回状态码 500,表示服务器内部错误。
/api/upload接口分析
- 获取请求参数和文件:
- 使用 upload.single(‘chunk’) 中间件从请求中获取单个文件分片 chunk。
- 从请求体 req.body 中获取分片索引 index、文件名 fileName、文件哈希值 fileHash、分片哈希值 chunkHash 和分片总数 chunkTotal。
- 检查文件是否已存在:
- 使用 isFileOrDirInExist 函数检查服务器上是否已存在完整的文件(文件名由 fileHash 和 fileName 组成)。
- 如果文件已存在,则删除临时文件夹 UPLOAD_MULTER_TEMP_DIR 的内容,并返回状态码 200 和成功信息,提示文件已存在,返回文件哈希值。
- 创建切片目录:
- 使用 fse.access 检查临时切片目录 tempChunkDir 是否存在,如果不存在,则使用 fse.mkdir 创建该目录。
- 参数验证:
- 检查是否获取到了必要的参数:fileName 和 fileHash。如果缺少必要的参数,则返回状态码 200 和错误信息,提示缺少必要的参数。
- 保存分片文件:
- 使用 fse.rename 将上传的分片文件重命名并移动到临时切片目录中,文件名使用分片哈希值 chunkHash。
- 获取已上传的分片索引:
- 调用 getUploadedChunksIdx 函数获取已上传的分片索引列表。
- 文件合并:
- 如果已上传的分片数量等于分片总数,则调用 mergeChunks 函数进行文件合并。
- 文件合并成功后,删除临时文件夹 UPLOAD_MULTER_TEMP_DIR 的内容,并返回状态码 200 和成功信息,返回已上传的分片列表和分片总数。
- 如果文件合并失败,返回状态码 500,表示服务器内部错误。
- 返回部分成功信息:
- 如果分片上传成功但未达到分片总数,则返回状态码 200 和部分成功信息,返回已上传的分片列表和分片总数。
- 错误处理:
- 如果在处理过程中发生错误(例如文件系统操作失败),则捕获错误,删除临时文件夹 UPLOAD_MULTER_TEMP_DIR 的内容,并返回状态码 500,表示服务器内部错误。