NestJS 如何校验上传文件类型并避免创建过多子进程?
可能。原代码每处理一个文件都会启动一个 file 子进程,而 Promise.all 不限制并发。多个上传请求同时到达时,子进程数量还会叠加。这种写法可以用在负载受控的场景,但现有代码不能保证高负载下的资源安全。
优先考虑使用进程内的文件签名识别库。如果需要保留 file 命令,就要限制上传规模,并设置共享的检测并发上限、排队数量上限和单次执行时间上限。
为什么异步调用仍然可能耗尽资源?
execFile 是异步 API,但每次调用仍会创建操作系统进程。下面的写法在遍历数组时会立即发起所有检测:
await Promise.all(
uploadedFiles.map((file) => this.assertValidFile(file)),
);
假设同时处理的请求数为 R,每个请求包含 F 个文件,在没有其他限制时,这批请求最多可能发起 R × F 次子进程调用。实际同时存活的子进程数量取决于请求到达时间和检测耗时。
子进程会消耗 CPU、内存、进程名额和文件描述符,读取文件还会产生磁盘 I/O。异步调用让 Node.js 能继续处理其他工作,但不会消除这些开销。
另外,当前代码只读取了 detectedMime,没有将它与允许的类型比较,因此尚未完成类型校验。
优先使用进程内的文件签名识别
如果只需要判断应用允许上传的几种常见文件格式,可以选择支持这些格式的文件签名识别库,直接检查文件内容,避免为每个文件创建子进程。
接入时需要确认:
- 使用 Multer 内存存储时,检查
file.buffer,并严格限制文件大小和文件数量。 - 使用磁盘存储时,通过识别库提供的文件路径或流接口读取文件,避免为了检测而将整个大文件读入内存。
- 无法识别或不在允许列表中的类型应拒绝,不能回退为信任客户端提供的
file.mimetype。
进程内识别仍会消耗 CPU 和 I/O,也需要限制并发。如果识别工作较重且同步执行,还要注意事件循环阻塞。
保留 file 命令时,使用共享的并发限制
限制必须覆盖同一个 Node.js 进程内的所有上传请求。如果只在每次 transform 内创建限流器,总进程数仍会随着并发请求增加而放大。
下面是一个有界检测服务。示例使用磁盘存储,只允许 JPEG 和 PNG;并发数、排队数及超时都是示例值,需要结合部署资源调整。
import {
Injectable,
InternalServerErrorException,
Logger,
ServiceUnavailableException,
UnsupportedMediaTypeException,
} from '@nestjs/common';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { resolve } from 'node:path';
const execFileAsync = promisify(execFile);
@Injectable()
export class FileTypeValidator {
private readonly logger = new Logger(FileTypeValidator.name);
private readonly maxActive = 4;
private readonly maxQueued = 20;
private readonly allowed = new Set(['image/jpeg', 'image/png']);
private active = 0;
private readonly waiters: Array<() => void> = [];
private async acquire(): Promise<void> {
if (this.active < this.maxActive) {
this.active += 1;
return;
}
if (this.waiters.length >= this.maxQueued) {
throw new ServiceUnavailableException('文件检测繁忙,请稍后重试');
}
await new Promise<void>((resolveSlot) => {
this.waiters.push(resolveSlot);
});
}
private release(): void {
const next = this.waiters.shift();
if (next) {
// 将当前名额交给等待中的任务,active 保持不变。
next();
} else {
this.active -= 1;
}
}
async assertValid(file: Express.Multer.File): Promise<void> {
await this.acquire();
try {
let detectedMime: string;
try {
const { stdout } = await execFileAsync(
'file',
['-b', '--mime-type', '--', resolve(file.path)],
{
encoding: 'utf8',
timeout: 2000,
maxBuffer: 8 * 1024,
killSignal: 'SIGKILL',
},
);
detectedMime = stdout.trim();
} catch (error) {
this.logger.error('文件类型检测失败', error);
throw new InternalServerErrorException('无法完成文件类型检测');
}
if (!this.allowed.has(detectedMime)) {
throw new UnsupportedMediaTypeException('不支持的文件类型');
}
} finally {
this.release();
}
}
}
将 FileTypeValidator 注册为默认单例 provider,让上传 Pipe 注入同一个实例。不要在每个请求中手动 new FileTypeValidator(),也不要将它配置为请求作用域,否则上述限制无法覆盖全部请求。
示例使用 execFile 的参数数组形式,避免通过 shell 拼接命令。-- 用于结束命令选项,部署环境中的 file 实现需要支持这些参数;上传路径应由服务端控制。SIGKILL 针对题目中的 Unix 环境,用于超时后强制终止直接启动的进程。
如果应用运行多个 Node.js worker 或副本,每个实例都有自己的限额,还需要根据实例数量计算部署总并发。
在上传入口限制文件大小和数量
Pipe 执行前,Multer 已经接收并处理了上传文件,因此检测服务的并发限制不能代替上传限制。
可以在现有 Multer 配置中合并类似配置:
limits: {
fileSize: 10 * 1024 * 1024,
files: 5,
fields: 10,
parts: 15,
}
这些值仅用于展示配置方式。fileSize 是单个文件的大小上限,files 限制文件数量,parts 限制 multipart 中字段和文件的总数量。还应根据业务设置请求频率、上传超时及临时存储容量限制。
execFile 的 timeout 只约束命令执行阶段,不包含此前的排队时间。如果业务有严格的请求时限,还需要增加排队超时,并移除已经取消的等待任务。
处理失败和临时文件清理
Promise.all 遇到第一个拒绝就会返回失败,但不会自动取消其他检测。如果随后立即删除所有临时文件,仍在运行的检测可能读不到文件。
在单次上传文件数量已受限的前提下,可以等本批检测全部结束后,再进入统一清理流程:
const results = await Promise.allSettled(
uploadedFiles.map((file) => this.validator.assertValid(file)),
);
for (const result of results) {
if (result.status === 'rejected') {
throw result.reason;
}
}
return uploadedFiles;
清理逻辑还要覆盖校验失败、上传中断等路径。检测命令失败时应拒绝文件,不能将其当成校验通过。
magic bytes 和 file --mime-type 提供的是格式识别结果,不能证明整个文件结构完整或内容安全。如果后续需要解码图片、解析文档或解压归档,应在对应处理阶段继续执行适用的结构校验和资源限制。
备注:内容仅供参考。
