人人都会AI编程

26.3 文件服务:上传、下载、缩略图、云存储对接

更新时间:2026-07-11

文件服务是几乎所有应用都会涉及的基础设施。从用户头像、商品图片到合同附件,对文件的上传、存储、处理、分发需求贯穿于各种业务系统。本节基于 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 类型(可伪造),应检查文件头魔术字节。对于图片,可用 sharpimage-type 库验证。
  • 防止路径遍历:不要直接使用 originalname 作为保存文件名,必须由服务端生成随机文件名。
  • 限制并发上传大小和数量:通过 limits.fileSizelimits.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 生产环境最佳实践

  1. 不要同步处理图片:上传后立即返回响应,将缩略图生成、转码等耗时任务交给消息队列(Bull、RabbitMQ)异步处理。
  2. 限制文件上传尺寸:不仅在 multer 做限制,还要在 Nginx 等反向代理层限制 client_max_body_size,防止大文件冲击。
  3. 病毒扫描:对上传文件进行病毒扫描(如 ClamAV),尤其是允许文档、压缩包上传的业务。
  4. 文件名与访问控制:存储文件时使用 UUID 等无意义名称;返回给前端的 URL 不暴露真实路径;增加访问令牌或限时签名。
  5. 缓存策略:静态资源设置长缓存 Cache-Control: max-age=31536000,通过文件内容哈希更新文件名来失效缓存。
  6. 日志与监控:记录上传失败、处理异常;监控存储空间和带宽用量。

通过合理搭配 multer、sharp、对象存储 SDK 和队列机制,Node.js 能够构建一个高效、稳定、可伸缩的文件服务系统,支撑常见的图片、视频、文档类应用需求。