前端实现大文件上传(文件分片、文件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"><inputref="fileInput"type="file"name="file"accept="application/pdf"><buttontype="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 MBprocessVal: 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类型继承BlobsplitedFileArr.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(); //异步 APIreader.readAsArrayBuffer(new Blob(chunks));reader.onload = (e: Event) => {spark.append((e.target as any).result as ArrayBuffer);resolve(spark.end());};reader.onerror = () => {reject('');};})},// 生成 formDatagenFormDataByChunkInfo(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: booleanisFileExist: 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); // 过滤掉已经上传的chunklet formDataArr = this.genFormDataByChunkInfo(chunkList);let allPromises = formDataArr.map((formData) => {let source = cancelControlReq.createSource(); // 生成sourcecancelControlReq.addCancelReq(source); //添加 sourcereturn 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类型继承BlobsplitedFileArr.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(); //异步 APIreader.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: booleanisFileExist: 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); // 过滤掉已经上传的chunklet formDataArr = this.genFormDataByChunkInfo(chunkList);let allPromises = formDataArr.map((formData) => {let source = cancelControlReq.createSource(); // 生成sourcecancelControlReq.addCancelReq(source); //添加 sourcereturn 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; // 获取 chunklet index = req.body?.index;let fileName = req.body?.fileName;let fileHash = req.body?.fileHash; // 文件 hashlet 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,表示服务器内部错误。
效果

相关文章:
前端实现大文件上传(文件分片、文件hash、并发上传、断点续传、进度监控和错误处理,含nodejs)
大文件分片上传是前端一种常见的技术,用于提高大文件上传的效率和可靠性。主要原理和步骤如下 文件分片 确定分片大小:确定合适的分片大小。通常分片大小在 1MB 到 5MB 之间使用 Blob.slice 方法:将文件分割成多个分片。每个分片可以使用 Bl…...
es单机安装脚本自动化
背景 所有部署工作都可以由机器本身完成,并不需要人的参与,人唯一需要做的是把变量提取出来,进行赋值喂给脚本,然后脚本自己执行即可。下边是es单机安装的过程和脚本,由人变到脚本执行,方便理解。 步骤 1、解压es软件tar包。 2、cd至解压以后得config目录下,vim修改…...
Java 数据库连接 - Sqlite
Java 数据库连接 - Sqlite PS: 1. 连接依赖库:[sqlite-jdbc-xxx.jar](https://mvnrepository.com/artifact/org.xerial/sqlite-jdbc)(根据连接的数据库版本选择) 2. 支持一次连接执行多次sql语句; 3. 仅本地连接;使用说明: publ…...
CentOS — 目录管理
文章目录 一、目录结构二、切换目录三、查看目录四、创建目录五、复制目录六、剪切目录七、删除目录 目录也是一种文件。 蓝色目录,绿色可执行文件,红色压缩文件,浅蓝色链接文件,灰色其它文件, 点开头的是隐藏文件&…...
【第二部分--Python之基础】04 函数
1 定义函数 自定义函数的语法格式如下: 以英文半角冒号结尾 由于定义函数时的参数不是实际数据,会在调用函数时传递给它们实际数据,所以我们称定义函数时的参数为形式参数,简称形参:称调用函数时传递的实际数据为实际参数&#x…...
我们公司只有3个人,一个前端,一个后端
在当今这个数字化时代,各行各业都离不开互联网技术的支撑,而在这股技术浪潮中,小而美的创业公司如同雨后春笋般涌现,它们凭借着灵活高效、创新不断的特点,在市场中占有一席之地。 今天,就让我带你走进这样一…...
基于LabVIEW的BeamGage自动化接口应用
设置 National Instruments LabVIEW可执行程序需要被配置为使用.NET 4框架。.NET允许自定义可执行程序的运行方式。可通过以下方式实现: 在LabVIEW安装目录中创建一个名为LabVIEW.exe.config的文本文件(例如:C:\Program Files\National Ins…...
【AI编辑器】Cursor与DeepSeek模型的集成:提升开发效率的新选择
目录 一、为什么选择DeepSeek模型 1.1 模型参数与训练 1.2 技术创新 1、FP8格式介绍 2、FP8混合精度训练的优势 3、FP8混合精度训练的技术要点 4、FP8混合精度训练的应用与挑战 1.3 性能表现 1.4 应用与部署 1.5 争议与前景 二、注册DeepSeek账号并获取API Key 三、…...
vue2实现excel文件预览
一、插件 通过xlsx插件解析excel数据,对解析后的html组件进行渲染展示。 npm install xlsx 二、完整代码 <template><!-- excel文件预览 --><divelement-loading-text"拼命加载中"element-loading-spinner"el-icon-loading"…...
STM32 和 ESP32
STM32 和 ESP32 是两种不同的微控制器系列,它们分别由不同的制造商生产,并且针对的应用场景和特性也有所不同。尽管如此,两者也有一些共通点,因为它们都是用于嵌入式系统开发的微控制器平台。以下是关于 STM32 和 ESP32 的联系与区…...
R语言中的时间序列分析·
1 数据集说明 AirPassengers 1949~1960年每月乘坐飞机的乘客数 JohnsonJohnson Johnson&Johnson每股季度收入 nhtemp 康涅狄格州纽黑文地区从1912年至1971年每年的平均气温 Nile 尼罗河的流量 sunspots 1749年~1983年月平均太阳黑子数 2 相关包 xts、forecast、tser…...
QML学习(六) anchors锚点和坐标,以及anchors锚点的使用
先来看看上一篇文章中的代码和效果 上一篇中讲到,第一个QML程序虽然做出来了,但程序界面里边元素的显示位置跟预想的不一样,这其实就是整体上对QML中的坐标使用存在问题。 改成这样,全以锚点来控制各个元素的坐标 import QtQuic…...
BFS广度优先搜索详解
对于BFS的,我来谈一谈自己的理解。首先,我们从一道最基础的题来进行学习: 洛谷B3625 迷宫寻路(仔细阅读哦,我就不解释了) B3625 迷宫寻路 - 洛谷 | 计算机科学教育新生态 对于这道题以及所有的BFS题目的核心&#x…...
vue项目利用webpack进行优化案例
使用 Webpack 优化 Vue 项目是提升性能和减少打包体积的关键步骤。以下是几个常见的优化案例及其详细实现方法: 1. 优化打包大小 1.1 按需加载 (Lazy Loading) Vue 提供了路由懒加载功能,可以将组件拆分成独立的块,按需加载,从而…...
如何单独安装 MATLAB 工具箱
很多时候由于 MATLAB 太大而选择安装一些 Toolbox,但用着用着发现要用到某个没有安装的 Toolbox,这时候就需要再单独安装这个 Toolbox,下面提供两种方法。 本文以安装 系统辨识工具箱 System Identification Toolbox 为例。 方法一…...
组网实训实现
小型单元网络实现 IP划分: 外网:172.1.1.0/24 172.1.2.0/24 内网:基于192.168.3.0/24的子网划分 综合办公楼:192.168.3.00 000000 /26(192.168.3.0-192.168.3.63) 综合一楼:192.168.3.0000 0000 /28&…...
openbmc sdk09.03 适配(一)
1.说明 本节是根据最新的sdk09.03适配ast2600平台。 sdk下载路径为: https://github.com/AspeedTech-BMC/openbmc可参阅文档: https://blog.csdn.net/wit_yuan/article/details/144613247nfs挂载方法: # mount -o nolock -t nfs serverip:/xx...
SQL使用存储过程
本文介绍什么是存储过程,为什么要使用存储过程,如何使用存储过程,以及创建和使用存储过程的基本语法。 1. 存储过程 迄今为止,我们使用的大多数SQL语句都是针对一个或多个表的单条语句。并非所有操作都这么简单,经常…...
C语言----函数、指针、数组
目录 编辑 指针函数 本质 格式: 函数指针 1、 概念 2、 格式 3、 举例 3.1基本用法 3.2函数指针作为函数参数的用法(回调函数) 函数指针数组 1. 概念 2. 格式 3. 例子 指针函数 本质 是函数,返回值为指针 格式: 数据类型…...
基于Java的敬老院管理系统的设计和实现【源码+文档+部署讲解】
基于Java的敬老院管理系统设计和实现 摘 要 新世纪以来,互联网与计算机技术的快速发展,我国也迈进网络化、集成化的信息大数据时代。对于大众而言,单机应用早已成为过去,传统模式早已满足不了当下办公生活等多种领域的需求,在一台电脑上不联网的软件少之又少&#x…...
从零开始:LabelImg图像标注工具的完整实战指南
从零开始:LabelImg图像标注工具的完整实战指南 【免费下载链接】labelImg LabelImg is now part of the Label Studio community. The popular image annotation tool created by Tzutalin is no longer actively being developed, but you can check out Label Stu…...
大模型遇“知识盲区“?RAG让它秒变“开卷考试“学霸!
过去一年,在落地RAG过程中,发现一个有意思的现象:很多人把AI当成了"万能百科全书",结果一问企业内部数据就抓瞎。 你有没有遇到过这样的情况: 问ChatGPT:“我们公司去年的销售额是多少࿱…...
Chandra AI性能调优:GPU显存优化全攻略
Chandra AI性能调优:GPU显存优化全攻略 1. 引言 跑大模型最头疼的是什么?对,就是那个让人又爱又恨的GPU显存!明明买了张不错的显卡,结果跑个模型就提示"Out of Memory",这种经历想必很多朋友都…...
FUTURE POLICE赋能在线教育:AI助教自动批改口语作业
FUTURE POLICE赋能在线教育:AI助教自动批改口语作业 每次上完英语口语课,最头疼的是什么?对很多学生来说,是等待老师批改作业的漫长过程,还有那千篇一律的“发音不错,继续努力”的反馈。对老师而言&#x…...
Python MCP服务部署成本飙升?5个被90%团队忽略的隐性开销及实时监控方案
第一章:Python MCP服务部署成本飙升的真相与警示Python MCP(Model Control Plane)服务在微服务架构中承担模型注册、版本调度、A/B测试路由等关键职责。近期大量团队反馈其云上部署成本在两周内激增300%以上,远超业务增长曲线。深…...
Spring Boot 与 GraphQL 2.0 集成:构建现代化 API
Spring Boot 与 GraphQL 2.0 集成:构建现代化 API 引言 在现代 Web 开发中,API 设计变得越来越重要。传统的 RESTful API 在面对复杂的数据查询需求时,往往会面临过度获取或获取不足的问题。GraphQL 作为一种新型的 API 查询语言,…...
CentOS7虚拟机网络配置全攻略:从ifconfig不显示ens33到FinalShell成功连接
CentOS7虚拟机网络配置全攻略:从ifconfig不显示ens33到FinalShell成功连接 刚接触Linux虚拟机的开发者或运维新手,经常会遇到一个令人头疼的问题:启动CentOS7虚拟机后,输入ifconfig命令,发现根本没有显示ens33网卡信息…...
React Native PagerView入门指南:5分钟快速搭建页面切换组件
React Native PagerView入门指南:5分钟快速搭建页面切换组件 【免费下载链接】react-native-pager-view React Native wrapper for the Android ViewPager and iOS UIPageViewController. 项目地址: https://gitcode.com/gh_mirrors/re/react-native-pager-view …...
梦幻动漫魔法工坊快速上手:无需代码,网页端直接生成动漫图像
梦幻动漫魔法工坊快速上手:无需代码,网页端直接生成动漫图像 你是否也曾幻想过,用几句话就能召唤出脑海中的梦幻场景?一个可爱的猫耳少女,在樱花树下回眸;或是奇幻的魔法森林里,精灵在月光下起…...
内存暴涨却查无踪迹?Python对象生命周期管理的7个致命盲区,现在不看明天宕机!
第一章:Python智能体内存管理的核心原理Python智能体(如基于LangChain、LlamaIndex构建的Agent)在运行过程中并非仅依赖语言模型推理,其内存管理机制直接决定状态持久性、上下文感知能力与多轮交互一致性。核心在于Python对象生命…...
