本文 AI 產出,尚未審核

ExpressJS (TypeScript) – 檔案上傳的儲存策略(Local、S3、GCS)


簡介

在 Web 應用中,檔案上傳是最常見的需求之一,無論是使用者大頭貼、產品照片或是影片,都需要一個可靠的儲存機制。
對於使用 ExpressJS + TypeScript 的開發者而言,選擇合適的儲存策略直接影響到系統的效能、成本與可維護性。

本篇文章將從 本機儲存 (Local)Amazon S3Google Cloud Storage (GCS) 三種主流方案出發,說明它們的原理、實作方式與適用情境,並提供完整的程式碼範例與實務建議,讓你能快速在專案中切換或同時支援多種儲存後端。


核心概念

1. 為什麼要抽象化儲存介面?

直接在路由裡寫死 fs.writeFileSyncs3.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 前置作業

  1. 在 AWS IAM 建立擁有 s3:PutObjects3:DeleteObject 權限的使用者,取得 Access KeySecret Key
  2. 建立 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)整合度高。
  • 支援 ColdlineNearline 等分層儲存,適合長期備份。
  • IAM 授權細緻,可針對 bucket 或物件設定存取權限。

4.2 前置作業

  1. 在 GCP 建立 Service Account,下載 JSON 金鑰檔,並設定環境變數 GOOGLE_APPLICATION_CREDENTIALS
  2. 建立 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,或改用 streammulter.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,統一回傳錯誤訊息與錯誤碼

最佳實踐

  1. 環境變數化:所有金鑰、bucket 名稱、區域皆放在 .env,避免硬編碼。
  2. 分層儲存:將不同類型的檔案(圖片、影片、備份)分別放在不同的 bucket / folder,方便設定 LifecycleIAM
  3. 使用 CDN:上傳後將檔案交給 CloudFront、Cloudflare 或 Google Cloud CDN,減少原始儲存的流量成本。
  4. 驗證檔案類型:在 Multer 設定 fileFilter,僅允許安全的 MIME(如 image/jpeg, application/pdf)。
  5. 日誌與監控:將上傳成功/失敗寫入日誌,並結合 CloudWatch、Stackdriver 監控異常率。

實際應用場景

場景 推薦儲存策略 為什麼
部落格圖片 Local(開發/小型部署)或 S3(正式環境) 圖片數量有限,成本考量較重要;正式環境建議使用 S3 搭配 CloudFront,提升載入速度。
電商商品照片 S3 + CloudFront 高流量、需要全球快速存取,且檔案會被多次重複讀取,使用 CDN 效果顯著。
影片平台(長片) GCS(Coldline)或 S3 Glacier 影片檔案大、存取頻率低,使用低成本的冷存儲,搭配 Signed URL 控制下載權限。
使用者大頭貼 S3GCS(Standard)+ Cache-Control 大量小檔案,適合快速讀取且可設定短期快取。
備份檔案 GCS Nearline / ColdlineS3 Glacier 需要長期保存且不常存取,成本效益最佳。

總結

  • 抽象化儲存介面 是提升程式碼可維護性與可測試性的關鍵。
  • 本機儲存 適合開發與小規模服務,S3 提供全球分佈與高可用,GCS 則在 Google 生態系中具備成本彈性與分層儲存。
  • 在實作時務必注意 檔案命名、記憶體限制、CORS、ACL 等細節,並遵循 環境變數化、CDN、日誌監控 的最佳實踐。

掌握了以上概念與範例,你就能根據專案需求,快速在 ExpressJS + TypeScript 中切換或同時使用多種儲存策略,讓檔案上傳功能既安全又具備擴展性。祝開發順利! 🚀