人人都会AI编程

14.4 文件处理:上传、下载、断点续传、云存储对接

更新时间:2026-07-10

文件处理是后端服务中最常见也最容易出问题的功能之一。小到用户头像上传,大到几十 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/片),逐片上传,服务端合并。

前端实现简化逻辑:

  1. 使用 File.slice() 切分文件。
  2. 为每个分片生成唯一标识(文件哈希 + 分片序号)。
  3. 逐片发送,可并发控制(如一次上传 3 片)。
  4. 所有分片上传完成后,请求合并接口。

后端接收分片需要两个核心接口:

  • 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 断点续传上传:完整的可靠性方案

上一节的分片上传已经天然具备断点续传特性:每个分片独立上传,失败的分片可以重新传输。但一个完整的方案还需要考虑以下细节:

  1. 分片上传前检测:前端可以先请求 /check 接口,传入文件哈希,服务端返回已上传成功的分片索引列表,前端跳过这些分片,仅上传缺失部分。
  2. 并发控制:维护一个上传队列,限制同时并发的分片数(如3个),一个分片失败可自动重试。
  3. 分片顺序:分片可以乱序到达,服务端按序号合并即可。
  4. 最终校验:合并前重新计算整个文件的哈希,与前端提供的哈希比对,保证完整性。如果不一致,要求重传。

封装一个简单的上传管理器思路(伪代码,前端可用 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.jsplupload,它们已经封装好了分片、重试、并发等功能。

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-storageUpload 类,它会自动处理分片:

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 最佳实践与安全考量

  1. 限制文件大小和类型:在网关层、框架中间件(如 Multer 的 limits)以及云存储策略层都要做限制,不要完全依赖客户端校验。
  2. 文件重命名:始终使用随机生成的文件名(如 UUID),避免暴露原始文件名可能带来的路径遍历攻击或敏感信息泄露。
  3. 临时文件清理:若使用磁盘临时存储,设置定时任务清理未合并的分片目录和超时的分片文件。
  4. 鉴权:下载或上传都需要验证用户身份,尤其是云存储的直接上传,预签名 URL 不要给予过长有效期,且可限制其只能用于特定对象。
  5. 跨域处理:如果使用客户端直传云存储,需在云存储控制台配置 CORS 规则,允许来自业务域名的请求。
  6. 监控与日志:记录上传/下载操作的关键信息(用户、文件大小、时间、IP),便于排查问题和审计。
  7. 大流量成本优化:对于公开下载的文件,务必配置 CDN,避免直接暴露云存储源站 URL,既节省费用又提升下载速度。

文件处理看似基础,但涉及上传策略、流控制、云服务对接等诸多细节,一个健壮的方案往往是项目中最早沉淀的核心能力之一。遵循以上模式,可以构建出既能应对标量级小文件,又能轻松处理 GB 级大文件的文件服务。