基于NestJs实现简单的大文件分片上传

前言

日常工作中,实现文件上传最常见的开发需求之一。一般来说,一个excel文件的大小也就在10MB以内,如果有好几十MB的文件,就勉强算是中等文件吧,只不过一次上传一个几十MB的文件,接口会慢一些。但是,如果碰到上百兆或者几个G以上的文件,例如视频、音频等,上述方式就不合适了。咱们就把 大文件拆分,进行分片上传,等上传完了,再将一片片文件合并一起,再恢复成原来的样子即可。

1. 基于nestjs构建简单的前后端服务

本文的demo是基于nestjs搭建的,启动后台服务后,如果想要直接构建同源的前端服务,可以在nestjs框架的入口处增加如下配置:

js 复制代码
async function bootstrap() {
  ......
  // 增加之后,可以通过本地后台地址和端口启动前端服务
  app.useStaticAssets('public', { prefix: '/static' });
  .....
}

public 文件目录下面新建一个upload.html文件,本地nestjs 服务启动之后,可以在浏览器输入地址:http://localhost:3000/static/upload.html 访问前端页面。这样一个简单的前后端服务搭建完成,我们基于此服务来实现大文件分片上传的demo。

2. 流程分析与问题思考

大文件的分片上传流程可以通过以下几个步骤实现:

    1. 在前端,将上传大文件拆分成一片又一片,并标记分片顺序和文件hash;
    1. 前端发起上传请求,每一次请求给后端带一片文件,在后端接受分片的所有文件并缓存;
    1. 当所有分片文件都上传完,再通过前端发出合并请求,告知后端将分片的文件合并;

基于上述的流程,我们需要思考以下问题:如果某个文件已经存在(曾经上传过),那我还需要上传吗?如果同一时刻,不同的用户都在上传,我们怎么鉴别那些文件是属于同一个文件类的呢?是否很容易造成分片混淆?因此我们需要具体的文件id,就不会操作错了。前端如何确定文件的id,如何才能得到文件的唯一标识?此处我们需要依赖一个插件 spark-md5

3. 通过 spark-md5 标记分片文件的 hash

spark-md5 是基于md5的一种优秀的算法,可以通过文件读取后,用来计算文件的唯一身份证标识 ------ hash值。 先全局引入该插件:

js 复制代码
 <script src='./spark-md5.js'></script>

实现通过SparkMD5来获取文件hash的逻辑:

js 复制代码
const getFileHash = (file) => {
    return new Promise((resolve) => {
        const fileReader = new FileReader();
        fileReader.readAsArrayBuffer(file);
        fileReader.onload = function (e) {
            let fileMd5 = SparkMD5.ArrayBuffer.hash(e.target.result);
            resolve(fileMd5);
        }
    });
}

如果直接计算一个整文件的hash值,文件小还好,如果文件比较大的,直接计算一整个文件的hash值,就会比较慢了。大文件分片不仅仅可以用于发送请求传递给后端,也可以用于计算大文件的hash值。直接计算一个大文件hash值任务慢,那就拆分成一些小任务,这样效率也提升了不少

4. 实现大文件的分片

文件file 是一种特殊的blob文件,因此可以通过 slice 方法进行切割,这是实现大文件分片的主要方法。

js 复制代码
const chunkSize = 1024 * 1024 * 1; // 预设切片大小限制 1MB
const createChunks = (file) => {
    // 接受一个文件对象,要把这个文件对象切片,返回一个切片数组
    const chunks = [];
    // 文件大小.slice(开始位置,结束位置)
    let start = 0;
    let index = 0;
    while (start < file.size) {
        let curChunk = file.slice(start, start + chunkSize);
        chunks.push({
            file: curChunk,
            uploaded: false,
            chunkIndex: index,
            fileHash: fileHash,
        });
        index++;
        start += chunkSize;
    }
    return chunks;
}

5. 前后端实现分片上传接口

前端通过表单提交的形式发送数据到服务端,表单中包含切割分片后的blob文件、文件名称、分片的文件hash、分片的文件序号等信息:

js 复制代码
function uploadHandler(chunk, chunks){
  return new Promise(async (resolve, reject) => {
      try {
          let fd = new FormData();
          fd.append('fileName', fileName);
          fd.append('file', chunk.file);
          fd.append('total', chunks.length);
          fd.append('fileHash', chunk.fileHash);
          fd.append('chunkIndex', chunk.chunkIndex);
          let result = await fetch('/upload/fileUpload', {
              method: 'POST',
              body: fd
          }).then(res => res.json());
          chunk.uploaded = true;
          resolve(result)
      } catch (err) {
          reject(err)
      }
  })
}

为了实现后端接口, 通过 nest g resource upload实现一个upload模块,为了处理理文件上传,Nest 提供了一个基于 Expressmulter中间件,MulterModule.register设置文件最终默认的存放路径,在UploadModule 中实现以下逻辑:

js 复制代码
import { Module } from '@nestjs/common';
import { UploadService } from './upload.service';
import { UploadController } from './upload.controller';
import { MulterModule } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import { join, extname } from 'path';

@Module({
  imports: [
    MulterModule.register({
      storage: diskStorage({
        destination: join(__dirname, '../files'),
        filename: (_, file, callback) => {
          const fileName = `${
            new Date().getTime() + extname(file.originalname)
          }`;
          return callback(null, fileName);
        },
      }),
    }),
  ],
  controllers: [UploadController],
  providers: [UploadService],
})
export class UploadModule {}

然后在UploadController 中新增接口/fileUpload,获取默认的文件缓存路径 file.path并将分片的文件移动到新的文件目录下统一处理:

js 复制代码
// 接受前端分片的大文件上传
@Post('fileUpload')
@UseInterceptors(FileInterceptor('file'))
fileUpload(@UploadedFile() file, @Req() req) {
    const { fileName, fileHash, chunkIndex } = req.body || {};
    if (!existsSync(newPath)) {
      mkdirSync(newPath);
    }
    rename(file.path, `${newPath}/${fileHash}-${chunkIndex}`, (err) => {
      if (err) {
        console.log('err', err);
      }
      console.log('上传成功');
    });
}

6. 前后端实现分片合并的接口

在分片请求的接口结束之后,发起合并的接口,请求参数包括文件hash、文件名等信息。

js 复制代码
// 发起合并分片
function merge(fileName,fileHash,total){
  let result = fetch('/upload/fileMerge', {
      method: 'POST',
      headers:{
          'Content-Type':'application/json'
      },
      body: JSON.stringify({
          fileName: fileName,
          fileHash: fileHash,
          total: total
      }) 
  }).then(res => res.json());
}

后台在UploadController 中新增接口/fileMerge,通过合并所有分片文件实现文件的统一。

js 复制代码
@Post('fileMerge')
fileMerge(@Req() req) {
    const { fileHash, fileName, total } = req.body;
    const mergePathArr = [];
    // 找出所有切片文件
    for (let i = 0; i < total; i++) {
      mergePathArr.push(`${newPath}/${fileHash}-${i}`);
    }
    // 合并切片文件
    mergePathArr.forEach((path) => {
      const content = readFileSync(path);
      appendFileSync(`${newPath}/${fileName}`, content);
    });
}

7. 完整代码

前端:

html 复制代码
<!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>大文件上传</title>
        <script src='./spark-md5.js'></script>
    </head>
    <body>
        <h3>大文件上传</h3>
        <input type="file">
    </body>

    <script>
      // 使用单独常量保存预设切片大小 1MB
      const chunkSize = 1024 * 1024 * 1; 
      
      // 分片
      const createChunks = (file) => {
          // 接受一个文件对象,要把这个文件对象切片,返回一个切片数组
          const chunks = [];
          // 文件大小.slice(开始位置,结束位置)
          let start = 0;
          let index = 0;
          while (start < file.size) {
              let curChunk = file.slice(start, start + chunkSize);
              chunks.push({
                  file: curChunk,
                  uploaded: false,
                  chunkIndex: index,
                  fileHash: fileHash,
              });
              index++;
              start += chunkSize;
          }
          return chunks;
      }
      
      // 通过SparkMD5来获取文件hash
      const getFileHash = (file) => {
          return new Promise((resolve) => {
              const fileReader = new FileReader();
              fileReader.readAsArrayBuffer(file);
              fileReader.onload = function (e) {
                  let fileMd5 = SparkMD5.ArrayBuffer.hash(e.target.result);
                  resolve(fileMd5);
              }
          });
      }
      
      //发起合并分片
      function merge(fileName,fileHash,total){
          let result = fetch('/upload/fileMerge', {
              method: 'POST',
              headers:{
                  'Content-Type':'application/json'
              },
              body: JSON.stringify({
                  fileName: fileName,
                  fileHash: fileHash,
                  total: total
              }) 
          }).then(res => res.json());
      }

      //分片上传
      function uploadHandler(chunk, chunks){
          return new Promise(async (resolve, reject) => {
              try {
                  let fd = new FormData();
                  fd.append('fileName', fileName);
                  fd.append('file', chunk.file);
                  fd.append('total', chunks.length);
                  fd.append('fileHash', chunk.fileHash);
                  fd.append('chunkIndex', chunk.chunkIndex);
                  let result = await fetch('/upload/fileUpload', {
                      method: 'POST',
                      body: fd
                  }).then(res => res.json());
                  chunk.uploaded = true;
                  resolve(result)
              } catch (err) {
                  reject(err)
              }
          })
      }

      // 文件相关信息
      let [ fileHash, fileName, file ] = [ '', '', document.querySelector('input') ]
      //提交文件
      file.onchange = async (e)=>{
          let file = e.target.files[0];
          // 设置文件名
          fileName = file.name;
          // 获取文件hash值
          fileHash = await getFileHash(file);
          // 获取分片之后的文件数据
          chunks = createChunks(file);
          // 分段上传分片文件
          chunks.forEach((chunk)=>{
              uploadHandler(chunk,chunks);
          });
          
          //模拟分片完成之后,发起合并分片文件请求
          setTimeout(()=>{
              merge(fileName,fileHash,chunks.length);
          },1000)
      }   
    </script>
</html>

后端:

ts 复制代码
import {
  Controller,
  Post,
  UseInterceptors,
  UploadedFile,
  Res,
  Body,
  Req,
} from '@nestjs/common';
import { UploadService } from './upload.service';
import { FileInterceptor } from '@nestjs/platform-express';
import { Response } from 'express';
import { join, resolve } from 'path';
import {
  rename,
  existsSync,
  mkdirSync,
  appendFileSync,
  readFileSync,
} from 'fs';

const newPath = resolve(__dirname, `../../../files`);

@Controller('upload')
export class UploadController {
  constructor(private readonly uploadService: UploadService) {}

  // 接受前端分片的大文件上传
  @Post('fileUpload')
  @UseInterceptors(FileInterceptor('file'))
  fileUpload(@UploadedFile() file, @Req() req) {
    const { fileName, fileHash, chunkIndex } = req.body || {};
    if (!existsSync(newPath)) {
      mkdirSync(newPath);
    }
    rename(file.path, `${newPath}/${fileHash}-${chunkIndex}`, (err) => {
      if (err) {
        console.log('err', err);
      }
      console.log('上传成功');
    });
  }

  // 合并切片
  @Post('fileMerge')
  fileMerge(@Req() req) {
    const { fileHash, fileName, total } = req.body;
    const mergePathArr = [];
    // 找出所有切片文件
    for (let i = 0; i < total; i++) {
      mergePathArr.push(`${newPath}/${fileHash}-${i}`);
    }
    // 合并切片文件
    mergePathArr.forEach((path) => {
      const content = readFileSync(path);
      appendFileSync(`${newPath}/${fileName}`, content);
    });
  }
}

总结

  1. 前端大文件上传核心是利用 Blob.prototype.slice 方法,和数组的 slice 方法相似,文件的 slice 方法可以返回原文件的某个切片
  2. 借助 http 的可并发性,同时上传多个切片。这样从原本传一个大文件,变成了并发传多个小的文件切片,可以大大减少上传时间。
  3. 由于是并发,传输到服务端的顺序可能会发生变化,因此我们还需要给每个切片记录顺序
  4. 服务端负责接受前端传输的切片,并在接收到所有切片后合并。此处简单的做法就是前端可以额外发一个请求,主动通知服务端进行切片的合并
  5. 当然还存在断点续传的场景需要讨论,我们后面会进一步拓展,此处只是一个简单的功能实现。
相关推荐
阿珊和她的猫23 分钟前
v-scale-scree: 根据屏幕尺寸缩放内容
开发语言·前端·javascript
uzong4 小时前
技术故障复盘模版
后端
GetcharZp4 小时前
基于 Dify + 通义千问的多模态大模型 搭建发票识别 Agent
后端·llm·agent
加班是不可能的,除非双倍日工资5 小时前
css预编译器实现星空背景图
前端·css·vue3
桦说编程5 小时前
Java 中如何创建不可变类型
java·后端·函数式编程
IT毕设实战小研5 小时前
基于Spring Boot 4s店车辆管理系统 租车管理系统 停车位管理系统 智慧车辆管理系统
java·开发语言·spring boot·后端·spring·毕业设计·课程设计
wyiyiyi5 小时前
【Web后端】Django、flask及其场景——以构建系统原型为例
前端·数据库·后端·python·django·flask
gnip6 小时前
vite和webpack打包结构控制
前端·javascript
excel6 小时前
在二维 Canvas 中模拟三角形绕 X、Y 轴旋转
前端
阿华的代码王国6 小时前
【Android】RecyclerView复用CheckBox的异常状态
android·xml·java·前端·后端