前端实现大文件上传(文件分片、文件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…...

Docker 离线安装指南
参考文章 1、确认操作系统类型及内核版本 Docker依赖于Linux内核的一些特性,不同版本的Docker对内核版本有不同要求。例如,Docker 17.06及之后的版本通常需要Linux内核3.10及以上版本,Docker17.09及更高版本对应Linux内核4.9.x及更高版本。…...

Spark 之 入门讲解详细版(1)
1、简介 1.1 Spark简介 Spark是加州大学伯克利分校AMP实验室(Algorithms, Machines, and People Lab)开发通用内存并行计算框架。Spark在2013年6月进入Apache成为孵化项目,8个月后成为Apache顶级项目,速度之快足见过人之处&…...

(十)学生端搭建
本次旨在将之前的已完成的部分功能进行拼装到学生端,同时完善学生端的构建。本次工作主要包括: 1.学生端整体界面布局 2.模拟考场与部分个人画像流程的串联 3.整体学生端逻辑 一、学生端 在主界面可以选择自己的用户角色 选择学生则进入学生登录界面…...

Python:操作 Excel 折叠
💖亲爱的技术爱好者们,热烈欢迎来到 Kant2048 的博客!我是 Thomas Kant,很开心能在CSDN上与你们相遇~💖 本博客的精华专栏: 【自动化测试】 【测试经验】 【人工智能】 【Python】 Python 操作 Excel 系列 读取单元格数据按行写入设置行高和列宽自动调整行高和列宽水平…...
java 实现excel文件转pdf | 无水印 | 无限制
文章目录 目录 文章目录 前言 1.项目远程仓库配置 2.pom文件引入相关依赖 3.代码破解 二、Excel转PDF 1.代码实现 2.Aspose.License.xml 授权文件 总结 前言 java处理excel转pdf一直没找到什么好用的免费jar包工具,自己手写的难度,恐怕高级程序员花费一年的事件,也…...

Swift 协议扩展精进之路:解决 CoreData 托管实体子类的类型不匹配问题(下)
概述 在 Swift 开发语言中,各位秃头小码农们可以充分利用语法本身所带来的便利去劈荆斩棘。我们还可以恣意利用泛型、协议关联类型和协议扩展来进一步简化和优化我们复杂的代码需求。 不过,在涉及到多个子类派生于基类进行多态模拟的场景下,…...

【Redis技术进阶之路】「原理分析系列开篇」分析客户端和服务端网络诵信交互实现(服务端执行命令请求的过程 - 初始化服务器)
服务端执行命令请求的过程 【专栏简介】【技术大纲】【专栏目标】【目标人群】1. Redis爱好者与社区成员2. 后端开发和系统架构师3. 计算机专业的本科生及研究生 初始化服务器1. 初始化服务器状态结构初始化RedisServer变量 2. 加载相关系统配置和用户配置参数定制化配置参数案…...

【OSG学习笔记】Day 16: 骨骼动画与蒙皮(osgAnimation)
骨骼动画基础 骨骼动画是 3D 计算机图形中常用的技术,它通过以下两个主要组件实现角色动画。 骨骼系统 (Skeleton):由层级结构的骨头组成,类似于人体骨骼蒙皮 (Mesh Skinning):将模型网格顶点绑定到骨骼上,使骨骼移动…...

自然语言处理——Transformer
自然语言处理——Transformer 自注意力机制多头注意力机制Transformer 虽然循环神经网络可以对具有序列特性的数据非常有效,它能挖掘数据中的时序信息以及语义信息,但是它有一个很大的缺陷——很难并行化。 我们可以考虑用CNN来替代RNN,但是…...
3403. 从盒子中找出字典序最大的字符串 I
3403. 从盒子中找出字典序最大的字符串 I 题目链接:3403. 从盒子中找出字典序最大的字符串 I 代码如下: class Solution { public:string answerString(string word, int numFriends) {if (numFriends 1) {return word;}string res;for (int i 0;i &…...