前端实现大文件上传(文件分片、文件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…...
什么是 C++ 的序列化?
什么是 C 的序列化? 序列化(Serialization)是指将对象的状态转换为可以存储或传输的格式的过程。它使得对象能够以二进制或文本的形式被保存到文件中,或者通过网络发送到远程计算机上,稍后可以重新构造出来࿰…...
047_小驰私房菜_Qcom 8系列,Jpeg GPU 旋转
【问题背景】 横屏模式下,发现有些三方app拍照旋转了90度。 【修改策略】 adb shell setprop endor.debug.camera.overrideGPURotationUsecase 1 或者在/vendor/etc/camera/camxoverridesettings.txt 里面添加如下内容 overrideGPURotationUsecase1 【解释】 Ga…...
【C++】2039:【例5.6】冒泡排序
博客主页: [小ᶻ☡꙳ᵃⁱᵍᶜ꙳] 本文专栏: C 文章目录 💯前言💯题目描述💯我的初步实现我的代码实现问题分析 💯老师的实现方式老师代码的特点分析老师代码的执行过程 💯我的代码与老师代码的对比优点…...
【Java回顾】Day4 反射机制
反射机制 之前学过一部分,笔记在20250103Java包_网络编程.md里,这里在之前的笔记的基础上做一些补充。 反射:得到class对象后反向获取对象的各种信息。 包 Field 类或接口中的字段(成员变量),动态访问和修改类的字段 模板 获取Class 对象 …...
MATLAB画柱状图
一、代码 clear; clc; figure(position,[150,100,900,550])%确定图片的位置和大小,[x y width height] %准备数据 Y1[0.53,7.9,8.3;0.52,6.8,9.2;0.52,5.9,8.6;2.8,5.8,7.9;3.9,5.2,7.8;1.8,5.8,8.4]; % withoutNHC X11:6; %画出4组柱状图,宽度1 h1…...
web漏洞之文件包含漏洞
一、文件包含漏洞 1、把DVWA页面改为low级别,然后点击File Inclusion页面 原理是File Inclusion页面访问的是low.php和include.php,这两个页面包含了include($_GET[page])这句话,意思是page会把用户输入的参数带进来然后由$_GET读取&#x…...
spring mvc源码学习笔记之六
pom.xml 内容如下 <?xml version"1.0" encoding"UTF-8"?> <project xmlns"http://maven.apache.org/POM/4.0.0"xmlns:xsi"http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation"http://maven.apache.org/P…...
深入理解 PyTorch 的 Dataset 和 DataLoader:构建高效数据管道
文章目录 简介PyTorch 的 DatasetDataset 的基本概念自定义 Dataset实现 __init__ 方法示例:从 CSV 文件加载数据 实现 __len__ 方法实现 __getitem__ 方法另一种示例:直接传递列表训练集和验证集的定义 1. 单个 Dataset 类 数据分割2. 分别定义两个 Da…...
VSCode设置ctrl或alt+mouse(left)跳转
总结: (1)VSCode初次远程连接服务器时,需要在服务器上下载 python 拓展,然后选择对应的环境 (2)VSCode设置ctrl或altmouse(left)跳转到定义...
在Ubuntu 18.04.6 LTS安装OpenFace流程
一、修改配置:将gcc8,g8作为默认选项 sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-8 100 sudo update-alternatives --config gcc 选择版本,再查看gcc --version sudo update-alternatives --install /usr/bin/g g /usr/bin/g-…...
微服务拆分的艺术:构建高效、灵活的系统架构
目录 一、微服务拆分的重要性 二、微服务拆分的策略 1. 按照业务领域拆分 2. 按照团队结构拆分 3. 按照业务边界拆分 4. 按照数据和数据库拆分 5. 按照用户界面或外部接口拆分 6. 按照功能模块或领域驱动设计拆分 7. 按照性能和可伸缩性需求拆分 三、微服务拆分的实践…...
PHP框架+gatewayworker实现在线1对1聊天--发送消息(6)
文章目录 发送消息原理说明发送功能实现html部分javascript代码PHP代码 发送消息原理说明 接下来我们发送聊天的文本信息。点击发送按钮的时候,会自动将文本框里的内容发送出去。过程是我们将信息发送到服务器,服务器再转发给对方。文本框的id为msgcont…...
java项目之读书笔记共享平台(源码+文档)
风定落花生,歌声逐流水,大家好我是风歌,混迹在java圈的辛苦码农。今天要和大家聊的是一款基于springboot的闲一品交易平台。项目源码以及部署相关请联系风歌,文末附上联系信息 。 项目简介: 读书笔记共享平台的主要使…...
RabbitMq的Java项目实践
在现代软件开发中,消息队列(Message Queue,简称MQ)作为一种重要的组件,承担着上下游消息传递和通信的重任。RabbitMQ作为一款流行的开源消息队列中间件,凭借其高可用性、可扩展性和易用性等特点,…...
气膜球幕:引领元宇宙时代的科技与艺术光影盛宴—轻空间
在科技与艺术交织的时代,未来的观影体验将不再受限于传统屏幕的束缚。随着气膜球幕的崭新亮相,突破性的光影效果和沉浸式体验让我们走进了一个全新的视听世界。这不仅仅是一个简单的球形影院,它是连接现实与虚拟、科技与艺术、光与影的桥梁&a…...
行为模式2.命令模式------灯的开关
行为型模式 模板方法模式(Template Method Pattern)命令模式(Command Pattern)迭代器模式(Iterator Pattern)观察者模式(Observer Pattern)中介者模式(Mediator Pattern…...
Linux环境下静态库和动态库的实现
Linux 环境下静态库和动态库的实现 在软件开发中,库是非常重要的组成部分。它们包含了一组可复用的函数和代码片段,用于提高开发效率和代码质量。在Linux系统中,库分为静态库和动态库两种。本文将介绍它们的实现方式,结合C语言代…...
如何很快将文件转换成另外一种编码格式?编码?按指定编码格式编译?如何检测文件编码格式?Java .class文件编码和JVM运行期内存编码?
如何很快将文件转换成另外一种编码格式? 利用VS Code右下角的"选择编码"功能,选择"通过编码保存"可以很方便将文件转换成另外一种编码格式。尤其,在测试w/ BOM或w/o BOM, 或者ANSI编码和UTF编码转换,特别方便。VS文件另…...
Tortoisegit 安装之后没有Add、ignore解决
在本地的仓库文件夹中点击右键,找到Settings, 从General 找到Contex Menu,我的系统是Win11,所以用Win11 Contex Menu 将所需要的操作打勾即可。...
线性代数考研笔记
行列式 背景 分子行列式:求哪个未知数,就把b1,b2放在对应的位置 分母行列式:系数对应写即可 全排列与逆序数 1 3 2:逆序数为1 奇排列 1 2 3:逆序数为0 偶排列 将 1 3 2 只需将3 2交换1次就可以还原原…...
C语言带参数的宏定义的相关知识汇总(最常用的形式、带标记分隔符##的形式...)
阅读大型C工程代码时,绕不开带参数的宏定义的阅读,所以有必要强化一下这一块的知识。 01-带参数的宏定义最常用的形式 # define S(a,b) a*b ... ... ... area S(3,2);则在编译预处理时area S(3,2);被展开为: area 3 * 2;02-带标记分隔符…...
cpp编译链接等
一、编译预处理 C程序编译的过程:预处理 -> 编译(优化、汇编)-> 链接 预处理指令主要有以下三种: 包含头文件:#include 宏定义:#define(定义宏)、#undef(删除宏…...
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...
JavaScript HTML DOM 实例
JavaScript HTML DOM 实例 JavaScript 的 HTML DOM(文档对象模型)允许您通过脚本来控制 HTML 页面。DOM 是 HTML 文档的编程接口,它将 Web 页面与编程语言连接起来,使得开发者可以改变页面中的内容、结构和样式。在这篇文章中,我们将通过一系列实例来探讨如何使用 JavaSc…...
【Vue】:解决动态更新 <video> 标签 src 属性后视频未刷新的问题
问题描述 在 Vue.js 项目,当尝试动态更新 <video> 标签的 <source> 元素 src 属性来切换视频时,遇到了一个问题:即使 src 属性已更改,浏览器仍显示旧视频。具体表现为用户选择新视频后,视频区域继续显示之…...
C语言| 二维数字的定义
【二维数组】 二维数组的本质就是一维数组,表现形式上是二维的。 定义一般形式为 类型说明符 数组名[常量表达式][常量表达式]; 举例 int a[2][3]; 定义了一个2行3列的二维数组a,共有6个元素。 元素名字依次是:a[0][0],a[0][1],a[0][…...
全国青少年信息学奥林匹克竞赛(信奥赛)备考实战之循环结构(for循环语句)—(十)(求解数学中特殊的数)
实战训练1—完全数 问题描述: 数学上的“完全数”是指真因子(除了自身以外的约数)之和等于它本身的自然数。例如,6的因子是1,2,3,而1236,所以6是完全数。如果一个正整数小于它的所有真因数之和࿰…...
【大模型】ChatGPT 数据分析与处理使用详解
目录 一、前言 二、AI 大模型数据分析介绍 2.1 什么是AI数据分析 2.2 AI数据分析与传统数据分析对比 2.2.1 差异分析 2.2.2 优劣势对比 2.3 AI大模型工具数据分析应用场景 三、AI大模型工具数据分析操作实践 3.1 ChatGPT 常用数据分析技巧操作演示 3.1.1 快速生成数据…...
[gcc]常见编译开关
GCC 提供了许多编译开关(编译选项),这些开关可以用于控制编译过程的各种方面,如优化级别、调试信息、警告和错误处理等。 以下是一些常见的 GCC 编译开关: -o:指定输出文件名。例如,gcc -o myp…...
iOS实现在collectionView顶部插入数据效果
有时候,我们会遇到这种需求,就是下拉刷新的时候,在 collectionView顶部插入数据,这个时候,需要我们注意 主要有两点 1 关闭隐式动画 由于我们使用insert在collectionView顶部插入数据是有从头部插入的隐式动画的&#…...
GPIO、RCC库函数
void GPIO_DeInit(GPIO_TypeDef* GPIOx); void GPIO_AFIODeInit(void); void GPIO_Init(GPIO_TypeDef* GPIOx, GPIO_InitTypeDef* GPIO_InitStruct); void GPIO_StructInit(GPIO_InitTypeDef* GPIO_InitStruct); //输出 读 uint8_t GPIO_ReadInputDataBit(GPIO_TypeDef* GPIOx,…...
PostgreSQL学习笔记(一):PostgreSQL介绍和安装
目录 概念 PostgreSQL简介 PostgreSQL的关键特性 1. 标准兼容性 2. 扩展性 3. 数据完整性和可靠性 4. 丰富的数据类型 5. 查询能力 6. 事务和并发控制 7. 扩展和插件 8. 跨平台和多语言支持 9. 高可用性和扩展性 常用场景 安装 Linux apt安装 下载安装包安装 客…...
从摩托罗拉手机打印短信的简单方法
昨天我试图从摩托罗拉智能手机上打印短信,但当我通过USB将手机连接到电脑时,我在电脑上找不到它们。由于我的手机内存已达到限制,并且我想保留短信的纸质版本,您能帮我将短信从摩托罗拉手机导出到计算机吗? 如您所知&…...
矩阵运算提速——玩转opencv::Mat
介绍:用Eigen或opencv::Mat进行矩阵的运算,比用cpp的vector或vector进行矩阵运算要快吗? 使用 Eigen 或 OpenCV 的 cv::Mat 进行矩阵运算通常比使用 std::vector<int> 或 std::vector<double> 更快。这主要有以下几个原因: 优化的底层实现…...
vue请求后端需要哪些问题
在使用 Vue 前端框架请求后端服务时,需要考虑和解决的问题有很多。以下是一个详细的讲解: 1. **API 设计与文档** - **明确 API 端点**:了解后端提供的 API 端点(URL),包括资源的路径和操作方法(…...
QML Image详解
1. 概述 Image 是 QML 中用于显示图片的基本组件。它允许开发者加载和显示各种格式的图像文件(如 PNG, JPEG, GIF 等),并提供了多种配置选项来控制图片的显示方式和行为。Image 元素支持各种图像处理功能,比如缩放、裁剪、模糊等…...
Chapter4.1 Coding an LLM architecture
文章目录 4 Implementing a GPT model from Scratch To Generate Text4.1 Coding an LLM architecture 4 Implementing a GPT model from Scratch To Generate Text 本章节包含 编写一个类似于GPT的大型语言模型(LLM),这个模型可以被训练来生…...
Linux 端口知识全解析
Linux 端口知识全解析 在 Linux 系统的网络世界里,端口如同一个个小小的“窗口”,数据的进出都依赖它们有条不紊地运作。理解 Linux 端口知识,无论是对于系统管理员排查网络故障,还是开发者进行网络编程,都至关重要。…...
《Armv8-A virtualization》学习笔记
1.MAIR 的全称是 Memory Attribute Indirection Register。它是ARM架构中的一种寄存器,用于定义内存的属性,并提供一种间接访问内存属性的机制。MAIR寄存器包含多个字段,这些字段指示不同类型内存的属性,例如是否可以缓存、是否为…...
23. 【.NET 8 实战--孢子记账--从单体到微服务】--记账模块--预算
在每个月发工资后很多人会对未来一个月的花销进行大致的计划,这个行为叫做预算。那么在这篇文章中我们将一起开发预算服务。 一、需求 预算需求就是简单的增删改查,虽然比较简单,但是也有几点需要注意。 编号需求说明1新增预算1. 针对每种…...
DOS攻击的原理和实现 (网络安全)hping3和Slowloris的运用
DoS攻击的原理和实现 DoS攻击(Denial of Service Attack,拒绝服务攻击)是指通过恶意手段使目标服务器、服务或网络资源无法正常提供服务,从而影响正常用户的访问。DoS攻击通常通过消耗目标系统的资源(如带宽、内存、处…...
十三、Vue 过渡和动画
文章目录 一、Vue过渡和动画概述1. 过渡的基本原理2. 动画的基本原理二、使用 CSS 过渡1. 单元素过渡2. 过渡模式in - out 模式out - in 模式三、使用 CSS 动画1. 单元素动画2. 动画结合过渡四、JavaScript 钩子函数实现过渡和动画1. 基本概念2. 示例五、列表过渡1. 基本原理2.…...
Dubbo 关键知识点解析:负载均衡、容错、代理及相关框架对比
1.Dubbo 负载均衡策略? Dubbo 是一个分布式服务框架,它提供了多种负载均衡策略来分发服务调用。在 Dubbo 中,负载均衡的实现是基于客户端的,即由服务消费者(Consumer)端决定如何选择服务提供者(…...
仿生的群体智能算法总结之三(十种)
群体智能算法是一类通过模拟自然界中的群体行为来解决复杂优化问题的方法。以下是30种常见的群体智能算法,本文汇总第21-30种。接上文 : 编号 算法名称(英文) 算法名称(中文) 年份 作者 1 Ant Colony Optimization (ACO) 蚁群优化算法 1991 Marco Dorigo 2 Particle Swar…...
《量子比特大阅兵:不同类型量子比特在人工智能领域的优劣势剖析》
在科技的前沿,量子比特与人工智能的融合正开启一扇全新的大门。不同类型的量子比特,如超导、离子阱、光量子等,在与人工智能结合时展现出独特的优势与劣势。 超导量子比特 超导量子比特是目前应用较为广泛且研究相对成熟的量子比特类型。它…...
el-input输入框需要支持多输入,最后传输给后台的字段值以逗号分割
需求:一个输入框字段需要支持多次输入,最后传输给后台的字段值以逗号分割 解决方案:结合了el-tag组件的动态编辑标签 那块的代码 //子组件 <template><div class"input-multiple-box" idinputMultipleBox><div>…...
机器人领域的一些仿真器
模拟工具和环境对于开发、测试和验证可变形物体操作策略至关重要。这些工具提供了一个受控的虚拟环境,用于评估各种算法和模型的性能,并生成用于训练和测试数据驱动模型的合成数据。 Bullet Physics Library 用于可变形物体模拟的一个流行的物理引擎是 B…...
前端-动画库Lottie 3分钟学会使用
目录 1. Lottie地址 2. 使用html实操 3. 也可以选择其他的语言 1. Lottie地址 LottieFiles: Download Free lightweight animations for website & apps.Effortlessly bring the smallest, free, ready-to-use motion graphics for the web, app, social, and designs.…...
腾讯云智能结构化 OCR:驱动多行业数字化转型的核心引擎
在当今数字化时代的汹涌浪潮中,数据已跃升为企业发展的关键要素,其高效、精准的处理成为企业在激烈市场竞争中脱颖而出的核心竞争力。腾讯云智能结构化 OCR 技术凭借其前沿的科技架构与卓越的功能特性,宛如一颗璀璨的明星,在交通、…...