基于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. 当然还存在断点续传的场景需要讨论,我们后面会进一步拓展,此处只是一个简单的功能实现。
相关推荐
黄尚圈圈26 分钟前
Vue 中引入 ECharts 的详细步骤与示例
前端·vue.js·echarts
浮华似水1 小时前
简洁之道 - React Hook Form
前端
2401_857622663 小时前
SpringBoot框架下校园资料库的构建与优化
spring boot·后端·php
正小安3 小时前
如何在微信小程序中实现分包加载和预下载
前端·微信小程序·小程序
2402_857589363 小时前
“衣依”服装销售平台:Spring Boot框架的设计与实现
java·spring boot·后端
哎呦没5 小时前
大学生就业招聘:Spring Boot系统的架构分析
java·spring boot·后端
_.Switch5 小时前
Python Web 应用中的 API 网关集成与优化
开发语言·前端·后端·python·架构·log4j
一路向前的月光5 小时前
Vue2中的监听和计算属性的区别
前端·javascript·vue.js
长路 ㅤ   5 小时前
vite学习教程06、vite.config.js配置
前端·vite配置·端口设置·本地开发
长路 ㅤ   5 小时前
vue-live2d看板娘集成方案设计使用教程
前端·javascript·vue.js·live2d