PAYATHON 2026

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 提供的是格式识别结果,不能证明整个文件结构完整或内容安全。如果后续需要解码图片、解析文档或解压归档,应在对应处理阶段继续执行适用的结构校验和资源限制。

备注:内容仅供参考。