大文件上传 + express、nest后端

1. 需求

前端在上传大文件的时候,为了网页性能,需要对大文件进行切割,然后上传到后端,并在全部切片上传完成之后通知后端进行合并,生成最终要上传的完整文件。 使用到的框架(库):

  1. Axios, CDN地址
  2. NestJs
  3. Multer

2. 需求分析

练习项目,先不考虑异常情况,假设文件切片全部上传成功。

前端

  1. 切割
  2. 切割之后,按顺序上传 ,并传递文件名+切片序号给后端
  3. 上传全部切片后,通知后端合并

后端

  1. 上传接口,并创建临时的文件夹对切片进行保存
  2. 合并接口,获取接口参数的文件名字段 ,获取该文件名对应的临时文件夹 ,读取里面的文件列表 ,进行排序后,利用Stream合并成一个完整的文件。

3. 尝试单个文件上传和保存

先尝试单个文件的上传,把整个流程走通。

3.1 前端

用input[file] 元素选择文件,然后用到Axios调用接口,Axios直接引用CDN。

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>大文件</title>
    <script src="https://cdn.bootcdn.net/ajax/libs/axios/1.5.0/axios.min.js"></script>
</head>
<body>
<input type="file" id="inputFile">
<button id="uploadFile">发送</button>

<script>
document.querySelector('#uploadFile').addEventListener('click', function (e) {
    const file = document.querySelector('#inputFile').files[0]
    const formData = new FormData()
    formData.set('name', file.name)
    formData.append('files', file)
    // http://localhost:7788/upload 我本地开启的服务
    return window.axios.post('http://localhost:7788/upload', formData, {
        contentType: 'multipart/form-data'
    })
})
</script>

3.2 nest后端

安装nest 脚手架

cli 复制代码
npm i -g @nestjs/cli

1. nest/cli创建nest项目

nest cli的文档

cli 复制代码
nest new big-file-upload

并在main.ts中 设置nest支持同源

js 复制代码
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.enableCors() // 设置允许不同域名的访问
  await app.listen(7788);
}
bootstrap();

2. 安装 @types/multer 类型依赖

cli 复制代码
yarn add @types/multer -D

3. 在 app.controller.js中添加upload接口

接口肯定为Post请求,需要用拦截器 Interceptors 接收文件,具体用FilesInterceptor接收。

js 复制代码
import { AppService } from './app.service';

@Controller()
export class AppController{
    constructor(privite readonly appService: AppService) {}
    
    @Post('upload')
    @UseInterceptors(FilesInterceptor('files', 2, {
        dest: 'uploads' // 这里的uploads 为上传文件存放的目录,需要在src同级目录中创建
    }))
    uploadFile(@UploadedFiles() files: Array<Express.Multer.File>, @Body() body) {
        console.log(body)
        console.log(files)
        return true
    }
}

4. 优化: 存放的文件名, 以及uploads

配置multer的diskStorage,对存放文件的文件夹做预处理,如果文件夹不存在,则利用node的fs模块创建。 文件名也是如此,对乱码的源文件名进行编码处理。文档地址

js 复制代码
可创建文件导出 storage ,也可以对在appController.ts中定义

const dirPath = path.join(process.cwd(), '/uploads'); // 拼凑出完成路径

const storage = multer.diskStorage({
  destination(
    req: express.Request,
    file: Express.Multer.File,
    callback: (error: Error | null, filename: string) => void,
  ) {
    if (!fs.existsSync(dirPath)) {
      fs.mkdirSync(dirPath);
    }
    callback(null, dirPath);
  },
  filename(
    req: e.Request,
    file: Express.Multer.File,
    callback: (error: Error | null, filename: string) => void,
  ) {
    const filename = Buffer.from(file.originalname, 'latin1').toString('utf8');
    callback(null, filename);
  },
});

3.3 express 后端

express官网

安装依赖

js 复制代码
yarn add express multer cors

创建 index.js 文件,并完成逻辑

代码逻辑和 上面的nest是一样的,利用multer 去接收文件

js 复制代码
const path = require('path')
const fs = require('fs')
const express = require('express')
const multer = require('multer')
const cors = require('cors')

const app = express()
app.use(cors()); // express支持同源

const dirPath = path.join(process.cwd(), '/uploads');

const storage = multer.diskStorage({
    destination: function (req, file, cb) {
        if (!fs.existsSync(dirPath)) {
            fs.mkdirSync(dirPath)
        }
        cb(null, dirPath)
    },
    filename: function (req, file, cb) {
        const filename = Buffer.from(file.originalname, "latin1").toString(
            "utf8"
        );
        cb(null, filename)
    }
})

const upload = multer({ storage })

app.post('/upload', upload.array('files', 2), function (req, res, next) {
    console.log('req.body', req.body);
    res.send('上传成功')
})

app.listen(7878, () => {
    console.log('http://localhost:7788 开启成功') // 在前端代码中需要替换对应的端口
});

4. 前端切割文件

上面是对单个文件直接进行上传处理, 现在主要是对该上传文件进行切割,然后逐一上传。 大部分逻辑还是同3.1。

4.1 切割

js 复制代码
<script> 
    document.querySelector('#uploadFile').addEventListener('click', function (e) { 
        const file = document.querySelector('#inputFile').files[0]

        const chunks = []
        let startPos = 0
        const chunkSize = 50 * 1024 // 10k
        while (startPos < file.size) {
            const chunk = file.slice(startPos, startPos + chunkSize)
            chunks.push(chunk)
            startPos += chunkSize
        }
        // 4.2 发送请求
    })
</script>

4.2 逐一发送请求

js 复制代码
const httpList = chunks.map((item, index) => {
    const formData = new FormData()
    formData.set('name', `${file.name}_${index}`)
    formData.append('files', item)
    return window.axios.post('http://localhost:7788/upload', formData, {
        contentType: 'multipart/form-data'
    })
})
// 4.3 通知合并

4.3 上传完毕后通知后端合并

javascript 复制代码
Promise.allSettled(httpList).then(list => {
    console.log(file)
    window.axios.get('http://localhost:7788/merge?name=' + encodeURIComponent(file.name))
})

5. 后端合并chunk文件

5.1 nest 合并文件

在上述第3点的例子中,我们知道了单个文件上传的后端逻辑。现在在此基础上对上传接口进行改造, 以及新增合并接口。

  1. 创建临时文件夹,存放切片
  2. 新增合并接口,生成完整文件

5.1.1 改造上传接口

js 复制代码
@Post('upload')
@UseInterceptors(
  FilesInterceptor('files', 2, {
    storage: storage,
  }),
)
uploadFile(
  @UploadedFiles() files: Array<Express.Multer.File>,
  @Body() params: { name: string },
): boolean {
  const file = files[0];
  const filename = params.name.match(/(.+)_\d+$/)[1]; // 在第四点钟,文件名加序号作为切片名字
  // 创建临时文件夹
  const fileNamePath = dirPath + '/chunk_' + filename;
  if (!fs.existsSync(fileNamePath)) {
    fs.mkdirSync(fileNamePath);
  }
  // 文件默认上传到uploads中, 将文件复制到临时文件夹中 并删除原文件
  fs.cpSync(file.path, `${fileNamePath}/${params.name}`);
  fs.rmSync(file.path);
  return true;
}

5.1.2 改造上传接口

利用fs.createReadStream 和 fs.createWriteStream 对文件进行创建和写入。

js 复制代码
@Get('merge')
mergeFile(@Query() params: { name: string }) {
  const chunkPath = 'uploads/chunk_' + params.name
  const fileList = fs.readdirSync(chunkPath); // 读取临时文件夹的文件,log出来可以看到是不按顺序的
  // 封装的一个 获取切片文件名后面的序号
  const getIndex = (str) => parseInt(str.match(/_\d+$/)[0].replace('_', ''), 10);
  // 对文件列表进行排序,保证合并的正确性
  const sortedList = fileList.sort((prev, next) => {
    return getIndex(prev) - getIndex(next);
  });
    
  let count = 0;
  let startPos = 0;
  sortedList.forEach((file) => {
    const filePath = `${chunkPath}/${file}`;
    const stream = fs.createReadStream(filePath); // 创建文件流

    stream
      .pipe(
        fs.createWriteStream('uploads/' + params.name, {
          start: startPos,
        }),
      )
      .on('finish', () => {
        count++;
         // 每次切片写入完成后,都会检查是否是最后一个文件,是的话,就删除临时文件夹
        if (count === sortedList.length) {
          fs.rm(
            chunkPath,
            {
              recursive: true,
            },
            () => {},
          );
        }
      });

    startPos += fs.statSync(filePath).size;
  });

  return true;
}

5.2 express 合并文件

这里的业务逻辑 和 用到的语法和nest的大致一样。可以把express单文件代码参照nest的代码进行改造。

6. 待优化点

  1. 前端可以使用多并发,加快上传效率;
  2. 对上传失败情况进行处理,如果上传失败了,对该切片进行记录,提供按钮给用户重新上传;
  3. 断点续传。

7. 结语

该项目还很粗糙,需要花时间去优化以及学习nest更多知识。

大文件切片上传前端 + nest源码。前端代码在static/index.html 内。

相关推荐
_jiang2 天前
nestjs 入门实战最强篇
redis·typescript·nestjs
敲代码的彭于晏5 天前
【Nest.js 10】JWT+Redis实现登录互踢
前端·后端·nestjs
前端小王hs18 天前
Nest通用工具函数执行顺序
javascript·后端·nestjs
明远湖之鱼20 天前
从入门到入门学习NestJS
前端·后端·nestjs
吃葡萄不吐番茄皮22 天前
从零开始学 NestJS(一):为什么要学习 Nest
前端·nestjs
东方小月23 天前
Vue3+NestJS实现权限管理系统(六):接口按钮权限控制
前端·后端·nestjs
白雾茫茫丶25 天前
Nest.js 实战 (十四):如何获取客户端真实 IP
nginx·nestjs
Spirited_Away1 个月前
Nest世界中的AOP
前端·node.js·nestjs
Eric_见嘉1 个月前
NestJS 🧑‍🍳 厨子必修课(六):Prisma 集成(下)
前端·后端·nestjs
kongxx2 个月前
NestJS中使用Guard实现路由保护
nestjs