文件上传处理

文件上传是 Web 应用中的常见需求,例如用户头像、附件、图片上传等。在 Node.js 和 Express 中,最流行的文件上传中间件是 Multer。本章将深入讲解如何使用 Multer 处理单文件和多文件上传,自定义存储、验证文件类型、处理错误,并探讨安全性和性能优化。

1. 文件上传的基本概念

HTTP 协议中,文件上传通常使用 multipart/form-data 编码类型。这种格式将表单数据分割成多个部分,每个部分可以有自己的内容类型和文件名。服务器端需要解析这种复杂格式才能提取文件和字段。

Multer 是一个基于 busboy 的 Express 中间件,专门用于处理 multipart/form-data。它会解析请求,并将文件信息挂载到 req.filereq.files 上,文本字段则挂载到 req.body

2. 安装和基本配置

首先,安装 Multer:

npm install multer

最简单的用法,指定文件上传的目标目录:

const multer = require('multer');
const upload = multer({ dest: 'uploads/' });

app.post('/upload', upload.single('avatar'), (req, res) => {
  console.log(req.file); // 文件信息
  console.log(req.body); // 其他文本字段
  res.send('文件上传成功');
});

这里 upload.single('avatar') 表示处理一个名为 "avatar" 的文件字段。上传的文件会被保存到 uploads/ 目录,文件名是随机生成的,没有扩展名。可以通过 req.file 获取文件详细信息,包括原始文件名、保存路径、大小等。

2.1 文件信息对象 (req.file)

req.file 包含以下字段:

  • fieldname - 表单字段名
  • originalname - 用户计算机上的文件名
  • encoding - 文件编码
  • mimetype - 文件的 MIME 类型
  • size - 文件大小(字节)
  • destination - 保存路径的文件夹
  • filename - 保存在 destination 中的文件名
  • path - 已上传文件的完整路径
  • buffer - 整个文件的 Buffer(仅当使用 storage: memoryStorage 时)

3. 单文件上传与多文件上传

3.1 单文件上传

使用 upload.single(fieldname)

app.post('/profile', upload.single('avatar'), (req, res) => {
  res.json({
    message: '头像上传成功',
    file: req.file
  });
});

3.2 多文件上传(同一字段)

使用 upload.array(fieldname[, maxCount]),处理同一字段的多个文件(例如 <input type="file" name="photos" multiple>)。

app.post('/photos', upload.array('photos', 12), (req, res) => {
  // req.files 是文件数组
  res.json({
    message: `上传了 ${req.files.length} 张图片`,
    files: req.files
  });
});

3.3 多个不同字段的文件

使用 upload.fields([...]),指定每个字段的名称和最大数量。

app.post('/upload-mixed', upload.fields([
  { name: 'avatar', maxCount: 1 },
  { name: 'gallery', maxCount: 8 }
]), (req, res) => {
  // req.files 是一个对象,键为字段名,值为文件数组
  console.log(req.files.avatar); // 数组,可能为空
  console.log(req.files.gallery);
  res.send('上传成功');
});

3.4 任何字段都不处理

如果希望上传任意字段的文件(不指定字段名),可以使用 upload.any(),但注意这可能导致安全问题,一般不推荐。

4. 自定义存储引擎

默认的 dest 选项会将文件保存到指定文件夹,但文件名是随机生成的,没有扩展名,也不保留原始文件名。通过自定义存储引擎,你可以完全控制文件保存的细节,例如文件名、路径等。

Multer 提供了 diskStoragememoryStorage 两种存储引擎。

4.1 磁盘存储 (diskStorage)

const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    cb(null, 'uploads/');
  },
  filename: (req, file, cb) => {
    // 保留原始扩展名,添加时间戳避免重名
    const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
    const ext = path.extname(file.originalname);
    cb(null, file.fieldname + '-' + uniqueSuffix + ext);
  }
});

const upload = multer({ storage: storage });

destinationfilename 函数都接收请求对象 (req)、文件对象 (file) 和回调函数 (cb)。你可以根据需要动态决定保存路径和文件名,甚至可以读取 req.body 中的值来构建路径。

4.2 内存存储 (memoryStorage)

文件数据将作为 Buffer 保存在内存中,不会写入磁盘。适用于需要直接处理文件内容(例如上传到云存储)的场景。

const storage = multer.memoryStorage();
const upload = multer({ storage: storage });

app.post('/profile', upload.single('avatar'), (req, res) => {
  // req.file.buffer 包含文件数据
  // 可以将 buffer 上传到 S3 或进行其他处理
});

5. 文件过滤与验证

为了安全性和数据完整性,通常需要对上传的文件进行过滤,例如只允许图片、限制大小等。

5.1 文件类型过滤

通过 fileFilter 函数实现。该函数接收 req, file, cb,需要调用 cb(null, true) 接受文件,或 cb(null, false) 拒绝文件(或传递错误)。

const fileFilter = (req, file, cb) => {
  // 允许的 MIME 类型
  if (file.mimetype === 'image/jpeg' || file.mimetype === 'image/png') {
    cb(null, true);
  } else {
    cb(new Error('仅支持 JPEG 和 PNG 图片'), false);
  }
};

const upload = multer({
  storage: storage,
  fileFilter: fileFilter,
  limits: { fileSize: 2 * 1024 * 1024 } // 限制 2MB
});

5.2 错误处理

Multer 的错误(如文件过大、类型不符)会触发 Express 的错误处理中间件。可以定义专门的错误处理中间件来捕获这些错误:

app.use((err, req, res, next) => {
  if (err instanceof multer.MulterError) {
    // Multer 抛出的错误(如文件过大、太多文件)
    return res.status(400).json({ error: err.message });
  } else if (err) {
    // 自定义错误(如 fileFilter 抛出的 Error)
    return res.status(400).json({ error: err.message });
  }
  next();
});

6. 文件上传安全最佳实践

  • 限制文件大小:通过 limits.fileSize 设置最大允许大小,防止 DoS 攻击。
  • 验证文件类型:不仅检查 MIME 类型,还可以检查文件扩展名、魔术数字(文件头)等。对于图片,可以使用专门的库(如 file-type)验证。
  • 存储位置:不要把上传的文件放在可公开访问的目录下,或者确保文件名的不可预测性(避免路径遍历)。
  • 文件名安全:不要使用用户提供的文件名直接存储,应生成安全的唯一文件名,过滤掉特殊字符。
  • 扫描病毒:对于用户上传的文件,如果有安全需求,应进行病毒扫描。
  • 定期清理:如果文件只是临时使用,需要设置清理机制,避免磁盘空间耗尽。

7. 获取上传进度

Multer 本身不支持进度事件,但可以结合流式上传或其他中间件(如 busboy 直接使用)实现。或者在前端使用 XMLHttpRequest 的上传进度事件。对于大文件,可以考虑分块上传。

一个简单的进度实现思路:使用 multer 搭配 express 无法直接获取进度,但可以在前端使用 axiosfetchonUploadProgress 显示进度条。如果需要服务器端进度,可以考虑使用 busboy 手动处理。

8. 完整示例:用户资料上传(包含头像和相册)

下面是一个综合示例,展示如何处理用户资料更新,包括头像(单文件)和相册图片(多文件)的上传。

const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');

const app = express();

// 确保上传目录存在
const uploadDir = 'uploads/';
const avatarDir = path.join(uploadDir, 'avatars');
const galleryDir = path.join(uploadDir, 'gallery');
[avatarDir, galleryDir].forEach(dir => {
  if (!fs.existsSync(dir)) {
    fs.mkdirSync(dir, { recursive: true });
  }
});

// 配置存储
const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    if (file.fieldname === 'avatar') {
      cb(null, avatarDir);
    } else if (file.fieldname === 'gallery') {
      cb(null, galleryDir);
    } else {
      cb(new Error('未知字段'), null);
    }
  },
  filename: (req, file, cb) => {
    const unique = Date.now() + '-' + Math.round(Math.random() * 1E9);
    const ext = path.extname(file.originalname);
    cb(null, file.fieldname + '-' + unique + ext);
  }
});

// 文件过滤
const fileFilter = (req, file, cb) => {
  if (file.mimetype.startsWith('image/')) {
    cb(null, true);
  } else {
    cb(new Error('只允许上传图片'), false);
  }
};

const upload = multer({
  storage: storage,
  fileFilter: fileFilter,
  limits: {
    fileSize: 5 * 1024 * 1024 // 5MB
  }
});

// 处理上传
app.post('/profile', upload.fields([
  { name: 'avatar', maxCount: 1 },
  { name: 'gallery', maxCount: 10 }
]), (req, res) => {
  const { username, bio } = req.body;
  const avatar = req.files.avatar ? req.files.avatar[0] : null;
  const gallery = req.files.gallery || [];

  // 保存用户信息到数据库(伪代码)
  // await User.update({ username, bio, avatar: avatar?.filename, gallery: gallery.map(f => f.filename) });

  res.json({
    message: '资料更新成功',
    user: { username, bio },
    files: {
      avatar: avatar,
      gallery: gallery
    }
  });
}, (err, req, res, next) => {
  // 错误处理
  if (err instanceof multer.MulterError) {
    return res.status(400).json({ error: err.message });
  } else if (err) {
    return res.status(400).json({ error: err.message });
  }
});

前端可以使用如下表单:

<form action="/profile" method="POST" enctype="multipart/form-data">
  <input type="text" name="username" placeholder="用户名">
  <textarea name="bio" placeholder="个人简介"></textarea>
  <input type="file" name="avatar" accept="image/*">
  <input type="file" name="gallery" accept="image/*" multiple>
  <button type="submit">提交</button>
</form>

9. 使用云存储(如 AWS S3)

对于生产环境,直接将文件存储在服务器磁盘可能不是最佳选择。可以使用内存存储,然后将文件上传到云存储(如 AWS S3、阿里云 OSS)。这里简要示意使用 multer-s3 中间件。

安装:npm install multer-s3 aws-sdk

const AWS = require('aws-sdk');
const multerS3 = require('multer-s3');

const s3 = new AWS.S3({ /* 配置 */ });

const upload = multer({
  storage: multerS3({
    s3: s3,
    bucket: 'my-bucket',
    key: (req, file, cb) => {
      cb(null, Date.now().toString() + '-' + file.originalname);
    }
  })
});

此时 req.file 会包含 S3 相关字段,如 location(文件 URL)。

10. 性能优化建议

  • 对于大文件,考虑使用流式上传(例如直接使用 busboy)避免占用太多内存。
  • 上传目录应使用单独的磁盘或分区,避免影响系统运行。
  • 利用 CDN 分发上传的文件。
  • 对于频繁访问的图片,可以生成缩略图并缓存。
总结: Multer 是处理 Express 文件上传的强大工具。通过自定义存储、文件过滤和错误处理,可以安全高效地实现各种上传需求。在实际项目中,还应注意文件的安全存储、定期清理和与云服务的集成。