跳到主要内容

文件上传

文件上传是 Web 应用中常见的功能需求,包括头像上传、图片管理、文档处理等场景。Express 本身不处理文件上传,需要借助中间件来实现。Multer 是 Express 生态中最流行的文件上传中间件,本章将详细介绍其使用方法和最佳实践。

Multer 简介

Multer 是一个 Node.js 中间件,用于处理 multipart/form-data 类型的表单数据,主要用于上传文件。它构建在 busboy 之上,效率很高。

安装

npm install multer

基本概念

Multer 处理文件上传后会向 req 对象添加以下属性:

  • req.file:单个文件信息(使用 single() 方法时)
  • req.files:文件数组或对象(使用 array()fields() 方法时)
  • req.body:表单中的文本字段

简单示例

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

const app = express();

// 配置 Multer:文件保存到 uploads 目录
const upload = multer({ dest: 'uploads/' });

// 单文件上传
app.post('/upload', upload.single('avatar'), (req, res) => {
// req.file 包含文件信息
// req.body 包含其他表单字段
console.log(req.file);
res.json({
message: '上传成功',
file: req.file
});
});

app.listen(3000);

前端表单必须设置 enctype="multipart/form-data"

<form action="/upload" method="post" enctype="multipart/form-data">
<input type="file" name="avatar">
<button type="submit">上传</button>
</form>

存储方式

Multer 提供两种存储引擎:磁盘存储(DiskStorage)和内存存储(MemoryStorage)。

磁盘存储

磁盘存储将文件保存到服务器硬盘,可以完全控制文件的命名和存储位置:

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

const storage = multer.diskStorage({
// 设置存储目录
destination: function (req, file, cb) {
// cb 的第一个参数是错误,第二个是目录路径
cb(null, 'uploads/');
},

// 设置文件名
filename: function (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 });

动态目录:根据文件类型或用户信息动态选择存储目录:

const storage = multer.diskStorage({
destination: function (req, file, cb) {
// 根据文件类型选择目录
let uploadPath = 'uploads/';

if (file.mimetype.startsWith('image/')) {
uploadPath += 'images/';
} else if (file.mimetype === 'application/pdf') {
uploadPath += 'documents/';
} else {
uploadPath += 'others/';
}

// 确保目录存在
const fs = require('fs');
if (!fs.existsSync(uploadPath)) {
fs.mkdirSync(uploadPath, { recursive: true });
}

cb(null, uploadPath);
},

filename: function (req, file, cb) {
// 使用用户ID和时间戳命名
const userId = req.user?.id || 'anonymous';
const ext = path.extname(file.originalname);
cb(null, `${userId}-${Date.now()}${ext}`);
}
});

内存存储

内存存储将文件保存在内存中的 Buffer 对象,适合小文件或需要进一步处理的场景:

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

app.post('/upload', upload.single('avatar'), (req, res) => {
// req.file.buffer 包含文件数据
// 注意:大文件会导致内存溢出

// 例如:直接上传到云存储
const cloudStorage = require('./cloud-storage');
cloudStorage.upload(req.file.buffer, req.file.originalname);

res.json({ message: '上传成功' });
});

上传方法

Multer 提供多种上传方法,适应不同的业务场景。

单文件上传

使用 single(fieldname) 上传单个文件:

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

// fieldname 是表单中 file input 的 name 属性值
app.post('/avatar', upload.single('avatar'), (req, res) => {
// req.file: 单个文件信息
// req.body: 其他表单字段

if (!req.file) {
return res.status(400).json({ error: '请选择文件' });
}

res.json({
message: '头像上传成功',
file: {
originalname: req.file.originalname,
size: req.file.size,
mimetype: req.file.mimetype
}
});
});

多文件上传(同名字段)

使用 array(fieldname[, maxCount]) 上传多个同名文件:

// maxCount 限制最大文件数量
app.post('/photos', upload.array('photos', 12), (req, res) => {
// req.files: 文件数组

if (!req.files || req.files.length === 0) {
return res.status(400).json({ error: '请选择文件' });
}

const fileInfo = req.files.map(file => ({
originalname: file.originalname,
size: file.size
}));

res.json({
message: `成功上传 ${req.files.length} 个文件`,
files: fileInfo
});
});

多文件上传(不同字段)

使用 fields(fields) 上传多个不同字段的文件:

const uploadFields = upload.fields([
{ name: 'avatar', maxCount: 1 },
{ name: 'gallery', maxCount: 8 },
{ name: 'documents', maxCount: 5 }
]);

app.post('/profile', uploadFields, (req, res) => {
// req.files: 对象,键为字段名,值为文件数组
// req.files['avatar'][0]: 头像文件
// req.files['gallery']: 相册文件数组
// req.files['documents']: 文档文件数组

const avatar = req.files['avatar']?.[0];
const gallery = req.files['gallery'] || [];
const documents = req.files['documents'] || [];

res.json({
message: '上传成功',
avatar: avatar?.originalname,
galleryCount: gallery.length,
documentsCount: documents.length
});
});

纯文本字段处理

使用 none() 只处理文本字段,不处理文件:

app.post('/form', upload.none(), (req, res) => {
// 只有 req.body,没有文件
res.json({ data: req.body });
});

接收所有文件

使用 any() 接收所有上传的文件:

// ⚠️ 注意:不要将 any() 作为全局中间件
app.post('/upload', upload.any(), (req, res) => {
// req.files: 所有文件的数组
res.json({ files: req.files });
});

文件限制

通过 limits 选项限制上传文件的大小和数量,防止恶意上传:

const upload = multer({
dest: 'uploads/',
limits: {
fileSize: 5 * 1024 * 1024, // 单个文件最大 5MB
files: 10, // 最多 10 个文件
fields: 20, // 最多 20 个非文件字段
fieldNameSize: 100, // 字段名最大长度
fieldSize: 1024 * 1024 // 字段值最大 1MB
}
});

限制选项说明

选项说明默认值
fileSize单个文件最大字节数无限制
files最大文件数量无限制
fields最大非文件字段数量无限制
fieldNameSize字段名最大字节数100 bytes
fieldSize字段值最大字节数1MB
parts最大 part 数量(字段+文件)无限制
headerPairs最大 header 键值对数量2000

文件过滤

使用 fileFilter 函数控制接受哪些文件:

const upload = multer({
dest: 'uploads/',
fileFilter: function (req, file, cb) {
// file 对象包含:
// - fieldname: 字段名
// - originalname: 原始文件名
// - encoding: 编码方式
// - mimetype: MIME 类型

// 允许的文件类型
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];

if (allowedTypes.includes(file.mimetype)) {
// 接受文件
cb(null, true);
} else {
// 拒绝文件
cb(new Error('只允许上传图片文件(JPEG、PNG、GIF、WebP)'));
}
}
});

更安全的文件类型验证

仅依赖 MIME 类型不够安全,攻击者可以伪造 MIME 类型。更安全的做法是验证文件的实际内容:

const fileType = require('file-type');
const fs = require('fs');

// 使用内存存储以便读取文件内容
const upload = multer({ storage: multer.memoryStorage() });

app.post('/upload', upload.single('file'), async (req, res) => {
try {
// 通过文件内容判断实际类型
const type = await fileType.fromBuffer(req.file.buffer);

const allowedTypes = ['jpg', 'png', 'gif', 'webp'];

if (!type || !allowedTypes.includes(type.ext)) {
return res.status(400).json({ error: '文件类型不允许' });
}

// 验证通过,保存文件
const filename = `${Date.now()}.${type.ext}`;
fs.writeFileSync(`uploads/${filename}`, req.file.buffer);

res.json({ message: '上传成功', filename });
} catch (err) {
res.status(500).json({ error: '上传失败' });
}
});

扩展名白名单

结合扩展名和 MIME 类型双重验证:

const path = require('path');

const allowedExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];

const upload = multer({
dest: 'uploads/',
fileFilter: function (req, file, cb) {
const ext = path.extname(file.originalname).toLowerCase();

if (!allowedExtensions.includes(ext)) {
return cb(new Error(`不支持的文件扩展名: ${ext}`));
}

if (!allowedMimeTypes.includes(file.mimetype)) {
return cb(new Error(`不支持的文件类型: ${file.mimetype}`));
}

cb(null, true);
}
});

错误处理

Multer 错误需要专门处理,它们有自己的错误类型:

const multer = require('multer');
const upload = multer({
dest: 'uploads/',
limits: { fileSize: 5 * 1024 * 1024 }
});

app.post('/upload', upload.single('avatar'), (req, res) => {
res.json({ file: req.file });
});

// Multer 错误处理中间件
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
// Multer 特定错误
switch (err.code) {
case 'LIMIT_FILE_SIZE':
return res.status(400).json({
error: '文件大小超过限制(最大 5MB)'
});
case 'LIMIT_FILE_COUNT':
return res.status(400).json({
error: '文件数量超过限制'
});
case 'LIMIT_UNEXPECTED_FILE':
return res.status(400).json({
error: '意外的文件字段'
});
case 'LIMIT_FIELD_KEY':
return res.status(400).json({
error: '字段名过长'
});
case 'LIMIT_FIELD_VALUE':
return res.status(400).json({
error: '字段值过长'
});
case 'LIMIT_FIELD_COUNT':
return res.status(400).json({
error: '字段数量超过限制'
});
case 'LIMIT_PART_COUNT':
return res.status(400).json({
error: '表单部分数量超过限制'
});
default:
return res.status(400).json({ error: err.message });
}
}

// 其他错误(如 fileFilter 抛出的错误)
if (err) {
return res.status(400).json({ error: err.message });
}

next();
});

常见 Multer 错误码

错误码说明
LIMIT_FILE_SIZE文件大小超过限制
LIMIT_FILE_COUNT文件数量超过限制
LIMIT_UNEXPECTED_FILE意外的文件字段
LIMIT_FIELD_KEY字段名过长
LIMIT_FIELD_VALUE字段值过长
LIMIT_FIELD_COUNT字段数量超过限制
LIMIT_PART_COUNT表单部分数量超过限制

图片处理

上传图片后,通常需要进行压缩、裁剪、格式转换等处理。

使用 Sharp 处理图片

Sharp 是高性能的 Node.js 图片处理库:

npm install sharp
const sharp = require('sharp');
const multer = require('multer');
const path = require('path');

// 内存存储
const upload = multer({ storage: multer.memoryStorage() });

app.post('/avatar', upload.single('avatar'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: '请选择文件' });
}

// 处理图片:调整大小、压缩、转换格式
const filename = `avatar-${Date.now()}.webp`;
const outputPath = path.join('uploads', 'avatars', filename);

await sharp(req.file.buffer)
.resize(200, 200, {
fit: 'cover', // 裁剪填充
position: 'center' // 从中心裁剪
})
.webp({ quality: 80 }) // 转换为 WebP,质量 80%
.toFile(outputPath);

res.json({
message: '头像上传成功',
path: `/uploads/avatars/${filename}`
});
} catch (err) {
console.error('图片处理错误:', err);
res.status(500).json({ error: '图片处理失败' });
}
});

生成缩略图

app.post('/photo', upload.single('photo'), async (req, res) => {
try {
const { buffer } = req.file;
const baseName = `photo-${Date.now()}`;

// 原图
await sharp(buffer)
.jpeg({ quality: 90 })
.toFile(`uploads/photos/${baseName}.jpg`);

// 缩略图
await sharp(buffer)
.resize(300, 300, { fit: 'inside' })
.jpeg({ quality: 80 })
.toFile(`uploads/photos/thumbs/${baseName}-thumb.jpg`);

res.json({
original: `/uploads/photos/${baseName}.jpg`,
thumbnail: `/uploads/photos/thumbs/${baseName}-thumb.jpg`
});
} catch (err) {
res.status(500).json({ error: '上传失败' });
}
});

文件上传到云存储

生产环境通常将文件上传到云存储服务,如 AWS S3、阿里云 OSS、腾讯云 COS 等。

上传到 AWS S3

npm install @aws-sdk/client-s3
const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
const multer = require('multer');

// 配置 S3 客户端
const s3 = new S3Client({
region: process.env.AWS_REGION,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY
}
});

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

app.post('/upload', upload.single('file'), async (req, res) => {
try {
const key = `uploads/${Date.now()}-${req.file.originalname}`;

const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: key,
Body: req.file.buffer,
ContentType: req.file.mimetype
});

await s3.send(command);

const url = `https://${process.env.S3_BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com/${key}`;

res.json({ url });
} catch (err) {
console.error('S3 上传错误:', err);
res.status(500).json({ error: '上传失败' });
}
});

上传到阿里云 OSS

npm install ali-oss
const OSS = require('ali-oss');
const multer = require('multer');

const client = new OSS({
region: process.env.OSS_REGION,
bucket: process.env.OSS_BUCKET,
accessKeyId: process.env.OSS_ACCESS_KEY_ID,
accessKeySecret: process.env.OSS_ACCESS_KEY_SECRET
});

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

app.post('/upload', upload.single('file'), async (req, res) => {
try {
const filename = `uploads/${Date.now()}-${req.file.originalname}`;

const result = await client.put(filename, req.file.buffer);

res.json({
url: result.url,
name: result.name
});
} catch (err) {
console.error('OSS 上传错误:', err);
res.status(500).json({ error: '上传失败' });
}
});

安全最佳实践

1. 限制文件类型和大小

const upload = multer({
dest: 'uploads/',
limits: {
fileSize: 5 * 1024 * 1024 // 5MB
},
fileFilter: (req, file, cb) => {
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
if (!allowedTypes.includes(file.mimetype)) {
return cb(new Error('不支持的文件类型'));
}
cb(null, true);
}
});

2. 重命名上传文件

永远不要使用用户提供的文件名保存文件:

const storage = multer.diskStorage({
destination: 'uploads/',
filename: (req, file, cb) => {
// 使用安全的文件名
const ext = path.extname(file.originalname);
const safeName = `${crypto.randomUUID()}${ext}`;
cb(null, safeName);
}
});

3. 验证文件内容

const fileType = require('file-type');

app.post('/upload', upload.single('file'), async (req, res) => {
const type = await fileType.fromBuffer(req.file.buffer);

// 验证实际文件类型
if (!type || !['jpg', 'png'].includes(type.ext)) {
// 删除已上传的文件
fs.unlinkSync(req.file.path);
return res.status(400).json({ error: '文件类型不允许' });
}

// ...
});

4. 存储在 Web 根目录外

// 不要存储在 public 目录下
const upload = multer({ dest: 'uploads/' }); // ✓ 安全

// 存储在 public 目录下可能有安全风险
// const upload = multer({ dest: 'public/uploads/' }); // ✗ 不推荐

5. 设置病毒扫描

对于允许上传可执行文件的场景,建议进行病毒扫描:

const { exec } = require('child_process');

async function scanFile(filePath) {
return new Promise((resolve, reject) => {
exec(`clamscan ${filePath}`, (error, stdout) => {
if (error) {
reject(new Error('文件扫描失败'));
} else {
resolve(stdout.includes('OK'));
}
});
});
}

完整示例

下面是一个完整的文件上传模块示例:

// routes/upload.js
const express = require('express');
const router = express.Router();
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const sharp = require('sharp');

// 确保上传目录存在
const uploadDir = 'uploads/avatars';
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}

// 配置存储
const storage = multer.diskStorage({
destination: uploadDir,
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `${req.user.id}-${Date.now()}${ext}`);
}
});

// 文件过滤
const fileFilter = (req, file, cb) => {
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];

if (!allowedTypes.includes(file.mimetype)) {
return cb(new Error('只允许上传 JPEG、PNG 或 WebP 格式的图片'), false);
}

cb(null, true);
};

// 创建 Multer 实例
const upload = multer({
storage,
limits: {
fileSize: 2 * 1024 * 1024 // 2MB
},
fileFilter
});

// 上传头像
router.post('/avatar', upload.single('avatar'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: '请选择文件' });
}

// 调整图片大小
const resizedPath = path.join(uploadDir, `resized-${req.file.filename}`);

await sharp(req.file.path)
.resize(200, 200, { fit: 'cover' })
.webp({ quality: 80 })
.toFile(resizedPath);

// 删除原图,保留处理后的图片
fs.unlinkSync(req.file.path);

// 更新用户头像 URL
const avatarUrl = `/uploads/avatars/resized-${req.file.filename}`;

res.json({
message: '头像上传成功',
avatar: avatarUrl
});
} catch (err) {
console.error('上传错误:', err);
res.status(500).json({ error: '上传失败' });
}
});

// 上传多张图片
router.post('/gallery', upload.array('photos', 10), async (req, res) => {
try {
if (!req.files || req.files.length === 0) {
return res.status(400).json({ error: '请选择文件' });
}

const results = await Promise.all(
req.files.map(async (file) => {
const thumbPath = path.join(uploadDir, `thumb-${file.filename}`);

await sharp(file.path)
.resize(300, 300, { fit: 'inside' })
.toFile(thumbPath);

return {
original: `/uploads/avatars/${file.filename}`,
thumbnail: `/uploads/avatars/thumb-${file.filename}`
};
})
);

res.json({
message: `成功上传 ${req.files.length} 张图片`,
files: results
});
} catch (err) {
console.error('上传错误:', err);
res.status(500).json({ error: '上传失败' });
}
});

module.exports = router;

小结

本章详细介绍了 Express 文件上传的实现方法:

  1. Multer 基础:安装配置、存储方式、上传方法
  2. 存储方式:磁盘存储和内存存储的选择
  3. 上传方法:单文件、多文件、多字段上传
  4. 文件限制:大小、数量限制,防止滥用
  5. 文件过滤:类型验证、内容验证
  6. 错误处理:Multer 特定错误的处理方式
  7. 图片处理:使用 Sharp 压缩、裁剪、格式转换
  8. 云存储:上传到 S3、OSS 等云服务
  9. 安全实践:文件类型验证、安全命名、存储位置

文件上传涉及安全性问题,务必遵循最佳实践,防止恶意文件上传。

练习

  1. 实现一个头像上传接口,要求只能上传图片,大小限制在 2MB 以内
  2. 使用 Sharp 实现图片上传后自动生成缩略图
  3. 实现一个多图上传接口,最多允许上传 9 张图片
  4. 将上传的文件保存到云存储服务(如阿里云 OSS 或 AWS S3)

参考资料