vue+nestjs大文件分片上传

105 阅读3分钟
  • 逻辑梗概:
    • 将大文件切成多个文件块
    • 逐个上传文件块给服务端
    • 服务端根据顺序合并文件块
  • 优势分析
    • 减轻服务器压力
      • 如果一次性上传大文件,服务器的存储和网络带宽压力都会非常大,而通过切片,可以将这些压力分散到多个小文件中,减轻服务器的压力
      • 断点续传,错误重试:因为大文件被分解了,如果因为一些原因中断了,已上传的部分就不用再重新上传了,只需要把后续的传上就好了
  • 前端
    • 切片
    • 上传切片
    • 断点续传,上传未完成的切片
  • 后端
    • 收切片,存切片
    • 合并切片
    • 校验文件是否存在
  • 代码实现
    • 前端,基于vue
      • 基础页面
        <template>
            <div class="hello">
                <input type="file" id="file" @change="getFile" />
                <input type="button" id="upload" value="上传" @click="uploadFile" />
                <input type="button" id="continue" value="继续上传" @click="continueUpload" />
            </div>
        </template>
        
      • 点击获取文件数据
        const getFile = async (event) => {
            const file = event.target.files[0];
            fileRef.value = file;
        };
        
      • 获取文件hash,文件唯一标识,服务端根据该标识判断文件是否已上传,需要下载三方包spark-md5
        import SparkMD5 from 'spark-md5';
        const getFileHash = (file) => {
            return new Promise((resolve, reject) => {
                const fileReader = new FileReader();
                fileReader.onload = function (e) {
                    const fileMd5 = SparkMD5.ArrayBuffer.hash(e.target.result);
                    resolve(fileMd5);
                };
                fileReader.onerror = function (err) {
                    reject(err);
                };
                fileReader.readAsArrayBuffer(file);
            });
        };
        
      • 获取文件切片,每个切片默认1M
        const createChunks = async (file, chunkSize = 1024 * 1024 * 1) => {
            const chunks = [];
            let index = 0; //分片索引
            let start = 0; //分片开始位置
            while (start < file.size) {
                const blob = file.slice(start, Math.min(start + chunkSize, file.size));
                chunks.push({
                    blob: blob,
                    uploaded: false, // 分片是否上传
                    chunkIndex: index, // 分片序号
                });
        
                index++;
                start += chunkSize;
            }
        
            fileChunks.value = chunks;
            return chunks;
        };
        
      • 上传接口封装
        const uploadEvent = (chunk) => {
            let fd = new FormData();
            fd.append('file', chunk.blob);
            fd.append('chunkIndex', chunk.chunkIndex);
            fd.append('fileHash', fileHash.value);
            return axios.post(
                'http://localhost:4000/admin/sys/log/file/upload',
                fd,
                {
                    headers: {
                    'Content-Type': 'multipart/form-data',
                    },
                }
            );
        };
        
      • 批量上传分片
            const limitFetch = async (chunks, count = 10) => {
                let runningCount = 0; // 正在执行的任务数
                const run = () => {
                    while (chunks.length > 0 && runningCount < count) {
                        const chunk = chunks.shift();
                        uploadEvent(chunk)
                        .then(() => {
                            chunk.uploaded = true;
                        })
                        .finally(() => {
                            runningCount--;
                            run();
                        });
                        runningCount++;
                    }
                };
                run();
            };
        
      • 合并文件分片
        const mergeFile = (fileHash, fileName) => {
            return axios.post(
                'http://localhost:4000/admin/sys/log/file/merge',
                {
                   fileHash,
                   fileName
                }
            );
        };
        
      • 抽离上传操作,获取总文件hash和切片可以同时进行加快速率,拿到分片后批量上传,最后发起合并请求
            const uploadFile = async () => {
                Promise.all([
                    getFileHash(fileRef.value),
                    createChunks(fileRef.value),
                ]).then(async (data) => {
                    const [hashId, chunks] = data;
                    fileHash.value = hashId;
                    await limitFetch(chunks);
                    await mergeFile(fileHash.value, fileRef.value.name);
                })
                .catch((err) => {
                    console.error('获取文件哈希或分片失败:', err);
                });
            };
        
      • 分片断点续传,只上传uploaded为false的切片
        const continueUpload = async () => {
            if (fileChunks.value.length == 0 || !fileHash.value) {
                return;
            }
        
            try {
                await uploadEvent(
                    fileChunks.value.filter((chunk) => !chunk.uploaded)
                );
                await mergeFile(fileHash.value, fileRef.value.name);
            } catch (err) {
                return {
                    mag: '文件上传错误',
                    success: false,
                };
            }
        };
        
    • 后端,基于nestjs
      • 接受上传的文件
         // 上传文件
         @Post('file/upload')
         @UseInterceptors(FileInterceptor('file')) //指定接收前端formData的file字段
         async uploadFile(
             @UploadedFile() file: Express.Multer.File,
             @Body() dto: FileInfoDto,
         ) {
             // 获取文件路径
             const tempPath = join(process.cwd(), 'uploads', `${dto.fileHash}`);
             try {
                 // console.log('文件已经存在’)
                 await fs.access(tempPath);
             } catch (e) {
                 // 文件夹不存在,创建文件夹
                 await fs.mkdir(tempPath, { recursive: true });
             }
             const tempChunkPath = join(tempPath, `${dto.chunkIndex}`);
             try {
                 await fs.access(tempChunkPath);
             } catch (e) {
                 await fs.writeFile(tempChunkPath, file.buffer);
             }
             return { code: 0, data: dto.chunkHash + '上传成功' };
         }
        
      • 合并分片
        @Post('file/merge')
        @Authorize()
        async mergeFile(@Body() dto: { fileHash: string; fileName: string }) {
          // 最终合并的文件路径
          const filePath = join(process.cwd(),'uploads',`${dto.fileHash}${extname(dto.fileName)}`);
        
          // 获取临时文件夹路径
          const tempPath = join(process.cwd(), 'uploads', `${dto.fileHash}`);
          // 拿到文件夹内容
          const chunkPaths = await fs.readdir(tempPath);
        
          let i = 0;
          while (i < chunkPaths.length) {
              const chunkPath = join(tempPath, i + '');
              const chunk = await fs.readFile(chunkPath); // 读取分片内容
              await fs.appendFile(filePath, chunk); // 将内容合并到最终文件内
              await fs.unlink(chunkPath); // 删除已经读取的分片文件
              i++;
          }
        
          await fs.rm(tempPath, { recursive: true, force: true }); // 删除临时文件夹路径
        
          return { code: 0, message: '文件合并成功' };
        }
        
      参考链接:blog.csdn.net/yb1314111/a…