文件存储 #
文件存储概述 #
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