关于文件上传,Nest.js会怎么做

513 阅读6分钟

前言

post 请求 一般有 4 种数据类型:

  • application/x-www-form-urlencoded
  • multipart/form-data
  • application/json
  • text/xml

Express 中,会通过不同的中间件来解析不同类型的数据。比如:

  • express.text() 用于解析 text 类型的 req.body,解析成 string
  • express.json() 用于解析 json 类型的 req.body
  • express.urlencoded() 用于解析 urlencoded 类型的 req.body

上面的中间件都是基于 body-parser 中间件封装的,但该中间件不能处理 multipart 类型的 req.body。因此,另一个中间件登场了,它就是 Multer

注意:Multer 不会处理任何非 multipart/form-data 类型的表单数据。

Multer 介绍和使用

安装 Multer

npm install multer --save

Multer API

详情查看 Multer 官方文档

multer(opts)

Multer 接受一个 options 对象,其中最基本的是 dest 属性,这将告诉 Multer 将上传文件保存在哪。如果你省略 options 对象,这些文件将保存在内存中,永远不会写入磁盘。

传递给 Multer 的选项有:

KeyDescription
dest or storage在哪里存储文件
fileFilter文件过滤器,控制哪些文件可以被接受
limits限制上传的数据
preservePath保存包含文件名的完整文件路径

.single(fieldname)

接受一个以 fieldname 命名的文件。这个文件的信息保存在 req.file

.array(fieldname[, maxCount])

接受一个以 fieldname 命名的文件数组。可以配置 maxCount 来限制上传的最大数量。这些文件的信息保存在 req.files

.fields(fields)

接受指定 fields 的混合文件。这些文件的信息保存在 req.files

fields 应该是一个对象数组,应该具有 name 和可选的 maxCount 属性。

.none()

只接受文本域。如果任何文件上传到这个模式,将发生 "LIMIT_UNEXPECTED_FILE" 错误。这和 upload.fields([]) 的效果一样。

.any()

接受一切上传的文件。文件数组将保存在 req.files

确保你总是处理了用户的文件上传。 永远不要将 multer 作为全局中间件使用,因为恶意用户可以上传文件到一个你没有预料到的路由,应该只在你需要处理上传文件的路由上使用。

storage

磁盘存储引擎 (DiskStorage)

磁盘存储引擎可以让你控制文件的存储。有两个选项可用,destinationfilename。他们都是用来确定文件存储位置的函数。

  • destination 是用来确定上传的文件应该存储在哪个文件夹中。也可以提供一个 string (例如 '/tmp/uploads')。如果没有设置 destination,则使用操作系统默认的临时文件夹。

  • filename 用于确定文件夹中的文件名的确定。 如果没有设置 filename,每个文件将设置为一个随机文件名,并且是没有扩展名的。

内存存储引擎 (MemoryStorage)

内存存储引擎将文件存储在内存中的 Buffer 对象,它没有任何选项。当使用内存存储引擎,文件信息将包含一个 buffer 字段,里面包含了整个文件数据。

注意:当你使用内存存储,上传非常大的文件,或者非常多的小文件,会导致你的应用程序内存溢出。

limits

一个对象,指定一些数据大小的限制。

KeyDescriptionDefault
fieldNameSizefield 名字最大长度100 bytes
fieldSizefield 值的最大长度1MB
fields非文件 field 的最大数量无限
fileSize在 multipart 表单中,文件最大长度 (字节单位)无限
files在 multipart 表单中,文件最大数量无限
parts在 multipart 表单中,part 传输的最大数量(fields + files)无限
headerPairs在 multipart 表单中,键值对最大组数2000

fileFilter

设置一个函数来控制什么文件可以上传以及什么文件应该跳过,这个函数应该看起来像这样:

function fileFilter(req, file, cb) {
    // 这个函数应该调用 `cb` 用boolean值来
    // 指示是否应接受该文件

    // 拒绝这个文件,使用`false`,像这样:
    cb(null, false);

    // 接受这个文件,使用`true`,像这样:
    cb(null, true);

    // 如果有问题,你可以总是这样发送一个错误:
    cb(new Error("I don't have a clue!"));
}

在 Nest.js 中使用 Multer

众所周知,Nest 构建在强大的 HTTP 服务器框架上,默认底层框架就是 Express。因此,Nest.js 中也可以使用 Multer 中间件。

我们首先使用 nest 命令新建一个项目

如何创建 nest 项目,查看上一篇文章 看完这篇,Nestjs 入门了

再输入命令,安装 Multer (记得安装 multer 的 ts 类型的包)

npm install multer --save
npm install @types/multer --save-dev

单文件上传

在 AppController 添加一个 handler:

import { Body, Controller, Post, UploadedFile, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';

@Post('upload/single')
@UseInterceptors(FileInterceptor('file', { dest: 'uploadFiles' }))
uploadSingleFile(@UploadedFile() file: Express.Multer.File, @Body() body) {
    console.log('file- -->', file);
    console.log('body --->', body);
}

我们通过 FileInterceptor 提取 file 字段,然后通过 UploadedFiles 装饰器把它当作参数传入。然后写下前端代码,通过 http-server 提供静态服务。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
</head>
<body>
    <input id="fileInput" type="file" multiple/>
    <script>
        const fileInput = document.querySelector('#fileInput');

        async function formData() {
            const data = new FormData();
            data.set('title','测试文件');
            data.set('type', 'image');
            data.set('file', fileInput.files[0]);
            debugger

            const res = await axios.post('http://localhost:3000/upload/single', data);
            console.log(res);
        }

        fileInput.onchange = formData;
    </script>
</body>
</html>

浏览器访问: image.png

上传后,服务端就打印出 file 对象,且文件保存到了 uploadFiles 文件夹下。

image-1.png

此时,我们打开 uploadFiles 下的文件,发觉存储的时候二进制文件,不能直接预览。

image-2.png

那么,我们应该如何修改保存的文件名,使其能够直接打开?

我们可以指定怎样存储,并指定保存的目录和文件名。这里要先创建用到的目录,然后再返回它的路径。

在 src/utils 下新建 storage.ts 文件,提供 storage 函数:

import * as multer from 'multer';
import * as fs from 'fs';
import * as path from 'path';

export const storage = multer.diskStorage({
    destination: function (req, file, cb) {
        const dirPath = path.dirname('uploads');
        if (!fs.existsSync(dirPath)) {
            fs.mkdirSync(dirPath, { recursive: true });
        }
        cb(null, 'uploads');
    },
    filename: function (req, file, cb) {
        const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}-${file.originalname}`;
        cb(null, uniqueSuffix);
    },
});

app.controller.ts 中进行对应修改:

@Post('upload/single')
@UseInterceptors(
    FileInterceptor('file', { dest: 'uploadFiles', storage: storage }),
)
uploadSingleFile(@UploadedFile() file: Express.Multer.File) {
    ...
}

注意:

  • 我们重新定义了filename,使用时间戳 Date.now() 加上 Math.random() 乘以 10 的 9 次方,然后取整,之后加上原来的文件名。
  • FileInterceptor 同时指定了 deststorage,但是上传后的文件存储在 storage 指定的文件夹。

image-3.png

文件上传后,还需要将 uploads 文件夹设为静态文件目录。

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';

async function bootstrap() {
    const app = await NestFactory.create<NestExpressApplication>(AppModule);

    // 允许跨域
    app.enableCors();

    // 设置静态文件目录
    app.useStaticAssets(join(__dirname, '../uploads'), { prefix: '/uploads' });

    await app.listen(process.env.PORT ?? 3000);
}

bootstrap();

多文件上传

在 AppController 再添加一个 handler:

import { Body, Controller, Post, UploadedFile, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';

@Post('upload/multiple')
@UseInterceptors(
    FilesInterceptor('files', 3, { dest: 'uploadFiles', storage: storage }),
)
uploadMultipleFile(@UploadedFiles() files: Array<Express.Multer.File>) {
    console.log('files--->', files);
    const filePaths = files.map((file) => file.path);
    return filePaths;
}

和单文件上传的不同点就是:

  • 多文件上传使用FilesInterceptor, 接收三个参数,第二个参数表示同时上传的文件个数。
  • 多文件上传使用 UploadedFiles, 且 files 的类型为 Array<Express.Multer.File>

前端代码做相应调整:

async function formData2() {
    const data = new FormData();
    data.set('title','测试文件');
    data.set('type', 'image');
    [...fileInput.files].forEach(item => {
        data.append('files', item)
    })

    const res = await axios.post('http://localhost:3000/upload/multiple', data);
    console.log(res);
}

fileInput.onchange = formData2;

多个字段上传

如果多个字段都会上传文件,而且限制也都不同,又该如何实现呢?

在 AppController 再添加一个 handler:

@Post('upload/multipleFields')
@UseInterceptors(
    FileFieldsInterceptor(
        [
            {
                name: 'files1',
                maxCount: 2,
            },
            {
                name: 'files2',
                maxCount: 3,
            },
        ],
        { dest: 'uploadFiles', storage: storage },
    ),
)
uploadMultipleFieldsFile(
    @UploadedFiles() files: {
        files1?: Express.Multer.File[];
        files2?: Express.Multer.File[];
    },
) {
    console.log('files--->', files);
}

前端代码做相应调整:

async function formData3() {
    const data = new FormData();
    data.set('title','测试文件');
    data.set('type', 'image');
    data.append('files1', fileInput.files[0]);
    data.append('files1', fileInput.files[1]);
    data.append('files2', fileInput.files[2]);
    data.append('files2', fileInput.files[3]);

    const res = await axios.post('http://localhost:3000/upload/multipleFields', data);
    console.log(res);
}

fileInput.onchange = formData3;

大文件上传

文件上传会耗费一定的时间,当文件过大时,上传等待的时候将会很漫长。这对于产品体验非常不好。因此,对于大文件一般采用 分片上传 的形式处理。

那么如何拆分和合并呢?

Blob 是一个装着二进制数据的容器对象。Blob 对象表示一个不可变、原始数据的类文件对象。它的数据可以按文本或二进制的格式进行读取,也可以转换成 ReadableStream 来用于数据操作。而 File 对象是一个特殊类型的 Blob 对象,且可以用在任意的 Blob 类型的 context 中。

文件拆分

Blob 对象有个常用的方法:slice(),它接收三个参数:

  • start 拆分的开始索引
  • end 拆分的结束索引
  • contentType 修改 slice 生成后的新 Blob 对象中的内容属性 type

start、end 代表左闭右开区间,即 end 索引指向的字节不会被读取。

所以,我们可以使用 slice 对上传的文件进行分片。

我们先添加一个 handler:

@Post('upload/largeFile')
@UseInterceptors(
    FilesInterceptor('files', 20, {
        dest: 'uploadFiles',
    }),
)
uploadLargeFile(@UploadedFiles() files: Array<Express.Multer.File>) {
    console.log('files', files);
}

可以看出,这个和多文件上传的没多大区别。其实,大文件上传就是前端将文件切割成多个小文件,然后再进行多文件上传到后端服务器。

前端代码做文件切割:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <script src="https://unpkg.com/axios@0.24.0/dist/axios.min.js"></script>
</head>
<body>
    <input id="fileInput" type="file" multiple/>
    <script>
        const fileInput = document.querySelector('#fileInput');

        const chunkSize = 20 * 1024; // 每个小文件定为20kB

        fileInput.onchange = async () => {
            const file = fileInput.files[0];

            const chunks = [];
            let startPos = 0;
            while(startPos < file.size) {
                chunks.push(file.slice(startPos, startPos + chunkSize));
                startPos += chunkSize;
            }

            chunks.map((chunk, index) => {
                const data = new FormData();
                data.set('name', file.name + '-' + index)
                data.append('files', chunk);
                axios.post('http://localhost:3000/upload/largeFile', data);
            })
        };

    </script>
</body>
</html>

上传文件后,可以看出多个请求且后端生成了多个文件片段:

image-4.png 后端服务将这些文件整理一下,放到一个单独的文件夹:

...
uploadLargeFile(
    @UploadedFiles() files: Array<Express.Multer.File>,
    @Body() body: { name: string },
) {
    const fileName = body.name.match(/(.+)\-\d+$/)[1];
    const chunkDir = 'uploads/chunks_' + fileName;

    if (!fs.existsSync(chunkDir)) {
        fs.mkdirSync(chunkDir);
    }
    fs.cpSync(files[0].path, chunkDir + '/' + body.name);
    fs.rmSync(files[0].path);
}

重新上传后可以看到:

image-5.png

文件合并

通过上面操作,文件拆分上传成功了。但是,文件又该如何合并呢?

通过查找 fs 的 API 文档,可以找到 fs.createWriteStream(path, [option]) 方法。它里面支持指定 start,表示从什么位置开始写入。我们将传过来的文件片段,安装规定的顺序写入就完成了合并操作。

在 AppController 添加一个 handler 进行合并操作,并且在合并完成后删除临时文件夹:

@Get('upload/merge')
mergeFile(@Query('name') name: string) {
    const chunkDir = 'uploads/chunks_' + name;

    const files = fs.readdirSync(chunkDir);
    files.sort(
        (a, b) => +a.split('-').reverse()[0] - +b.split('-').reverse()[0],
    );

    let count = 0;
    let startPos = 0;
    files.map((file) => {
        const filePath = chunkDir + '/' + file;
        const stream = fs.createReadStream(filePath);
        stream
            .pipe(
                fs.createWriteStream('uploads/' + name, {
                    start: startPos,
                }),
            )
            .on('finish', () => {
                count++;

                if (count === files.length) {
                    fs.rm(
                        chunkDir,
                        {
                            recursive: true,
                        },
                        () => {
                            console.log('文件删除成功');
                        },
                    );
                }
            });

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

在前端代码里,当分片全部上传完后,调用 merge 接口进行合并:

fileInput.onchange = async () => {
    const file = fileInput.files[0];

    const chunks = [];
    let startPos = 0;
    while(startPos < file.size) {
        chunks.push(file.slice(startPos, startPos + chunkSize));
        startPos += chunkSize;
    }

    const tasks = [];
    chunks.map((chunk, index) => {
        const data = new FormData();
        data.set('name', file.name + '-' + index)
        data.append('files', chunk);
        tasks.push(axios.post('http://localhost:3000/upload/largeFile', data));
    })

    await Promise.all(tasks);
    axios.get('http://localhost:3000/upload/merge?name=' + file.name);
};

总结

通过网上查阅的各种资料及个人理解,将文件上传这一块梳理清楚,特别是关于大文件的上传解决方案。对大文件进行分割合并操作,这是个特别常见解决方案。