本文 AI 產出,尚未審核
ExpressJS (TypeScript) – 檔案上傳的儲存策略(Local、S3、GCS)
簡介
在 Web 應用中,檔案上傳是最常見的需求之一,無論是使用者大頭貼、產品照片或是影片,都需要一個可靠的儲存機制。
對於使用 ExpressJS + TypeScript 的開發者而言,選擇合適的儲存策略直接影響到系統的效能、成本與可維護性。
本篇文章將從 本機儲存 (Local)、Amazon S3、Google Cloud Storage (GCS) 三種主流方案出發,說明它們的原理、實作方式與適用情境,並提供完整的程式碼範例與實務建議,讓你能快速在專案中切換或同時支援多種儲存後端。
核心概念
1. 為什麼要抽象化儲存介面?
直接在路由裡寫死 fs.writeFileSync 或 s3.upload 會讓程式碼耦合度過高,未來換雲端供應商或改成 CDN 時必須大幅重構。
解法:建立一個 Storage Service Interface,所有上傳流程只呼叫介面方法,具體實作則交給不同的儲存類別負責。
// src/storage/IStorageService.ts
export interface IStorageService {
/** 上傳檔案,回傳公開 URL */
upload(file: Express.Multer.File, destination?: string): Promise<string>;
/** 刪除檔案 */
delete(key: string): Promise<void>;
}
使用介面抽象化的好處:
- 可測試:在單元測試時只需要 mock 介面。
- 可切換:只要實作
IStorageService,即能在 Local、S3、GCS 之間自由切換。
2. 本機儲存(Local)
2.1 主要特性
- 簡單快速:不需要額外雲端服務,適合開發、測試或小型專案。
- 成本最低:只佔用伺服器磁碟空間。
- 限制:單機部署時,檔案不會自動同步;若伺服器重啟或磁碟滿了,檔案會遺失。
2.2 實作範例
// src/storage/LocalStorageService.ts
import { IStorageService } from './IStorageService';
import { promises as fs } from 'fs';
import path from 'path';
import crypto from 'crypto';
export class LocalStorageService implements IStorageService {
private readonly basePath: string;
private readonly baseUrl: string; // 供前端直接存取的 URL 前綴
constructor(basePath: string, baseUrl: string) {
this.basePath = basePath;
this.baseUrl = baseUrl;
}
async upload(file: Express.Multer.File, destination = ''): Promise<string> {
// 產生唯一檔名,避免衝突
const ext = path.extname(file.originalname);
const filename = crypto.randomBytes(16).toString('hex') + ext;
const folder = path.join(this.basePath, destination);
await fs.mkdir(folder, { recursive: true });
const fullPath = path.join(folder, filename);
await fs.writeFile(fullPath, file.buffer);
// 回傳可直接存取的 URL
return `${this.baseUrl}/${destination}/${filename}`;
}
async delete(key: string): Promise<void> {
const filePath = path.join(this.basePath, key);
await fs.unlink(filePath);
}
}
重點說明
- 使用
file.buffer(Multer 設定storage: memoryStorage())可以避免在磁碟上產生暫存檔。crypto.randomBytes產生的檔名確保 唯一性,減少命名衝突的機會。
3. Amazon S3
3.1 主要特性
- 高可用、全球分佈:適合大量流量與跨區域服務。
- 內建權限與 CDN 整合(CloudFront)。
- 成本:依存儲量、請求次數與資料傳輸計費,需要注意 讀寫頻率。
3.2 前置作業
- 在 AWS IAM 建立擁有
s3:PutObject、s3:DeleteObject權限的使用者,取得 Access Key 與 Secret Key。 - 建立 Bucket(例如
my-app-uploads),設定 CORS 允許前端直接存取:
[
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["GET","PUT","POST","DELETE"],
"AllowedOrigins": ["*"],
"ExposeHeaders": []
}
]
3.3 實作範例
// src/storage/S3StorageService.ts
import { IStorageService } from './IStorageService';
import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
import crypto from 'crypto';
import path from 'path';
import { Readable } from 'stream';
export class S3StorageService implements IStorageService {
private readonly client: S3Client;
private readonly bucket: string;
private readonly baseUrl: string; // 例如 https://my-app-uploads.s3.amazonaws.com
constructor(bucket: string, region: string, baseUrl: string) {
this.bucket = bucket;
this.baseUrl = baseUrl;
this.client = new S3Client({
region,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});
}
async upload(file: Express.Multer.File, destination = ''): Promise<string> {
const ext = path.extname(file.originalname);
const key = `${destination}/${crypto.randomBytes(16).toString('hex')}${ext}`;
const command = new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: file.buffer, // 直接使用 Buffer
ContentType: file.mimetype,
ACL: 'public-read', // 讓檔案可以直接透過 URL 存取
});
await this.client.send(command);
return `${this.baseUrl}/${key}`;
}
async delete(key: string): Promise<void> {
const command = new DeleteObjectCommand({
Bucket: this.bucket,
Key: key,
});
await this.client.send(command);
}
}
小技巧
- 若檔案較大(>5 MB),建議改用 multipart upload,或直接讓前端使用 pre‑signed URL 上傳,減少 Node 端的記憶體佔用。
4. Google Cloud Storage (GCS)
4.1 主要特性
- 與 Google 生態系(BigQuery、Cloud Run)整合度高。
- 支援 Coldline、Nearline 等分層儲存,適合長期備份。
- IAM 授權細緻,可針對 bucket 或物件設定存取權限。
4.2 前置作業
- 在 GCP 建立 Service Account,下載 JSON 金鑰檔,並設定環境變數
GOOGLE_APPLICATION_CREDENTIALS。 - 建立 bucket(例
my-app-gcs),開啟 Uniform bucket-level access,設定 CORS:
[
{
"origin": ["*"],
"method": ["GET","PUT","POST","DELETE"],
"responseHeader": ["Content-Type"],
"maxAgeSeconds": 3600
}
]
4.3 實作範例
// src/storage/GCSStorageService.ts
import { IStorageService } from './IStorageService';
import { Storage, File } from '@google-cloud/storage';
import crypto from 'crypto';
import path from 'path';
export class GCSStorageService implements IStorageService {
private readonly bucketName: string;
private readonly storage: Storage;
private readonly baseUrl: string; // 例如 https://storage.googleapis.com/my-app-gcs
constructor(bucketName: string) {
this.bucketName = bucketName;
this.storage = new Storage(); // 會自動讀取 GOOGLE_APPLICATION_CREDENTIALS
this.baseUrl = `https://storage.googleapis.com/${bucketName}`;
}
async upload(file: Express.Multer.File, destination = ''): Promise<string> {
const ext = path.extname(file.originalname);
const filename = `${destination}/${crypto.randomBytes(16).toString('hex')}${ext}`;
const bucket = this.storage.bucket(this.bucketName);
const gcsFile = bucket.file(filename);
await gcsFile.save(file.buffer, {
resumable: false,
contentType: file.mimetype,
public: true, // 直接公開
});
return `${this.baseUrl}/${filename}`;
}
async delete(key: string): Promise<void> {
const bucket = this.storage.bucket(this.bucketName);
const gcsFile = bucket.file(key);
await gcsFile.delete();
}
}
注意:
gcsFile.save內部會自動處理 multipart,但若檔案非常大,仍建議改用 stream (createWriteStream)。
5. 在 Express 路由中使用抽象化服務
// src/routes/upload.ts
import { Router } from 'express';
import multer from 'multer';
import { IStorageService } from '../storage/IStorageService';
import { LocalStorageService } from '../storage/LocalStorageService';
import { S3StorageService } from '../storage/S3StorageService';
import { GCSStorageService } from '../storage/GCSStorageService';
const router = Router();
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } }); // 10 MB
// 依環境變數決定儲存策略
let storageService: IStorageService;
switch (process.env.STORAGE_PROVIDER) {
case 's3':
storageService = new S3StorageService(
process.env.S3_BUCKET!,
process.env.AWS_REGION!,
`https://${process.env.S3_BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com`,
);
break;
case 'gcs':
storageService = new GCSStorageService(process.env.GCS_BUCKET!);
break;
default:
storageService = new LocalStorageService(
path.resolve(__dirname, '../../uploads'),
`${process.env.BASE_URL}/uploads`,
);
}
// 單一檔案上傳範例
router.post('/avatar', upload.single('avatar'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
try {
const url = await storageService.upload(req.file, 'avatars');
res.json({ url });
} catch (e) {
console.error(e);
res.status(500).json({ error: 'Upload failed' });
}
});
export default router;
關鍵點
- Multer 設為 memoryStorage,讓檔案先留在記憶體,之後交給不同的儲存服務處理。
- 透過
process.env.STORAGE_PROVIDER可以無縫切換本機、S3、GCS。
常見陷阱與最佳實踐
| 陷阱 | 為什麼會發生 | 解決方案 |
|---|---|---|
| 檔案名稱衝突 | 直接使用原始檔名會在同一目錄產生覆寫 | 使用 UUID / randomBytes 加上副檔名,或加入時間戳 |
| 記憶體 OOM | multer.memoryStorage() 會把全部檔案放在 Node 記憶體,若上傳大檔會崩潰 |
設定 limits.fileSize,或改用 stream(multer.diskStorage + pipe) |
| CORS 設定遺漏 | 前端直接呼叫 S3 / GCS 時收到 403 | 在 bucket 中正確設定 CORS,允許所需的 HTTP 方法與來源 |
| 未設定 ACL | 上傳後無法直接透過 URL 存取 | 在 S3 使用 ACL: 'public-read',GCS 使用 public: true,或透過 CloudFront / Signed URL 控制存取 |
| 過期的臨時檔 | 本機儲存時未清除舊檔,磁碟空間逐漸耗盡 | 定期執行 cron 或使用 lifecycle policy(S3、GCS)自動刪除過期檔案 |
| 錯誤未捕獲 | 上傳失敗後直接拋出例外,導致服務崩潰 | 在所有 await 前加 try/catch,統一回傳錯誤訊息與錯誤碼 |
最佳實踐
- 環境變數化:所有金鑰、bucket 名稱、區域皆放在
.env,避免硬編碼。 - 分層儲存:將不同類型的檔案(圖片、影片、備份)分別放在不同的 bucket / folder,方便設定 Lifecycle 或 IAM。
- 使用 CDN:上傳後將檔案交給 CloudFront、Cloudflare 或 Google Cloud CDN,減少原始儲存的流量成本。
- 驗證檔案類型:在 Multer 設定
fileFilter,僅允許安全的 MIME(如image/jpeg,application/pdf)。 - 日誌與監控:將上傳成功/失敗寫入日誌,並結合 CloudWatch、Stackdriver 監控異常率。
實際應用場景
| 場景 | 推薦儲存策略 | 為什麼 |
|---|---|---|
| 部落格圖片 | Local(開發/小型部署)或 S3(正式環境) | 圖片數量有限,成本考量較重要;正式環境建議使用 S3 搭配 CloudFront,提升載入速度。 |
| 電商商品照片 | S3 + CloudFront | 高流量、需要全球快速存取,且檔案會被多次重複讀取,使用 CDN 效果顯著。 |
| 影片平台(長片) | GCS(Coldline)或 S3 Glacier | 影片檔案大、存取頻率低,使用低成本的冷存儲,搭配 Signed URL 控制下載權限。 |
| 使用者大頭貼 | S3 或 GCS(Standard)+ Cache-Control | 大量小檔案,適合快速讀取且可設定短期快取。 |
| 備份檔案 | GCS Nearline / Coldline 或 S3 Glacier | 需要長期保存且不常存取,成本效益最佳。 |
總結
- 抽象化儲存介面 是提升程式碼可維護性與可測試性的關鍵。
- 本機儲存 適合開發與小規模服務,S3 提供全球分佈與高可用,GCS 則在 Google 生態系中具備成本彈性與分層儲存。
- 在實作時務必注意 檔案命名、記憶體限制、CORS、ACL 等細節,並遵循 環境變數化、CDN、日誌監控 的最佳實踐。
掌握了以上概念與範例,你就能根據專案需求,快速在 ExpressJS + TypeScript 中切換或同時使用多種儲存策略,讓檔案上傳功能既安全又具備擴展性。祝開發順利! 🚀