文件处理是后端服务中最常见也最容易出问题的功能之一。小到用户头像上传,大到几十 GB 的视频分片传输,如果处理不当,不仅会拖垮服务器内存,还会导致用户体验极差的上传失败或下载中断。本节从最基础的上传下载讲起,逐步深入断点续传的实现原理,最后讨论对接云存储的最佳实践。
14.4.1 文件上传:从表单解析到分片策略
基础上传:使用 Multer 处理 multipart/form-data
浏览器上传文件通常使用 multipart/form-data 格式。Node.js 中最成熟的解析库是 Multer,它可以将请求中的文件写入磁盘或临时缓存,并提供文件元信息。
安装:
npm install multer
单文件上传示例(Express):
const express = require('express');
const multer = require('multer');
const path = require('path');
const app = express();
// 配置存储引擎
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, './uploads/'); // 确保目录存在
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `${Date.now()}-${Math.round(Math.random() * 1e9)}${ext}`);
}
});
const upload = multer({
storage,
limits: { fileSize: 5 * 1024 * 1024 }, // 限制 5MB
fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) {
cb(null, true);
} else {
cb(new Error('仅允许上传图片'));
}
}
});
app.post('/upload', upload.single('avatar'), (req, res) => {
// req.file 包含上传文件信息
res.json({ path: req.file.path });
});
如果是多文件上传,使用 upload.array('photos', 5);混合字段上传则用 upload.fields([...])。
Multer 的 memoryStorage 选项可以将文件保存在内存中的 Buffer,便于直接上传到云存储而不落盘,但对请求大小仍需限制。
大文件分片上传:突破单次请求限制
当文件达到几百 MB 甚至 GB 级别时,单次上传不仅受网络波动影响大,还会因请求体过大被反向代理或 Node.js 的 body parser 限制。分片上传是业界标准方案:将文件切分为多个小块(如 5MB/片),逐片上传,服务端合并。
前端实现简化逻辑:
- 使用
File.slice()切分文件。 - 为每个分片生成唯一标识(文件哈希 + 分片序号)。
- 逐片发送,可并发控制(如一次上传 3 片)。
- 所有分片上传完成后,请求合并接口。
后端接收分片需要两个核心接口:
POST /upload/chunk— 接收单个分片POST /upload/merge— 触发分片合并
示例实现(使用 Express + fs 模块):
const fsp = require('fs').promises;
const path = require('path');
const crypto = require('crypto');
// 接收分片
app.post('/upload/chunk', upload.single('chunk'), async (req, res) => {
const { fileHash, chunkIndex } = req.body;
const chunkDir = path.resolve('./chunks', fileHash);
// 确保分片目录存在
await fsp.mkdir(chunkDir, { recursive: true });
// 将分片暂存为以 chunkIndex 命名的文件
const chunkPath = path.join(chunkDir, `${chunkIndex}`);
await fsp.rename(req.file.path, chunkPath);
res.json({ ok: true });
});
// 合并分片
app.post('/upload/merge', async (req, res) => {
const { fileHash, fileName, chunkSize, totalChunks } = req.body;
const chunkDir = path.resolve('./chunks', fileHash);
const finalPath = path.resolve('./uploads', fileName);
// 按序号读取分片并写入最终文件
const writeStream = require('fs').createWriteStream(finalPath);
for (let i = 0; i < totalChunks; i++) {
const chunkFile = path.join(chunkDir, `${i}`);
const chunkBuffer = await fsp.readFile(chunkFile);
writeStream.write(chunkBuffer);
await fsp.unlink(chunkFile); // 删除已合并分片
}
writeStream.end();
// 删除分片目录
await fsp.rmdir(chunkDir);
res.json({ path: finalPath });
});
关键点:前端上传前最好先计算整个文件的 MD5 或 SHA256,作为 fileHash。这样即使不同用户上传同名文件,也可以通过哈希区分,并实现秒传(服务端检查哈希,若已存在直接返回成功)。
直接上传到云存储(预签名 URL)
如果服务器仅做中转,流量和磁盘压力会很大。生产环境通常让客户端直接上传到云存储(OSS/S3),服务端只负责颁发临时凭证。云服务商提供预签名 URL,允许客户端在限定时间内直接上传。
以 AWS S3 为例:
const AWS = require('aws-sdk');
const s3 = new AWS.S3({ region: 'us-east-1' });
app.get('/presigned-url', async (req, res) => {
const params = {
Bucket: 'my-bucket',
Key: `uploads/${Date.now()}_${req.query.filename}`,
Expires: 300, // 预签名 URL 有效期,单位秒
ContentType: req.query.contentType
};
const url = await s3.getSignedUrlPromise('putObject', params);
res.json({ url, key: params.Key });
});
前端拿到 url 后直接执行 PUT 请求上传文件。这种方案将流量压力彻底转移给云服务商,后端只需要处理业务逻辑(如记录文件信息)。
14.4.2 文件下载:流式传输与断点续传
流式下载,避免内存爆炸
如果直接将文件整个读入内存再返回,大文件会瞬间撑爆进程。正确的做法是使用流,将文件通过 fs.createReadStream 管道到 HTTP 响应:
app.get('/download/:filename', (req, res) => {
const filePath = path.resolve('./uploads', req.params.filename);
const stat = fs.statSync(filePath);
res.setHeader('Content-Length', stat.size);
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${req.params.filename}"`);
const readStream = fs.createReadStream(filePath);
readStream.pipe(res);
});
流式传输不仅内存友好,还能自动处理背压,使得下载速度与客户端的接收能力匹配。
断点续传下载(Range 请求)
HTTP 协议提供了 Range 头,允许客户端请求文件的特定字节范围。下载中断后,客户端可以从已接收的最后一个字节接着请求,避免重新下载。
服务端需要解析 Range 头,并返回 206 Partial Content 状态码:
app.get('/download/:filename', (req, res) => {
const filePath = path.resolve('./uploads', req.params.filename);
const stat = fs.statSync(filePath);
const range = req.headers.range;
if (!range) {
// 无 Range 头,返回整个文件
res.setHeader('Content-Length', stat.size);
res.status(200);
fs.createReadStream(filePath).pipe(res);
return;
}
// 解析 Range: bytes=0-1023 格式
const parts = range.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : stat.size - 1;
if (start >= stat.size || end >= stat.size) {
res.status(416).send('Range Not Satisfiable');
return;
}
const chunkSize = end - start + 1;
res.status(206);
res.setHeader('Content-Range', `bytes ${start}-${end}/${stat.size}`);
res.setHeader('Accept-Ranges', 'bytes');
res.setHeader('Content-Length', chunkSize);
res.setHeader('Content-Type', 'application/octet-stream');
const readStream = fs.createReadStream(filePath, { start, end });
readStream.pipe(res);
});
前端下载器(浏览器或 axios)通常会自动处理断点续传,只要服务端正确实现了 Range 支持。
14.4.3 断点续传上传:完整的可靠性方案
上一节的分片上传已经天然具备断点续传特性:每个分片独立上传,失败的分片可以重新传输。但一个完整的方案还需要考虑以下细节:
- 分片上传前检测:前端可以先请求
/check接口,传入文件哈希,服务端返回已上传成功的分片索引列表,前端跳过这些分片,仅上传缺失部分。 - 并发控制:维护一个上传队列,限制同时并发的分片数(如3个),一个分片失败可自动重试。
- 分片顺序:分片可以乱序到达,服务端按序号合并即可。
- 最终校验:合并前重新计算整个文件的哈希,与前端提供的哈希比对,保证完整性。如果不一致,要求重传。
封装一个简单的上传管理器思路(伪代码,前端可用 JavaScript):
class UploadManager {
constructor(file, chunkSize = 5 * 1024 * 1024) {
this.file = file;
this.chunkSize = chunkSize;
this.chunkCount = Math.ceil(file.size / chunkSize);
}
async upload() {
for (let i = 0; i < this.chunkCount; i++) {
const chunk = this.file.slice(i * this.chunkSize, (i + 1) * this.chunkSize);
const form = new FormData();
form.append('chunk', chunk);
form.append('chunkIndex', i);
form.append('fileHash', await this.calcHash());
form.append('chunkCount', this.chunkCount);
try {
await axios.post('/upload/chunk', form);
} catch (e) {
i--; // 重试当前分片
}
}
await axios.post('/upload/merge', { /* ... */ });
}
}
实际生产中,可以采用一些成熟的库如 simple-uploader.js 或 plupload,它们已经封装好了分片、重试、并发等功能。
14.4.4 云存储对接:以 AWS S3 及兼容服务为例
云存储不仅能解决海量文件存储问题,还提供 CDN 加速、生命周期管理、权限控制等附加价值。主流的云存储服务(阿里云 OSS、腾讯云 COS、七牛云、MinIO 等)大多兼容 S3 的 API,开发方式类似。
安装 SDK 并配置客户端
npm install @aws-sdk/client-s3
使用新版 AWS SDK v3 的模块化导入:
const { S3Client, PutObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3');
const s3Client = new S3Client({
region: 'us-east-1',
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY
}
});
服务端控制的上传(文件先传到 Node.js 再转发到 S3)
适用于需要服务端进行权限校验、缩略图生成、病毒扫描等操作的场景。注意使用流式传输避免占用过多内存。
app.post('/upload-to-s3', upload.single('file'), async (req, res) => {
const fileStream = require('fs').createReadStream(req.file.path);
const key = `user-content/${Date.now()}_${req.file.originalname}`;
const command = new PutObjectCommand({
Bucket: 'my-bucket',
Key: key,
Body: fileStream,
ContentType: req.file.mimetype
});
await s3Client.send(command);
// 清除本地临时文件
await fsp.unlink(req.file.path);
res.json({ url: `https://my-bucket.s3.amazonaws.com/${key}` });
});
使用 Multipart Upload 上传大文件到 S3
S3 提供了分段上传 API(CreateMultipartUpload、UploadPart、CompleteMultipartUpload),与我们的分片上传策略可以无缝结合。但更简单的做法是使用 S3 的预签名 URL 分段上传,或直接用 @aws-sdk/lib-storage 的 Upload 类,它会自动处理分片:
const { Upload } = require('@aws-sdk/lib-storage');
const uploadS3 = new Upload({
client: s3Client,
params: {
Bucket: 'my-bucket',
Key: key,
Body: fileStream
},
queueSize: 4, // 并发分片数
partSize: 5 * 1024 * 1024 // 5MB
});
uploadS3.on('httpUploadProgress', (progress) => {
console.log(`已上传 ${progress.loaded} / ${progress.total}`);
});
await uploadS3.done();
这种封装让我们不必手动管理分片,就能享受断点续传和并发优势,且直接上传至 S3,服务端无需处理文件。
对接阿里云 OSS
阿里云 OSS SDK 同样支持流式上传和分片上传,用法与 S3 类似,但配置不同:
const OSS = require('ali-oss');
const client = new OSS({
region: 'oss-cn-hangzhou',
accessKeyId: 'your-id',
accessKeySecret: 'your-secret',
bucket: 'your-bucket'
});
// 流式上传
const result = await client.putStream('object-key', fileStream);
14.4.5 最佳实践与安全考量
- 限制文件大小和类型:在网关层、框架中间件(如 Multer 的
limits)以及云存储策略层都要做限制,不要完全依赖客户端校验。 - 文件重命名:始终使用随机生成的文件名(如 UUID),避免暴露原始文件名可能带来的路径遍历攻击或敏感信息泄露。
- 临时文件清理:若使用磁盘临时存储,设置定时任务清理未合并的分片目录和超时的分片文件。
- 鉴权:下载或上传都需要验证用户身份,尤其是云存储的直接上传,预签名 URL 不要给予过长有效期,且可限制其只能用于特定对象。
- 跨域处理:如果使用客户端直传云存储,需在云存储控制台配置 CORS 规则,允许来自业务域名的请求。
- 监控与日志:记录上传/下载操作的关键信息(用户、文件大小、时间、IP),便于排查问题和审计。
- 大流量成本优化:对于公开下载的文件,务必配置 CDN,避免直接暴露云存储源站 URL,既节省费用又提升下载速度。
文件处理看似基础,但涉及上传策略、流控制、云服务对接等诸多细节,一个健壮的方案往往是项目中最早沉淀的核心能力之一。遵循以上模式,可以构建出既能应对标量级小文件,又能轻松处理 GB 级大文件的文件服务。