文件存储 #

文件存储概述 #

Heroku 的 Dyno 文件系统是临时的,每次重启或重新部署都会丢失数据。因此,需要使用外部存储服务来持久化文件。

Dyno 文件系统特点 #

text
┌─────────────────────────────────────────────────────┐
│              Dyno 文件系统                           │
├─────────────────────────────────────────────────────┤
│                                                     │
│  /app/              # 应用代码(只读)               │
│  /tmp/              # 临时文件(可写,重启丢失)     │
│  ~/.heroku/         # Heroku 工具文件               │
│                                                     │
│  特点:                                             │
│  ├── 临时性:重启后丢失                             │
│  ├── 隔离性:每个 Dyno 独立                         │
│  └── 限制:不适合存储大文件                         │
│                                                     │
└─────────────────────────────────────────────────────┘

存储方案对比 #

方案 适用场景 特点
AWS S3 通用文件存储 可靠、可扩展
Cloudinary 图片/视频 自动处理、CDN
Azure Blob 企业应用 与 Azure 集成
Google Cloud Storage 大数据 与 GCP 集成

AWS S3 集成 #

安装依赖 #

bash
npm install @aws-sdk/client-s3

配置 S3 #

javascript
const { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-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 BUCKET_NAME = process.env.S3_BUCKET_NAME;

上传文件 #

javascript
const fs = require('fs');
const path = require('path');

async function uploadFile(file, key) {
  const fileContent = fs.readFileSync(file.path);
  
  const command = new PutObjectCommand({
    Bucket: BUCKET_NAME,
    Key: key,
    Body: fileContent,
    ContentType: file.mimetype,
    ACL: 'public-read'
  });
  
  await s3.send(command);
  
  return `https://${BUCKET_NAME}.s3.${process.env.AWS_REGION}.amazonaws.com/${key}`;
}

// 使用示例
const url = await uploadFile(req.file, `uploads/${Date.now()}-${req.file.originalname}`);

下载文件 #

javascript
async function downloadFile(key) {
  const command = new GetObjectCommand({
    Bucket: BUCKET_NAME,
    Key: key
  });
  
  const response = await s3.send(command);
  return response.Body;
}

删除文件 #

javascript
async function deleteFile(key) {
  const command = new DeleteObjectCommand({
    Bucket: BUCKET_NAME,
    Key: key
  });
  
  await s3.send(command);
}

预签名 URL #

javascript
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');

async function getUploadUrl(key, expiresIn = 3600) {
  const command = new PutObjectCommand({
    Bucket: BUCKET_NAME,
    Key: key
  });
  
  return await getSignedUrl(s3, command, { expiresIn });
}

async function getDownloadUrl(key, expiresIn = 3600) {
  const command = new GetObjectCommand({
    Bucket: BUCKET_NAME,
    Key: key
  });
  
  return await getSignedUrl(s3, command, { expiresIn });
}

Multer 文件上传 #

基本配置 #

javascript
const express = require('express');
const multer = require('multer');
const { S3Client } = require('@aws-sdk/client-s3');
const multerS3 = require('multer-s3');

const app = express();

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: multerS3({
    s3: s3,
    bucket: process.env.S3_BUCKET_NAME,
    key: function (req, file, cb) {
      cb(null, `uploads/${Date.now()}-${file.originalname}`);
    }
  }),
  limits: {
    fileSize: 5 * 1024 * 1024 // 5MB
  },
  fileFilter: function (req, file, cb) {
    const allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
    if (!allowedTypes.includes(file.mimetype)) {
      return cb(new Error('Invalid file type'), false);
    }
    cb(null, true);
  }
});

// 单文件上传
app.post('/upload', upload.single('file'), (req, res) => {
  res.json({
    message: 'File uploaded successfully',
    url: req.file.location
  });
});

// 多文件上传
app.post('/upload/multiple', upload.array('files', 5), (req, res) => {
  res.json({
    message: 'Files uploaded successfully',
    files: req.files.map(f => f.location)
  });
});

内存存储(临时处理) #

javascript
const upload = multer({
  storage: multer.memoryStorage(),
  limits: {
    fileSize: 5 * 1024 * 1024
  }
});

app.post('/process', upload.single('file'), async (req, res) => {
  // 处理文件
  const processedBuffer = await processImage(req.file.buffer);
  
  // 上传到 S3
  const key = `processed/${Date.now()}-${req.file.originalname}`;
  await uploadToS3(processedBuffer, key);
  
  res.json({ message: 'File processed and uploaded' });
});

Cloudinary 图片处理 #

安装配置 #

bash
npm install cloudinary
javascript
const cloudinary = require('cloudinary').v2;

cloudinary.config({
  cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
  api_key: process.env.CLOUDINARY_API_KEY,
  api_secret: process.env.CLOUDINARY_API_SECRET
});

上传图片 #

javascript
async function uploadImage(file) {
  const result = await cloudinary.uploader.upload(file.path, {
    folder: 'myapp',
    transformation: [
      { width: 800, height: 600, crop: 'limit' }
    ]
  });
  
  return {
    url: result.secure_url,
    publicId: result.public_id
  };
}

图片变换 #

javascript
// 生成缩略图
const thumbnailUrl = cloudinary.url(publicId, {
  width: 200,
  height: 200,
  crop: 'fill'
});

// 格式转换
const webpUrl = cloudinary.url(publicId, {
  format: 'webp'
});

// 优化
const optimizedUrl = cloudinary.url(publicId, {
  quality: 'auto',
  fetch_format: 'auto'
});

删除图片 #

javascript
async function deleteImage(publicId) {
  await cloudinary.uploader.destroy(publicId);
}

静态文件服务 #

使用 CDN #

javascript
// 配置静态资源 CDN
const CDN_URL = process.env.CDN_URL || '';

app.use(express.static('public', {
  setHeaders: (res, path) => {
    res.setHeader('Cache-Control', 'public, max-age=31536000');
  }
}));

// 模板中使用 CDN
app.locals.assetUrl = (path) => {
  return CDN_URL ? `${CDN_URL}${path}` : path;
};

Express 静态文件 #

javascript
// 开发环境直接服务静态文件
if (process.env.NODE_ENV === 'development') {
  app.use(express.static('public'));
}

// 生产环境建议使用 CDN 或对象存储

文件处理 #

图片处理 #

javascript
const sharp = require('sharp');

async function processImage(buffer) {
  return await sharp(buffer)
    .resize(800, 600, { fit: 'inside' })
    .jpeg({ quality: 80 })
    .toBuffer();
}

// 生成缩略图
async function createThumbnail(buffer) {
  return await sharp(buffer)
    .resize(200, 200, { fit: 'cover' })
    .jpeg({ quality: 70 })
    .toBuffer();
}

PDF 处理 #

javascript
const PDFDocument = require('pdfkit');

async function generatePDF(data) {
  return new Promise((resolve) => {
    const doc = new PDFDocument();
    const chunks = [];
    
    doc.on('data', chunk => chunks.push(chunk));
    doc.on('end', () => resolve(Buffer.concat(chunks)));
    
    doc.fontSize(25).text('Report', 100, 100);
    doc.text(data.content);
    doc.end();
  });
}

文件压缩 #

javascript
const archiver = require('archiver');

async function createZip(files) {
  return new Promise((resolve) => {
    const archive = archiver('zip');
    const chunks = [];
    
    archive.on('data', chunk => chunks.push(chunk));
    archive.on('end', () => resolve(Buffer.concat(chunks)));
    
    files.forEach(file => {
      archive.append(file.content, { name: file.name });
    });
    
    archive.finalize();
  });
}

安全考虑 #

文件类型验证 #

javascript
const fileType = require('file-type');

async function validateFileType(buffer, allowedTypes) {
  const type = await fileType.fromBuffer(buffer);
  
  if (!type || !allowedTypes.includes(type.mime)) {
    throw new Error('Invalid file type');
  }
  
  return type;
}

// 使用示例
app.post('/upload', upload.single('file'), async (req, res) => {
  try {
    const type = await validateFileType(req.file.buffer, [
      'image/jpeg',
      'image/png'
    ]);
    // 继续处理
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
});

文件大小限制 #

javascript
const upload = multer({
  limits: {
    fileSize: 5 * 1024 * 1024, // 5MB
    files: 5 // 最多 5 个文件
  }
});

// 错误处理
app.use((error, req, res, next) => {
  if (error instanceof multer.MulterError) {
    if (error.code === 'LIMIT_FILE_SIZE') {
      return res.status(400).json({ error: 'File too large' });
    }
    if (error.code === 'LIMIT_FILE_COUNT') {
      return res.status(400).json({ error: 'Too many files' });
    }
  }
  next(error);
});

访问控制 #

javascript
// 私有文件访问
app.get('/files/:key', authenticate, async (req, res) => {
  const url = await getSignedDownloadUrl(req.params.key);
  res.redirect(url);
});

// 预签名 URL
async function getSignedDownloadUrl(key, expiresIn = 300) {
  const command = new GetObjectCommand({
    Bucket: BUCKET_NAME,
    Key: key
  });
  
  return await getSignedUrl(s3, command, { expiresIn });
}

Heroku Add-ons #

使用 Bucketeer #

bash
# 添加 Bucketeer(S3 兼容)
heroku addons:create bucketeer:free

# 查看配置
heroku config | grep BUCKETEER

# 使用方式与 S3 相同

使用 Cloudinary Add-on #

bash
# 添加 Cloudinary
heroku addons:create cloudinary:starter

# 查看配置
heroku config | grep CLOUDINARY

最佳实践 #

1. 使用 CDN #

text
用户请求 ──► CDN ──► 缓存命中?
                         │
           ┌─────────────┼─────────────┐
           │             │             │
           ▼             ▼             ▼
          是            否            否
           │             │             │
           ▼             ▼             ▼
      返回缓存      从源获取       从 S3 获取
                         │             │
                         └──────┬──────┘
                                │
                                ▼
                           缓存到 CDN

2. 文件命名策略 #

javascript
function generateFileName(originalName) {
  const ext = path.extname(originalName);
  const timestamp = Date.now();
  const random = Math.random().toString(36).substring(2, 8);
  return `${timestamp}-${random}${ext}`;
}

// 按日期分目录
function getStoragePath(filename) {
  const date = new Date();
  const year = date.getFullYear();
  const month = String(date.getMonth() + 1).padStart(2, '0');
  return `uploads/${year}/${month}/${filename}`;
}

3. 错误处理 #

javascript
async function safeUpload(file) {
  try {
    const url = await uploadFile(file);
    return { success: true, url };
  } catch (error) {
    console.error('Upload failed:', error);
    return { success: false, error: error.message };
  }
}

故障排查 #

上传失败 #

bash
# 检查环境变量
heroku config | grep AWS

# 检查日志
heroku logs --tail | grep upload

# 常见问题
# 1. AWS 凭证错误
# 2. Bucket 不存在
# 3. 权限不足
# 4. 文件过大

文件访问问题 #

bash
# 检查文件是否存在
aws s3 ls s3://bucket-name/path/to/file

# 检查权限
aws s3api get-object-acl --bucket bucket-name --key path/to/file

下一步 #

文件存储掌握后,接下来学习 插件市场概览 了解更多 Add-ons 服务!

最后更新:2026-03-28