前言
post 请求 一般有 4 种数据类型:
- application/x-www-form-urlencoded
- multipart/form-data
- application/json
- text/xml
在 Express 中,会通过不同的中间件来解析不同类型的数据。比如:
express.text()用于解析text类型的 req.body,解析成 stringexpress.json()用于解析json类型的 req.bodyexpress.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 的选项有:
| Key | Description |
|---|---|
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)
磁盘存储引擎可以让你控制文件的存储。有两个选项可用,destination 和 filename。他们都是用来确定文件存储位置的函数。
-
destination是用来确定上传的文件应该存储在哪个文件夹中。也可以提供一个string(例如'/tmp/uploads')。如果没有设置destination,则使用操作系统默认的临时文件夹。 -
filename用于确定文件夹中的文件名的确定。 如果没有设置filename,每个文件将设置为一个随机文件名,并且是没有扩展名的。
内存存储引擎 (MemoryStorage)
内存存储引擎将文件存储在内存中的 Buffer 对象,它没有任何选项。当使用内存存储引擎,文件信息将包含一个 buffer 字段,里面包含了整个文件数据。
注意:当你使用内存存储,上传非常大的文件,或者非常多的小文件,会导致你的应用程序内存溢出。
limits
一个对象,指定一些数据大小的限制。
| Key | Description | Default |
|---|---|---|
fieldNameSize | field 名字最大长度 | 100 bytes |
fieldSize | field 值的最大长度 | 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>
浏览器访问:
上传后,服务端就打印出 file 对象,且文件保存到了 uploadFiles 文件夹下。
此时,我们打开 uploadFiles 下的文件,发觉存储的时候二进制文件,不能直接预览。
那么,我们应该如何修改保存的文件名,使其能够直接打开?
我们可以指定怎样存储,并指定保存的目录和文件名。这里要先创建用到的目录,然后再返回它的路径。
在 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同时指定了dest和storage,但是上传后的文件存储在storage指定的文件夹。
文件上传后,还需要将 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>
上传文件后,可以看出多个请求且后端生成了多个文件片段:
后端服务将这些文件整理一下,放到一个单独的文件夹:
...
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);
}
重新上传后可以看到:
文件合并
通过上面操作,文件拆分上传成功了。但是,文件又该如何合并呢?
通过查找 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);
};
总结
通过网上查阅的各种资料及个人理解,将文件上传这一块梳理清楚,特别是关于大文件的上传解决方案。对大文件进行分割合并操作,这是个特别常见解决方案。