前端实现大文件上传(文件分片、文件hash、并发上传、断点续传、进度监控和错误处理,含nodejs)

news2025/1/7 10:42:59

大文件分片上传是前端一种常见的技术,用于提高大文件上传的效率和可靠性。主要原理和步骤如下

  1. 文件分片
    1. 确定分片大小:确定合适的分片大小。通常分片大小在 1MB 到 5MB 之间
    2. 使用 Blob.slice 方法:将文件分割成多个分片。每个分片可以使用 Blob.slice 方法从文件对象中切出
  2. 文件哈希
    计算哈希值:
    • 使用 Web Workers 来计算每个分片的哈希值,以避免阻塞主线程。(可以根据业务方向进行选择)
    • 使用 spark-md5 库来计算 MD5 哈希值
  3. 并发上传
    使用 Promise.all 或 async/await 来同时上传多个分片,或者使用plimit进行并发管理
  4. 断点续传
    1. 记录已上传的分片:使用本地存储(如 localStorage 或 IndexedDB)记录已上传的分片信息(根据业务情况而定)
    2. 在上传前,向服务器查询已上传的分片,只上传未完成的分片
  5. 重试机制:对于上传失败的分片,可以设置重试次数,并在重试失败后提示用户 (根据业务情况而定)
  6. 进度监控
    监听上传进度:
    • 使用 XMLHttpRequest 的 upload.onprogress 事件或 Fetch API 的 ReadableStream 来监听上传进度,或者通过后端返回已上传内容进行计算
    • 计算每个分片的上传进度,并累加到总进度中
  7. 错误处理
    在上传过程中捕获网络错误、服务器错误等,并进行相应的处理

大文件上传源码及其解析

示例代码和上面原理步骤实现可能有点不同(根据业务情况进行修改),但整体流程一致

HTML布局

<div class="kh-idx">
   <div class="kh-idx-banner">
     {{ msg }}
   </div>
   <form id="uploadForm" class="kh-idx-form">
     <input
       ref="fileInput"
       type="file"
       name="file"
       accept="application/pdf"
     >
     <button
       type="button"
       @click="uploadFile"
     >
       Upload File
     </button>
   </form>
   <progress v-if="processVal" :value="processVal" max="100"></progress>
 </div>

CSS

.kh-idx {

  &-banner {
    background-color: brown;
    color: aliceblue;
    text-align: center;
  }

  &-form {
    margin-top: 20px;
  }
}

TS 逻辑

import { defineComponent } from 'vue';
import sparkMD5 from 'spark-md5';
import pLimit from 'p-limit';
import { postUploadFile, postUploadFileCheck } from '@client/api/index';
import axios, { CancelTokenSource } from 'axios';

/**
 * 前端大文件上传技术点
 * 1.文件切片(Chunking):将大文件分割成多个小片段(切片),这样可以减少单次上传的数据量,降低上传失败的概率,并支持断点续传。
 * 2.文件hash:助验证文件的完整性和唯一性
 * 3.并发上传:利用JavaScript的异步特性,可以同时上传多个文件切片,提高上传效率。
 * 4.断点续传:在上传过程中,如果发生中断,下次再上传可以从中断点继续上传,而不是重新上传整个文件。这通常通过记录已上传的切片索引来实现。
 * 5.进度监控:通过监听上传事件,可以实时获取上传进度,并显示给用户。
 * 6.错误处理:在上传过程中,要及时处理可能出现的错误,如网络错误、服务器错误等
 */
export default defineComponent({
  name: 'KhIndex',
  data() {
  	return {
  		msg: '文件上传demo',
      chunkSize: 5 * 1024 * 1024, // 设置分片大小 5 MB
      processVal: 0
  	};
  },
  methods: {

    // 分割文件
    splitFileByChunkSize(file: File, chunkSize: number) {
      let splitedFileArr = [];
      let fileSize = file.size; // 获取文件大小
      let totalChunkNumber = Math.ceil(fileSize / chunkSize); // 向上取整获取 chunk 数量
      for (let i = 0; i < totalChunkNumber; i++) {

        // File类型继承Blob
        splitedFileArr.push(file.slice(i * chunkSize, (i + 1) * chunkSize));
      }

      return {
        originFile: file,
        name: file.name,
        splitedFile: splitedFileArr
      }
    },

    // 计算分割后的文件 hash 值
    calcuateFileHash(splitedFiles: Array<Blob>, chunkSize: number): Promise<string> {
      let spark = new sparkMD5.ArrayBuffer();
      let chunks: Blob[] = [];

      splitedFiles.forEach((chunk, idx) => {
        if (
          idx === 0 ||
          idx === splitedFiles.length - 1
        ) {
          chunks.push(chunk);
        } else {

          // 中间剩余切片分别在前面、后面和中间取2个字节参与计算
          chunks.push(chunk.slice(0, 2)); // 前面的2字节
          chunks.push(chunk.slice(chunkSize / 2, (chunkSize / 2) + 2)); // 中间的2字节
          chunks.push(chunk.slice(chunkSize - 2, chunkSize)); // 后面的2字节
        }
      });
      
      return new Promise((resolve, reject) => {
        let reader = new FileReader(); //异步 API
        reader.readAsArrayBuffer(new Blob(chunks));
        reader.onload = (e: Event) => {
          spark.append((e.target as any).result as ArrayBuffer);
          resolve(spark.end());
        };
        reader.onerror = () => {
          reject('');
        };
      })

    },

    // 生成 formData
    genFormDataByChunkInfo(chunkList: Array<{
      fileName: string,
      fileHash: string,
      index: number,
      chunk: Blob,
      chunkHash:  string,
      size: number,
      chunkTotal: number
    }>) {
      return chunkList.map(({
        fileName,
        fileHash,
        index,
        chunk,
        chunkHash,
        chunkTotal,
        size
      }) => {
        let formData = new FormData();
        formData.append('chunk', chunk);
        formData.append('chunkHash', chunkHash);
        formData.append('size',  String(size));
        formData.append('chunkTotal', String(chunkTotal));
        formData.append('fileName', fileName);
        formData.append('fileHash', fileHash);
        formData.append('index', String(index));

        return formData;
      });
    },

    // 取消请求
    createReqControl() {
      let cancelToken = axios.CancelToken;
      let cancelReq: CancelTokenSource[] = [];

      return {
        addCancelReq(req: CancelTokenSource) {
          cancelReq.push(req);
        },
        cancelAllReq(msg = '已取消请求') {
          cancelReq.forEach((req) => {
            req.cancel(msg); // 全部取消后续请求
          })
        },
        createSource() {
          return cancelToken.source();
        },
        print() {
          console.log(cancelReq);
        }
      }
    },

    // 上传文件前的检查
    async uploadFileCheck(
      splitedFileObj: {
        originFile: File,
        name: string,
        splitedFile: Array<Blob>
      },
      fileMd5: string
    ): Promise<{
        isError: boolean
        isFileExist: boolean,
        uploadedChunks: []
      }> {
      try {
        let { data } = await postUploadFileCheck({
          fileHash: fileMd5,
          chunkTotal: splitedFileObj.splitedFile.length,
          fileName: splitedFileObj.name
        });

        if (
          data.code === 200
          && !(data.result?.isFileExist)
        ) {
          return {
            isError: false,
            isFileExist: data.result?.isFileExist,
            uploadedChunks: data.result.uploadedChunks
          };
        }
        return {
          isError: true,
          isFileExist: false,
          uploadedChunks: []
        };
      } catch (error) {
        return {
          isError: true,
          isFileExist: false,
          uploadedChunks: []
        }
      }
    },

    // 并发请求
    async uploadFilesConcurrently(
      splitedFileObj: {
        originFile: File,
        name: string,
        splitedFile: Array<Blob>
      },
      fileMd5: string,
      concurrentNum = 3,
      uploadedChunks: Array<number>
    ) {
      let cancelControlReq = this.createReqControl();

      const LIMIT_FUN = pLimit(concurrentNum); // 初始化并发限制
      let fileName = splitedFileObj.name; // 文件名
      let chunkTotalNum = splitedFileObj.splitedFile.length;
      let chunkList = splitedFileObj.splitedFile
        .map((chunk, idx) => {
          if (uploadedChunks.includes(idx)) return null;
          return {
            fileName, 
            fileHash: fileMd5,
            index: idx,
            chunk,
            chunkTotal: chunkTotalNum,
            chunkHash: `${ fileMd5 }-${ idx }`,
            size: chunk.size
          }
        }).filter((fileInfo) => fileInfo != null); // 过滤掉已经上传的chunk

      let formDataArr = this.genFormDataByChunkInfo(chunkList);

      let allPromises = formDataArr.map((formData) => {
        let source = cancelControlReq.createSource(); // 生成source
        cancelControlReq.addCancelReq(source); //添加 source

        return LIMIT_FUN(() => new Promise(async (resolve, reject) => {
          try {
            let result = await postUploadFile(formData, source.token);
            if (result.data.code === 100) {
              cancelControlReq.cancelAllReq(); // 取消后续全部请求
            }
           
            if (
              result.data.code === 201
              || result.data.code === 200
            ) {
              let data = result.data.result;
              this.setPropress(Number(data.uploadedChunks.length), Number(data.chunkTotal));
            }
            resolve(result);
          } catch (error) {
            this.setPropress(0, 0); // 关闭进度条

            // 报错后取消后续请求
            cancelControlReq.cancelAllReq(); // 取消后续全部请求
            reject(error);
          }
        }));
      })

      return await Promise.all(allPromises);
    },
	
	// 设置进度条
    setPropress(uploadedChunks: number, chunkTotal: number) {
      this.processVal = (uploadedChunks / chunkTotal) * 100;
    },
    
	// 文件上传
    async uploadFile() {
      // 获取文件输入元素中的文件列表
      let files = (this.$refs.fileInput as HTMLInputElement).files || [];
      if (files.length <= 0) return;
		
	  // 将选择的文件按照指定的分片大小进行分片处理	
      let fileSplitedObj = this.splitFileByChunkSize(files[0], this.chunkSize);
      // 计算整个文件的哈希值,用于后续的文件校验和秒传功能
      let fileMd5 = await this.calcuateFileHash(fileSplitedObj.splitedFile, this.chunkSize);
      // 检查服务器上是否已存在该文件的分片以及整个文件
      let uploadedChunksObj = await this.uploadFileCheck(fileSplitedObj, fileMd5);
      
	  // 如果检查过程中发生错误,或者文件已存在,则直接返回	
      if (
        !(!uploadedChunksObj.isError
        && !uploadedChunksObj.isFileExist)
      ) return;

      // 并发上传文件分片,最多同时上传3个分片
      let uploadFileResultArr = await this.uploadFilesConcurrently(
        fileSplitedObj,
        fileMd5,
        3,
        uploadedChunksObj.uploadedChunks
      );
		
	  // 上传成功后,重置进度条
      if (
        uploadFileResultArr
        && Array.isArray(uploadFileResultArr)
      ) {
        this.setPropress(0, 0);
      }
      
    }
  }
});

uploadFile函数逻辑分析

检查是否选择了要上传的文件

 let files = (this.$refs.fileInput as HTMLInputElement).files || [];
 if (files.length <= 0) return; // 没有选择文件,后续就不走

文件分片

 let fileSplitedObj = this.splitFileByChunkSize(files[0], this.chunkSize);
 
// 分割文件
splitFileByChunkSize(file: File, chunkSize: number) {
  let splitedFileArr = [];
  let fileSize = file.size; // 获取文件大小
  let totalChunkNumber = Math.ceil(fileSize / chunkSize); // 向上取整获取 chunk 数量
  for (let i = 0; i < totalChunkNumber; i++) {

    // File类型继承Blob
    splitedFileArr.push(file.slice(i * chunkSize, (i + 1) * chunkSize));
  }

  return {
    originFile: file,
    name: file.name,
    splitedFile: splitedFileArr
  }
},

splitFileByChunkSize 函数功能分析

  1. 初始化变量:
    • splitedFileArr:用于存储分割后的文件分片数组。
    • fileSize:获取文件的总大小。
    • totalChunkNumber:计算文件需要被分割成的分片数量。通过文件大小除以每个分片的大小,然后向上取整得到。
  2. 文件分片:
    • 使用 for 循环遍历每个分片。
    • 在循环中,使用 file.slice 方法从文件中切出每个分片。file.slice 方法接受两个参数:起始位置和结束位置,分别对应当前分片的开始和结束字节。
    • 将每个分片添加到 splitedFileArr 数组中。
  3. 返回结果:返回一个对象,包含原始文件、文件名和分割后的文件分片数组。

生成文件MD5

let fileMd5 = await this.calcuateFileHash(fileSplitedObj.splitedFile, this.chunkSize);

// 计算分割后的文件 hash 值
calcuateFileHash(splitedFiles: Array<Blob>, chunkSize: number): Promise<string> {
  let spark = new sparkMD5.ArrayBuffer();
  let chunks: Blob[] = [];

  splitedFiles.forEach((chunk, idx) => {
    if (
      idx === 0 ||
      idx === splitedFiles.length - 1
    ) {
      chunks.push(chunk);
    } else {

      // 中间剩余切片分别在前面、后面和中间取2个字节参与计算
      chunks.push(chunk.slice(0, 2)); // 前面的2字节
      chunks.push(chunk.slice(chunkSize / 2, (chunkSize / 2) + 2)); // 中间的2字节
      chunks.push(chunk.slice(chunkSize - 2, chunkSize)); // 后面的2字节
    }
  });
  
  return new Promise((resolve, reject) => {
    let reader = new FileReader(); //异步 API
    reader.readAsArrayBuffer(new Blob(chunks));
    reader.onload = (e: Event) => {
      spark.append((e.target as any).result as ArrayBuffer);
      resolve(spark.end());
    };
    reader.onerror = () => {
      reject('');
    };
  })

},

calcuateFileHash 函数功能分析

  1. 初始化变量:
    • spark:创建一个 sparkMD5.ArrayBuffer 实例,用于计算文件的 MD5 哈希值。
    • chunks:初始化一个数组,用于存储参与哈希计算的文件片段。
  2. 选择文件片段:
    • 遍历 splitedFiles 数组,该数组包含了文件的所有分片。
    • 对于第一个和最后一个分片,直接将它们添加到 chunks 数组中。
    • 对于中间的分片,只选择每个分片的前 2 个字节、中间的 2 个字节和最后的 2 个字节参与哈希计算。这样可以减少计算量,同时保持一定的哈希准确性。
  3. 读取文件片段:
    • 创建一个 FileReader 实例,用于读取文件片段。
    • 使用 FileReader.readAsArrayBuffer 方法将 chunks 数组中的文件片段读取为 ArrayBuffer 格式。
  4. 计算哈希值:
    • 在 FileReader 的 onload 事件中,将读取到的 ArrayBuffer 数据添加到 spark 实例中。
    • 调用 spark.end() 方法计算最终的 MD5 哈希值,并通过 resolve 回调函数返回该哈希值。
  5. 错误处理:
    在 FileReader 的 onerror 事件中,如果读取文件片段发生错误,则通过 reject 回调函数返回一个空字符串,表示哈希计算失败。

检查是否已存在该文件的分片以及整个文件

 let uploadedChunksObj = await this.uploadFileCheck(fileSplitedObj, fileMd5);
 
 // 如果检查过程中发生错误,或者文件已存在,则直接返回	
 if (
   !(!uploadedChunksObj.isError
   && !uploadedChunksObj.isFileExist)
 ) return;
 
 // 上传文件前的检查
 async uploadFileCheck(
   splitedFileObj: {
     originFile: File,
     name: string,
     splitedFile: Array<Blob>
   },
   fileMd5: string
 ): Promise<{
     isError: boolean
     isFileExist: boolean,
     uploadedChunks: []
   }> {
   try {
     let { data } = await postUploadFileCheck({
       fileHash: fileMd5,
       chunkTotal: splitedFileObj.splitedFile.length,
       fileName: splitedFileObj.name
     });

     if (
       data.code === 200
       && !(data.result?.isFileExist)
     ) {
       return {
         isError: false,
         isFileExist: data.result?.isFileExist,
         uploadedChunks: data.result.uploadedChunks
       };
     }
     return {
       isError: true,
       isFileExist: false,
       uploadedChunks: []
     };
   } catch (error) {
     return {
       isError: true,
       isFileExist: false,
       uploadedChunks: []
     }
   }
 },

uploadFileCheck 函数功能分析

  1. 参数接收:
    • splitedFileObj:包含原始文件信息和分割后的文件分片数组的对象。
      • originFile:原始文件对象。
      • name:文件名。
      • splitedFile:分割后的文件分片数组。
    • fileMd5:文件的哈希值。
  2. 发送请求:
    使用 postUploadFileCheck 函数(假设这是一个封装好的 HTTP POST 请求函数)向服务器发送文件上传前的检查请求。
    请求体中包含文件的哈希值 fileHash、分片总数 chunkTotal 和文件名 fileName。
  3. 处理响应:
    • 如果服务器返回的状态码为 200 且文件不存在(data.result?.isFileExist 为 false),则返回一个对象,表示没有错误发生,文件不存在,以及已上传的分片列表 uploadedChunks。
    • 如果服务器返回的状态码不是 200 或文件已存在,则返回一个对象,表示发生了错误,文件不存在,已上传的分片列表为空。
  4. 错误处理:
    如果在发送请求或处理响应过程中发生错误(例如网络错误或服务器错误),则捕获错误并返回一个对象,表示发生了错误,文件不存在,已上传的分片列表为空。

并发上传

// 上传文件
let uploadFileResultArr = await this.uploadFilesConcurrently(
  fileSplitedObj,
  fileMd5,
  3,
  uploadedChunksObj.uploadedChunks
);

// 并发请求
async uploadFilesConcurrently(
  splitedFileObj: {
    originFile: File,
    name: string,
    splitedFile: Array<Blob>
  },
  fileMd5: string,
  concurrentNum = 3,
  uploadedChunks: Array<number>
) {
  let cancelControlReq = this.createReqControl();

  const LIMIT_FUN = pLimit(concurrentNum); // 初始化并发限制
  let fileName = splitedFileObj.name; // 文件名
  let chunkTotalNum = splitedFileObj.splitedFile.length;
  let chunkList = splitedFileObj.splitedFile
    .map((chunk, idx) => {
      if (uploadedChunks.includes(idx)) return null;
      return {
        fileName, 
        fileHash: fileMd5,
        index: idx,
        chunk,
        chunkTotal: chunkTotalNum,
        chunkHash: `${ fileMd5 }-${ idx }`,
        size: chunk.size
      }
    }).filter((fileInfo) => fileInfo != null); // 过滤掉已经上传的chunk

  let formDataArr = this.genFormDataByChunkInfo(chunkList);

  let allPromises = formDataArr.map((formData) => {
    let source = cancelControlReq.createSource(); // 生成source
    cancelControlReq.addCancelReq(source); //添加 source

    return LIMIT_FUN(() => new Promise(async (resolve, reject) => {
      try {
        let result = await postUploadFile(formData, source.token);
        if (result.data.code === 100) {
          cancelControlReq.cancelAllReq(); // 取消后续全部请求
        }
        if (
          result.data.code === 201
          || result.data.code === 200
        ) {
          let data = result.data.result;
          this.setPropress(Number(data.uploadedChunks.length), Number(data.chunkTotal));
        }
        resolve(result);
      } catch (error) {
        this.setPropress(0, 0); // 关闭进度条

        // 报错后取消后续请求
        cancelControlReq.cancelAllReq(); // 取消后续全部请求
        reject(error);
      }
    }));
  })

  return await Promise.all(allPromises);
},

uploadFilesConcurrently 函数功能分析

  1. 初始化并发控制:
    • cancelControlReq:创建一个请求控制对象,用于管理上传请求的取消操作。
    • LIMIT_FUN:使用 pLimit 函数初始化并发限制,concurrentNum 指定了同时上传的最大分片数量,默认为 3。
  2. 准备上传数据:
    • fileName:获取文件名。
    • chunkTotalNum:获取分片总数。
    • chunkList:将分片数组映射为包含上传所需信息的对象数组。每个对象包含文件名、文件哈希值、分片索引、分片数据、分片总数、分片哈希值和分片大小。过滤掉已上传的分片。
  3. 生成表单数据:
    • formDataArr:调用 genFormDataByChunkInfo 方法,根据分片信息生成对应的 FormData 对象数组。
  4. 创建并发上传任务:
    • 使用 map 方法遍历 formDataArr,为每个分片创建一个上传任务。
    • source:为每个上传任务生成一个取消令牌 source,并将其添加到请求控制对象中。
    • LIMIT_FUN:使用 pLimit 函数限制并发上传的数量。
    • 在每个上传任务中,使用 postUploadFile 函数发送上传请求,并传递 FormData 和取消令牌。
    • 如果上传成功,更新上传进度。如果上传失败,取消后续所有上传请求,并返回错误。
  5. 等待所有上传任务完成:
    使用 Promise.all 等待所有上传任务完成,返回一个包含所有上传结果的数组。

nodeJs 逻辑

index入口文件

const EXPRESS = require('express');
const PATH = require('path');
const HISTORY = require('connect-history-api-fallback');
const COMPRESSION = require('compression');
const REQUEST = require('./routes/request');
const ENV = require('./config/env');
const APP = EXPRESS();
const PORT = 3000;

APP.use(COMPRESSION());// 开启gzip压缩

// 设置静态资源缓存
const SERVE = (path, maxAge) => EXPRESS.static(path, { maxAge });

APP.use(EXPRESS.json());
APP.all('*', (req, res, next) => {
  res.header("Access-Control-Allow-Origin","*");
  res.header("Access-Control-Allow-Headers","Content-Type");
  res.header("Access-Control-Allow-Methods","*");
  next() 
});
APP.use(REQUEST);

APP.use(HISTORY());// 重置单页面路由

APP.use('/dist', SERVE(PATH.resolve(__dirname, '../dist'), ENV.maxAge));

//根据环境变量使用不同环境配置
APP.use(require(ENV.router));

APP.listen(PORT, () => {
  console.log(`APP listening at http://localhost:${PORT}\n`);
});

request处理请求

const express = require('express');
const requestRouter = express.Router();
const { resolve, join } =  require('path');
const multer = require('multer');
const UPLOAD_DIR = resolve(__dirname, '../upload');
const UPLOAD_FILE_DIR = join(UPLOAD_DIR, 'files');
const UPLOAD_MULTER_TEMP_DIR = join(UPLOAD_DIR, 'multerTemp');
const upload = multer({ dest: UPLOAD_MULTER_TEMP_DIR });
const fse = require('fs/promises');
const fs = require('fs');
require('events').EventEmitter.defaultMaxListeners = 20; // 将默认限制增加到

// 合并chunks
function mergeChunks(
  fileName,
  tempChunkDir,
  destDir,
  fileHash,
  chunks,
  cb
) {
  let writeStream = fs.createWriteStream(`${ destDir }/${ fileHash }-${ fileName }`);
  writeStream.on('finish', async () => {
    writeStream.close(); // 关闭
    try {
      await fse.rm(tempChunkDir, { recursive: true, force: true });
    } catch (error) {
      console.error(tempChunkDir, error);
    }
  })

  let readStreamFun = function(chunks, cb) {
    try {
      let val = chunks.shift();
      let path = join(tempChunkDir, `${ fileHash }-${ val }`);
      let readStream = fs.createReadStream(path);
      readStream.pipe(writeStream, { end: false });
      readStream.once('end', () => {
        console.log('path', path);
        if(fs.existsSync(path)) {
          fs.unlinkSync(path);
        }
        if (chunks.length > 0) {
          readStreamFun(chunks, cb);
        } else {
          cb();
        }
      });
    } catch (error) {
      console.error( error);
    }
  }
  readStreamFun(chunks, () => {
    cb();
    writeStream.end();
  });
}

// 判断当前文件是否已经存在
function isFileOrDirInExist(filePath) {
  return fs.existsSync(filePath);
};

// 删除文件夹内的内容胆保留文件夹
function rmDirContents(dirPath) {
  fs.readdirSync(dirPath).forEach(file => {
    let curPath = join(dirPath, file);
    if (fs.lstatSync(curPath).isDirectory()) {
      rmDirContents(curPath);
    } else {
      fs.unlinkSync(curPath);
    }
  });
}

// 获取已上传chunks序号
async function getUploadedChunksIdx(tempChunkDir, fileHash) {
  if (!isFileOrDirInExist(tempChunkDir)) return []; // 不存在直接返回[]

  let uploadedChunks = await fse.readdir(tempChunkDir);
  let uploadedChunkArr = uploadedChunks.filter(file => file.startsWith(fileHash + '-'))
  .map(file => parseInt(file.split('-')[1], 10));

  return [ ...(new Set(uploadedChunkArr.sort((a, b) => a - b))) ];
}

requestRouter.post('/api/upload/check', async function(req, res) {
  try {
    let fileHash = req.body?.fileHash;
    let chunkTotal = req.body?.chunkTotal;
    let fileName = req.body?.fileName;
    let tempChunkDir = join(UPLOAD_DIR, 'temp', fileHash); // 存储切片的临时文件夹

    if (!fileHash || chunkTotal == null) {
      return res.status(200).json({
        code: 400,
        massage: '缺少必要的参数',
        result: null
      });
    }

    let isFileExist = fs.existsSync(join(UPLOAD_FILE_DIR, `${ fileHash }-${ fileName }`));
    // 如果文件存在,则清除temp中临时文件和文件夹
    if (isFileExist) {
      // 当前文件夹存在
      if (fs.existsSync(tempChunkDir)){
        fs.rmSync(tempChunkDir, { recursive: true, force: true });
      }

      return res.status(200).json({
        code: 200,
        massage: '成功',
        result: {
          uploadedChunks: [],
          isFileExist
        }
      })
    }

    let duplicateUploadedChunks = await getUploadedChunksIdx(tempChunkDir, fileHash);

    return res.status(200).json({
      code: 200,
      massage: '成功',
      result: {
        uploadedChunks: duplicateUploadedChunks,
        isFileExist
      }
    });
  } catch (error) {
    console.error(error);
    rmDirContents(UPLOAD_MULTER_TEMP_DIR); // 删除临时文件
    return res.status(500).end();
  }
});

requestRouter.post('/api/upload', upload.single('chunk'), async function (req, res) {
  try {
    let chunk = req.file; // 获取 chunk
    let index = req.body?.index;
    let fileName = req.body?.fileName;
    let fileHash = req.body?.fileHash; // 文件 hash
    let chunkHash = req.body?.chunkHash;
    let chunkTotal = req.body?.chunkTotal; // chunk 总数
    let tempChunkDir = join(UPLOAD_DIR, 'temp', fileHash); // 存储切片的临时文件夹

    if (isFileOrDirInExist(join(UPLOAD_FILE_DIR, `${ fileHash }-${ fileName }`))) {
      rmDirContents(UPLOAD_MULTER_TEMP_DIR); // 删除临时文件
      return res.status(200).json({
        code: 100,
        massage: '该文件已存在',
        result: fileHash
      }).end();
    }

    // 切片目录不存在,则创建
    try {
      await fse.access(tempChunkDir, fse.constants.F_OK)
    } catch (error) {
      await fse.mkdir(tempChunkDir, { recursive: true });
    }

    if (!fileName || !fileHash) {
      res.status(200).json({
        code: 400,
        massage: '缺少必要的参数',
        result: null
      });
    }

    await fse.rename(chunk.path, join(tempChunkDir, chunkHash));
    let duplicateUploadedChunks = await getUploadedChunksIdx(tempChunkDir, fileHash); // 获取已上传的chunks

    // 当全部chunks上传完毕后,进行文件合并
    if (duplicateUploadedChunks.length === Number(chunkTotal)) {
      mergeChunks(
        fileName,
        tempChunkDir,
        UPLOAD_FILE_DIR,
        fileHash,
        duplicateUploadedChunks,
        () => {
          rmDirContents(UPLOAD_MULTER_TEMP_DIR); // 删除临时文件
          res.status(200).json({
            code: 200,
            massage: '成功',
            result: {
              uploadedChunks: new Array(Number(chunkTotal)).fill().map((_, index) => index),
              chunkTotal: Number(chunkTotal)
            }
          })
        }
      );
    } else {
      res.status(200).json({
        code: 201,
        massage: '部分成功',
        result: {
          uploadedChunks: duplicateUploadedChunks,
          chunkTotal: Number(chunkTotal)
        }
      })
    }
  } catch (error) {
    console.error(error);
    rmDirContents(UPLOAD_MULTER_TEMP_DIR); // 删除临时文件
    return res.status(500).end();
  }
});

module.exports = requestRouter;

/api/upload/check接口分析

  1. 获取请求参数:
    • 从请求体 req.body 中获取文件的哈希值 fileHash、分片总数 chunkTotal 和文件名 fileName。
  2. 参数验证:
    • 检查是否获取到了必要的参数:fileHash 和 chunkTotal。如果缺少必要的参数,则返回状态码 200 和错误信息,提示缺少必要的参数。
  3. 文件存在性检查:
    • 使用 fs.existsSync 方法检查服务器上是否已存在完整的文件(文件名由 fileHash 和 fileName 组成)。
    • 如果文件已存在,则:
      • 如果存在临时文件夹 tempChunkDir,则删除该临时文件夹及其内容。
      • 返回状态码 200 和成功信息,提示文件已存在,并返回已上传的分片列表为空,以及文件存在状态为 true。
  4. 获取已上传的分片索引:
    • 如果文件不存在,则调用 getUploadedChunksIdx 函数获取已上传的分片索引。
    • 返回状态码 200 和成功信息,返回已上传的分片索引列表和文件存在状态为 false。
  5. 错误处理:
    如果在处理过程中发生错误(例如文件系统操作失败),则捕获错误,删除临时文件夹 UPLOAD_MULTER_TEMP_DIR 的内容,并返回状态码 500,表示服务器内部错误。

/api/upload接口分析

  1. 获取请求参数和文件:
    • 使用 upload.single(‘chunk’) 中间件从请求中获取单个文件分片 chunk。
    • 从请求体 req.body 中获取分片索引 index、文件名 fileName、文件哈希值 fileHash、分片哈希值 chunkHash 和分片总数 chunkTotal。
  2. 检查文件是否已存在:
    • 使用 isFileOrDirInExist 函数检查服务器上是否已存在完整的文件(文件名由 fileHash 和 fileName 组成)。
    • 如果文件已存在,则删除临时文件夹 UPLOAD_MULTER_TEMP_DIR 的内容,并返回状态码 200 和成功信息,提示文件已存在,返回文件哈希值。
  3. 创建切片目录:
    • 使用 fse.access 检查临时切片目录 tempChunkDir 是否存在,如果不存在,则使用 fse.mkdir 创建该目录。
  4. 参数验证:
    • 检查是否获取到了必要的参数:fileName 和 fileHash。如果缺少必要的参数,则返回状态码 200 和错误信息,提示缺少必要的参数。
  5. 保存分片文件:
    • 使用 fse.rename 将上传的分片文件重命名并移动到临时切片目录中,文件名使用分片哈希值 chunkHash。
  6. 获取已上传的分片索引:
    • 调用 getUploadedChunksIdx 函数获取已上传的分片索引列表。
  7. 文件合并:
    • 如果已上传的分片数量等于分片总数,则调用 mergeChunks 函数进行文件合并。
    • 文件合并成功后,删除临时文件夹 UPLOAD_MULTER_TEMP_DIR 的内容,并返回状态码 200 和成功信息,返回已上传的分片列表和分片总数。
    • 如果文件合并失败,返回状态码 500,表示服务器内部错误。
  8. 返回部分成功信息:
    • 如果分片上传成功但未达到分片总数,则返回状态码 200 和部分成功信息,返回已上传的分片列表和分片总数。
  9. 错误处理:
    • 如果在处理过程中发生错误(例如文件系统操作失败),则捕获错误,删除临时文件夹 UPLOAD_MULTER_TEMP_DIR 的内容,并返回状态码 500,表示服务器内部错误。

效果

在这里插入图片描述

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2271567.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

源代码编译安装X11及相关库、vim,配置vim(1)

一、目录结构 如下。 所有X11及相关库装到mybuild&#xff0c;源代码下载到src下&#xff0c;解压&#xff0c;进入&#xff0c;编译安装。编译时指定--prefix到相同的目录&#xff0c;即上图中mybuild。 ./configure --prefixpwd/../../mybuild [CFLAGS"-I/path/to/X11…

bytetrack 解决跟踪后框晃动的问题

使用距离最近的匹配的检测框 替代 bytetrack返回的跟踪框 作为最终的返回结果 完整byte_tracker.py代码为&#xff1a; import numpy as np from collections import deque import os import os.path as osp import copy import torch import torch.nn.functional as Ffrom …

如何使用OBS Studio录制屏幕?

可以进入官网或github进行下载&#xff1a; https://obsproject.com/download 安装包解压后进入bin 进入64-bit 选择obs 64 进入OBS Studio后在来源内右键&#xff0c;选择添加 选择添加显示器采集即可录取整个屏幕&#xff0c;窗口采集可选择窗口进行录制 选择对应显示器即配置…

ArcGIS Server 10.2授权文件过期处理

新的一年&#xff0c;arcgis server授权过期了&#xff0c;服务发不不了。查看ecp授权文件&#xff0c;原来的授权日期就到2024.12.31日。好吧&#xff0c;这里直接给出处理方法。 ArcGIS 10.2安装时&#xff0c;有的破解文件中会有含一个这样的注册程序&#xff0c;没有的话&…

循环冗余校验CRC的介绍

一、简介 循环冗余校验CRC&#xff08;Cyclic Redundancy Check&#xff09;是数据通信领域中最常用的一种差错校验码。该校验方法中&#xff0c;使用多项式出发&#xff08;模2除法&#xff09;运算后的余数为校验字段。CRC只能实现检错&#xff0c;不能实现纠错&#xff0c;使…

消息中间件类型都有哪些

在消息中间件的专业术语中&#xff0c;我们可以根据其特性和使用场景将其划分为几种主要的类型。这些类型不仅反映了它们各自的技术特点&#xff0c;还决定了它们在不同应用场景下的适用性。 1. 点对点&#xff08;Point-to-Point&#xff09;消息中间件&#xff1a; • 这类中…

微信小程序中 “页面” 和 “非页面” 的区别

微信小程序中 “页面” 和 “非页面” 的区别&#xff0c;并用表格进行对比。 核心概念&#xff1a; 页面 (Page)&#xff1a; 页面是微信小程序中用户可以直接交互的视图层&#xff0c;也是小程序的基本组成部分。每个页面都有自己的 WXML 结构、WXSS 样式和 JavaScript 逻辑…

卸载wps后word图标没有变成白纸恢复

这几天下载了个wps教育版&#xff0c;后头用完了删了 用习惯的2019图标 给兄弟我干没了&#xff1f;&#xff1f;&#xff1f; 其他老哥说什么卸载关联重新下 &#xff0c;而且还要什么撤销保存原来的备份什么&#xff0c;兄弟也是不得不怂了 后头就发现了这个半宝藏博主&…

SQL Server导出和导入可选的数据库表和数据,以sql脚本形式

一、导出 1. 打开SQL Server Management Studio&#xff0c;在需要导出表的数据库上单击右键 → 任务 → 生成脚本 2. 在生成脚本的窗口中单击进入下一步 3. 如果只需要导出部分表&#xff0c;则选择第二项**“选择具体的数据库对象(Select specific database objects)”**&am…

基于SpringBoot在线竞拍平台系统功能实现十五

一、前言介绍&#xff1a; 1.1 项目摘要 随着网络技术的飞速发展和电子商务的普及&#xff0c;竞拍系统作为一种新型的在线交易方式&#xff0c;已经逐渐深入到人们的日常生活中。传统的拍卖活动需要耗费大量的人力、物力和时间&#xff0c;从组织拍卖、宣传、报名、竞拍到成…

Android Camera压力测试工具

背景描述&#xff1a; 随着系统的复杂化和业务的积累&#xff0c;日常的功能性测试已不足以满足我们对Android Camera相机系统的测试需求。为了确保Android Camera系统在高负载和多任务情况下的稳定性和性能优化&#xff0c;需要对Android Camera应用进行全面的压测。 对于压…

配置嵌入式服务器

一、如何定制和修改Servlet容器的相关配置 修改和server有关的配置&#xff08;ServerProperties&#xff09; server.port8081 server.context‐path/tx server.tomcat.uri-encodingUTF-8二、注册servlet三个组件【Servlet、Filter、Listener】 由于SpringBoot默认是以jar包…

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,…

3D高斯点云CUDA版本数据制作与demo运行

0. 简介 关于UCloud(优刻得)旗下的compshare算力共享平台 UCloud(优刻得)是中国知名的中立云计算服务商&#xff0c;科创板上市&#xff0c;中国云计算第一股。 Compshare GPU算力平台隶属于UCloud&#xff0c;专注于提供高性价4090算力资源&#xff0c;配备独立IP&#xff0c;…

框架模块说明 #09 日志模块_01

背景 日志模块是系统的重要组成部分&#xff0c;主要负责记录系统运行状态和定位错误问题的功能。通常&#xff0c;日志分为系统日志、操作日志和安全日志三类。虽然分布式数据平台是当前微服务架构中的重要部分&#xff0c;但本文的重点并不在此&#xff0c;而是聚焦于自定义…

conda指定路径安装虚拟python环境

DataBall 助力快速掌握数据集的信息和使用方式&#xff0c;会员享有 百种数据集&#xff0c;持续增加中。 需要更多数据资源和技术解决方案&#xff0c;知识星球&#xff1a; “DataBall - X 数据球(free)” -------------------------------------------------------------…

aws(学习笔记第二十二课) 复杂的lambda应用程序(python zip打包)

aws(学习笔记第二十二课) 开发复杂的lambda应用程序(python的zip包) 学习内容&#xff1a; 练习使用CloudShell开发复杂lambda应用程序(python) 1. 练习使用CloudShell CloudShell使用背景 复杂的python的lambda程序会有许多依赖的包&#xff0c;如果不提前准备好这些python的…

driftingblues6靶场攻略

首先 打开kali&#xff0c;扫描主机 地址是192.168.111.143 访问网站 主页源码看一看&#xff0c;没啥用 老套路&#xff0c; 用nmap扫描一下开放端口 用dirsearch扫描一下目录 如果说扫描不到&#xff0c;那就可能是字典不行&#xff0c;换工具就完了 nmap -sV 192.168.…

【顶刊TPAMI 2025】多头编码(MHE)之Part 6:极限分类无需预处理

目录 1 标签分解方法的消融研究2 标签分解对泛化的影响3 讨论4 结论 论文&#xff1a;Multi-Head Encoding for Extreme Label Classification 作者&#xff1a;Daojun Liang, Haixia Zhang, Dongfeng Yuan and Minggao Zhang 单位&#xff1a;山东大学 代码&#xff1a;https:…

vue视频录制 限制大小,限制时长

<template><div style"height: 100vh;background: #000;"><span style"color: #fff;font-size: 18px;">切换数量&#xff1a;{{ devices.length }}</span><video ref"video" autoplay muted playsinline></vid…