文件上传
文件上传是 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 文件上传的实现方法:
- Multer 基础:安装配置、存储方式、上传方法
- 存储方式:磁盘存储和内存存储的选择
- 上传方法:单文件、多文件、多字段上传
- 文件限制:大小、数量限制,防止滥用
- 文件过滤:类型验证、内容验证
- 错误处理:Multer 特定错误的处理方式
- 图片处理:使用 Sharp 压缩、裁剪、格式转换
- 云存储:上传到 S3、OSS 等云服务
- 安全实践:文件类型验证、安全命名、存储位置
文件上传涉及安全性问题,务必遵循最佳实践,防止恶意文件上传。
练习
- 实现一个头像上传接口,要求只能上传图片,大小限制在 2MB 以内
- 使用 Sharp 实现图片上传后自动生成缩略图
- 实现一个多图上传接口,最多允许上传 9 张图片
- 将上传的文件保存到云存储服务(如阿里云 OSS 或 AWS S3)