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

12306分流抢票软件 bypass v1.16.43 绿色版(春节自动抢票工具)
软件介绍 12306Bypass分流抢票软件,易操作强大的12306抢票软件,全程自动抢票,云识别验证码打码,多线程秒单、稳定捡漏,支持抢候补票、抢到票自动付款,支持多天、多车次、多席别、多乘客、短信提醒等功能。…...

【数据仓库】hadoop3.3.6 安装配置
文章目录 概述下载解压安装伪分布式模式配置hdfs配置hadoop-env.shssh免密登录模式设置初始化HDFS启动hdfs配置yarn启动yarn 概述 该文档是基于hadoop3.2.2版本升级到hadoop3.3.6版本,所以有些配置,是可以不用做的,下面仅记录新增操作&#…...

小试牛刀-SpringBoot集成SOL链
目录 一、什么是solanaj? 二、Pom依赖 三、主要类 3.1 RpcClient 3.2 PublicKey 3.3 Transaction 3.4 TransactionInstruction 四、示例代码 Welcome to Code Blocks blog 本篇文章主要介绍了 [小试牛刀-SpringBoot集成SOL链] ❤博主广交技术好友,喜欢文章的…...

批量插入报错: No value specified for parameter
先上代码和xml文件: 错误: ### Cause: java.sql.SQLException: No value specified for parameter 9 ; bad SQL grammar []; nested exception is java.sql.SQLException: No value specified for parameter 9代码: List<HwcListingData> theList new ArrayList<&g…...

VSCode设置ctrl或alt+mouse(left)跳转
总结: (1)VSCode初次远程连接服务器时,需要在服务器上下载 python 拓展,然后选择对应的环境 (2)VSCode设置ctrl或altmouse(left)跳转到定义...

Crosslink-NX应用连载(12):如何复用特殊功能管脚
作者:Hello,Panda 大家早上好。 昨天有朋友私信我,如何复用Crosslink-NX的特殊功能引脚如PROGRAMN、DONE、INITN诸如这些。熊猫君在这里简单介绍下: 以LIFCL-33U-8CTG104C为例,我们建立一个简单的指示灯LED周期闪烁的工程&…...

‘元素.style.样式名‘获取不到样式,应该使用Window.getComputedStyle()获取正真的样式
一、问题描述 有一次,想通过js获取一个元素的样式的某个属性状态而去执行不同的逻辑代码,结果发现获取的样式总是不对,基本为空。(通过元素.style.样式名的方式去获取。) 通过打印发现,所有的属性均存在&…...

双目视觉:reprojectImageTo3D函数
前言 reprojectImageTo3D 是 OpenCV 中用于从视差图生成三维点云的函数。它的原理是利用视差图和相机的校准参数,通过三角测量法,计算每个像素对应的三维坐标。以下内容根据源码分析所写,觉得可以的话,点赞收藏哈!&am…...

Arduino Uno简介与使用方法
目录 一、Arduino Uno概述 1. 硬件特性 2. 开发环境 二、Arduino Uno的基本使用方法 1. 硬件连接 2. 软件编程 三、Arduino Uno编程基础 1. 基本语法 2. 常用函数 四、Arduino Uno应用举例 1. LED闪烁 2. 温度检测 3. 超声波测距 五、Arduino Uno的扩展与应用 1…...

深入了解 StarRocks 表类型:解锁高效数据分析的密码
在当今数字化浪潮下,大数据分析成为企业决策、优化业务流程的关键利器。StarRocks 作为一款备受瞩目的高性能分析型数据库,其多样化的表类型为复杂的数据处理需求提供了精准解决方案。今天,就让我们一同深入探索 StarRocks 中的主键表、明细表…...