文件服务是几乎所有应用都会涉及的基础设施。从用户头像、商品图片到合同附件,对文件的上传、存储、处理、分发需求贯穿于各种业务系统。本节基于 Node.js 生态,构建一套可以直接落地的文件服务方案,涵盖从接收用户文件到最终访问的全链路。
26.3.1 文件上传:安全接收与初步处理
虽然 Node.js 原生可以解析 multipart/form-data 格式,但直接处理非常繁琐,且容易留下安全漏洞。社区标准方案是使用 multer,它将文件流式保存到磁盘或内存,提供了较强的安全性控制。
基础上传实现
const multer = require('multer');
const path = require('path');
// 配置存储引擎:指定上传目录和文件名
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, './uploads/'); // 确保 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 fileFilter = (req, file, cb) => {
const allowedMimes = ['image/jpeg', 'image/png', 'image/webp'];
if (allowedMimes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('只允许上传 JPG、PNG、WebP 格式的图片'), false);
}
};
const upload = multer({
storage,
fileFilter,
limits: { fileSize: 5 * 1024 * 1024 } // 限制 5MB
});
在路由中使用:
app.post('/upload', upload.single('avatar'), (req, res) => {
if (!req.file) {
return res.status(400).json({ message: '没有上传文件或文件类型不合法' });
}
// 记录文件信息到数据库,返回访问 URL
const fileInfo = {
originalName: req.file.originalname,
savedName: req.file.filename,
path: req.file.path,
size: req.file.size,
mimeType: req.file.mimetype
};
// 插入数据库 ...
res.json({ file: fileInfo, url: `/files/${req.file.filename}` });
});
安全加固要点
- 验证文件类型:不能仅依赖 MIME 类型(可伪造),应检查文件头魔术字节。对于图片,可用
sharp或image-type库验证。 - 防止路径遍历:不要直接使用
originalname作为保存文件名,必须由服务端生成随机文件名。 - 限制并发上传大小和数量:通过
limits.fileSize和limits.files限制。 - 隔离上传目录:不要将上传目录放在应用代码目录下,应使用独立数据盘或临时目录,并定期清理未被业务引用的文件。
26.3.2 文件下载与流式响应
当文件只是某种资源(如图片、PDF)时,静态文件通过 Nginx 或对象存储分发即可,但某些场景需要后端代理下载或强制下载行为(例如付费文档)。
简单的文件下载端点
const fs = require('fs');
const path = require('path');
app.get('/download/:filename', (req, res) => {
const filePath = path.resolve(__dirname, 'uploads', req.params.filename);
// 安全检查:防止路径遍历
if (!filePath.startsWith(path.resolve(__dirname, 'uploads'))) {
return res.status(403).send('禁止访问');
}
fs.access(filePath, fs.constants.F_OK, (err) => {
if (err) return res.status(404).send('文件不存在');
// 设置强制下载头
res.setHeader('Content-Disposition', `attachment; filename="${req.params.filename}"`);
// 创建可读流并管道到响应,支持断点续传
const readStream = fs.createReadStream(filePath);
readStream.pipe(res);
readStream.on('error', (err) => {
console.error('文件传输错误:', err);
if (!res.headersSent) {
res.status(500).send('下载失败');
}
});
});
});
支持断点续传(范围请求)
真正的生产环境需要处理 Range 头,允许客户端断点续传。Node.js 中使用 fs.createReadStream 配合 stat 获取文件大小,手动处理:
app.get('/video/:filename', (req, res) => {
const filePath = path.resolve(__dirname, 'videos', req.params.filename);
fs.stat(filePath, (err, stats) => {
if (err) return res.status(404).send('文件不存在');
const range = req.headers.range;
if (range) {
const parts = range.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : stats.size - 1;
if (start >= stats.size || end >= stats.size) {
res.status(416).send('请求范围不满足');
return;
}
const chunksize = (end - start) + 1;
const file = fs.createReadStream(filePath, { start, end });
res.writeHead(206, {
'Content-Range': `bytes ${start}-${end}/${stats.size}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunksize,
'Content-Type': 'video/mp4',
});
file.pipe(res);
} else {
// 没有 range 头,直接全量发送
res.writeHead(200, { 'Content-Length': stats.size, 'Content-Type': 'video/mp4' });
fs.createReadStream(filePath).pipe(res);
}
});
});
26.3.3 缩略图生成:用 sharp 进行图像处理
对于图片服务,生成多种尺寸的缩略图是标配。sharp 是 Node.js 中处理图像最快、最省内存的库(底层基于 libvips),支持调整大小、裁剪、格式转换、水印等。
实时生成与缓存
通常的策略是:用户上传原始图后,立即异步生成预定的几种尺寸,而不是每次请求时临时生成。但某些场景(比如后台动态调整尺寸)下实时生成也是可行的。
上传时异步生成缩略图
const sharp = require('sharp');
const path = require('path');
async function generateThumbnails(filePath, filename) {
const sizes = [
{ name: 'thumbnail', width: 150, height: 150 },
{ name: 'medium', width: 600, height: 600 },
{ name: 'large', width: 1200, height: 1200 }
];
const tasks = sizes.map(size => {
const outputPath = path.resolve(__dirname, 'uploads/thumbnails', `${size.name}-${filename}`);
return sharp(filePath)
.resize(size.width, size.height, { fit: 'cover' }) // cover 裁剪填充
.jpeg({ quality: 80 })
.toFile(outputPath);
});
try {
await Promise.all(tasks);
console.log('缩略图生成完成');
} catch (err) {
console.error('缩略图生成失败:', err);
}
}
// 在文件上传成功回调中调用
app.post('/upload', upload.single('photo'), (req, res) => {
// ... 基础上传处理后
generateThumbnails(req.file.path, req.file.filename);
// 直接返回原始文件URL,缩略图在后台生成,不阻塞响应
res.json({ message: '上传成功,缩略图生成中' });
});
动态裁剪并缓存到对象存储
如果图片存储于云上,也可以动态请求携带尺寸参数,生成后缓存:
app.get('/image/:type/:filename', async (req, res) => {
const { type, filename } = req.params;
// type 对应 'thumb', 'medium', 'large'
const filePath = path.resolve(__dirname, 'uploads', filename);
const outputPath = path.resolve(__dirname, 'uploads/cache', `${type}-${filename}`);
try {
// 如果缓存已存在直接返回
await fs.promises.access(outputPath);
fs.createReadStream(outputPath).pipe(res);
} catch {
// 不存在则生成后返回
let width, height;
if (type === 'thumb') { width = 200; height = 200; }
else if (type === 'medium') { width = 600; height = 600; }
else { return res.status(400).send('未知尺寸类型'); }
try {
await sharp(filePath)
.resize(width, height, { fit: 'inside' })
.toFile(outputPath);
fs.createReadStream(outputPath).pipe(res);
} catch (err) {
res.status(500).send('图片处理失败');
}
}
});
常见图像处理操作
// 格式转换(png -> jpg)
sharp(inputBuffer).jpeg().toBuffer();
// 添加水印(需要提前准备水印图片)
sharp(mainImage)
.composite([{ input: watermarkBuffer, gravity: 'southeast' }])
.toFile(output);
// 获取图片元数据
const metadata = await sharp(file).metadata();
console.log(metadata.width, metadata.height);
// 旋转/翻转
sharp(input).rotate(90).toFile(output);
26.3.4 云存储对接:将文件与静态服务解耦
直接把文件存放在应用服务器磁盘上,存在扩展困难、备份复杂、跨域访问等痛点。引入对象存储服务(如阿里云 OSS、AWS S3、MinIO)可以让存储层独立扩展,并借助 CDN 加速分发。
使用 AWS S3 SDK 上传文件
安装 @aws-sdk/client-s3:
const { S3Client, PutObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
const fs = require('fs');
const s3Client = new S3Client({
region: 'ap-southeast-1',
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY,
secretAccessKey: process.env.AWS_SECRET_KEY
}
});
async function uploadToS3(filePath, key) {
const fileStream = fs.createReadStream(filePath);
const command = new PutObjectCommand({
Bucket: 'my-app-bucket',
Key: key,
Body: fileStream
});
await s3Client.send(command);
return `https://my-app-bucket.s3.amazonaws.com/${key}`;
}
在 multer 配置中可以使用内存存储,避免磁盘占用:
const upload = multer({
storage: multer.memoryStorage(),
fileFilter,
limits: { fileSize: 10 * 1024 * 1024 }
});
app.post('/cloud-upload', upload.single('file'), async (req, res) => {
const buffer = req.file.buffer;
const key = `uploads/${Date.now()}-${req.file.originalname}`;
await s3Client.send(new PutObjectCommand({
Bucket: 'my-app-bucket',
Key: key,
Body: buffer,
ContentType: req.file.mimetype
}));
const url = `https://my-app-bucket.s3.amazonaws.com/${key}`;
res.json({ url });
});
生成私有文件的临时下载链接
对于需要权限控制的文件,可以生成预签名 URL 并设置过期时间:
async function getPresignedUrl(key) {
const command = new GetObjectCommand({
Bucket: 'my-app-bucket',
Key: key
});
const url = await getSignedUrl(s3Client, command, { expiresIn: 3600 }); // 1小时有效
return url;
}
使用 MinIO 自建 S3 兼容存储(离线/内网环境)
MinIO 是开源的 S3 兼容对象存储,适合私有化部署。其 JavaScript 客户端使用方式与 S3 几乎一致:
const { Client: MinioClient } = require('minio');
const minioClient = new MinioClient({
endPoint: 'play.min.io',
port: 9000,
useSSL: true,
accessKey: 'YOURKEY',
secretKey: 'YOURSECRET'
});
// 上传
await minioClient.putObject('my-bucket', 'photos/avatar.png', buffer);
迁移到 MinIO 可保持与 S3 相同的代码结构,仅变更连接配置。
26.3.5 生产环境最佳实践
- 不要同步处理图片:上传后立即返回响应,将缩略图生成、转码等耗时任务交给消息队列(Bull、RabbitMQ)异步处理。
- 限制文件上传尺寸:不仅在 multer 做限制,还要在 Nginx 等反向代理层限制
client_max_body_size,防止大文件冲击。 - 病毒扫描:对上传文件进行病毒扫描(如 ClamAV),尤其是允许文档、压缩包上传的业务。
- 文件名与访问控制:存储文件时使用 UUID 等无意义名称;返回给前端的 URL 不暴露真实路径;增加访问令牌或限时签名。
- 缓存策略:静态资源设置长缓存
Cache-Control: max-age=31536000,通过文件内容哈希更新文件名来失效缓存。 - 日志与监控:记录上传失败、处理异常;监控存储空间和带宽用量。
通过合理搭配 multer、sharp、对象存储 SDK 和队列机制,Node.js 能够构建一个高效、稳定、可伸缩的文件服务系统,支撑常见的图片、视频、文档类应用需求。