dramaling-vocab-learning/Cloudflare-R2圖片儲存遷移指南.md

17 KiB
Raw Blame History

Cloudflare R2 圖片儲存遷移指南

概述

將 DramaLing 後端的例句圖片儲存從本地檔案系統遷移到 Cloudflare R2 雲端儲存服務。

目前架構分析

現有圖片儲存系統

  • 接口: IImageStorageService
  • 實現: LocalImageStorageService
  • 儲存位置: wwwroot/images/examples
  • URL 格式: https://localhost:5008/images/examples/{fileName}
  • 依賴注入: 已在 ServiceCollectionExtensions.cs 注冊

系統優點

良好的抽象設計,便於替換實現 完整的接口定義,包含所有必要操作 已整合到圖片生成工作流程中

Phase 1: Cloudflare R2 環境準備

1.1 建立 R2 Bucket

  1. 登入 Cloudflare Dashboard

  2. 建立 R2 Bucket

    左側導航 → R2 Object Storage → Create bucket
    
    Bucket 名稱: dramaling-images
    區域: 建議選擇離用戶較近的區域 (如 Asia-Pacific)
    

1.2 設定 API 憑證

  1. 建立 R2 API Token

    R2 Dashboard → Manage R2 API tokens → Create API token
    
    Permission: Object Read & Write
    Bucket: dramaling-images
    TTL: 永不過期 (或根據需求設定)
    
  2. 記錄重要資訊

    Access Key ID: [記錄此值]
    Secret Access Key: [記錄此值]
    Account ID: [從 R2 Dashboard 右側取得]
    Bucket Name: dramaling-images
    Endpoint URL: https://[account-id].r2.cloudflarestorage.com
    

1.3 設定 CORS (跨域存取)

在 R2 Dashboard → dramaling-images → Settings → CORS policy:

[
  {
    "AllowedOrigins": [
      "http://localhost:3000",
      "http://localhost:5000",
      "https://你的前端域名.com"
    ],
    "AllowedMethods": ["GET", "HEAD"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": [],
    "MaxAgeSeconds": 86400
  }
]

1.4 設定 Public URL (可選)

如果需要 CDN 加速:

R2 Dashboard → dramaling-images → Settings → Public URL
Connect Custom Domain: images.dramaling.com (需要你有 Cloudflare 管理的域名)

Phase 2: .NET 專案設定

2.1 安裝 NuGet 套件

DramaLing.Api.csproj 中添加:

<PackageReference Include="AWSSDK.S3" Version="3.7.307.25" />
<PackageReference Include="AWSSDK.Extensions.NETCore.Setup" Version="3.7.300" />

或使用 Package Manager Console:

dotnet add package AWSSDK.S3
dotnet add package AWSSDK.Extensions.NETCore.Setup

2.2 設定模型類別

建立 backend/DramaLing.Api/Models/Configuration/CloudflareR2Options.cs:

namespace DramaLing.Api.Models.Configuration;

public class CloudflareR2Options
{
    public const string SectionName = "CloudflareR2";

    public string AccessKeyId { get; set; } = string.Empty;
    public string SecretAccessKey { get; set; } = string.Empty;
    public string AccountId { get; set; } = string.Empty;
    public string BucketName { get; set; } = string.Empty;
    public string EndpointUrl { get; set; } = string.Empty;
    public string PublicUrlBase { get; set; } = string.Empty; // 用於 CDN URL
    public bool UsePublicUrl { get; set; } = false;
}

public class CloudflareR2OptionsValidator : IValidateOptions<CloudflareR2Options>
{
    public ValidateOptionsResult Validate(string name, CloudflareR2Options options)
    {
        var failures = new List<string>();

        if (string.IsNullOrEmpty(options.AccessKeyId))
            failures.Add("CloudflareR2:AccessKeyId is required");

        if (string.IsNullOrEmpty(options.SecretAccessKey))
            failures.Add("CloudflareR2:SecretAccessKey is required");

        if (string.IsNullOrEmpty(options.AccountId))
            failures.Add("CloudflareR2:AccountId is required");

        if (string.IsNullOrEmpty(options.BucketName))
            failures.Add("CloudflareR2:BucketName is required");

        if (string.IsNullOrEmpty(options.EndpointUrl))
            failures.Add("CloudflareR2:EndpointUrl is required");

        return failures.Count > 0
            ? ValidateOptionsResult.Fail(failures)
            : ValidateOptionsResult.Success;
    }
}

Phase 3: 實現 R2 儲存服務

3.1 建立 R2ImageStorageService

建立 backend/DramaLing.Api/Services/Media/Storage/R2ImageStorageService.cs:

using Amazon.S3;
using Amazon.S3.Model;
using DramaLing.Api.Models.Configuration;
using DramaLing.Api.Services.Storage;
using Microsoft.Extensions.Options;

namespace DramaLing.Api.Services.Media.Storage;

public class R2ImageStorageService : IImageStorageService
{
    private readonly AmazonS3Client _s3Client;
    private readonly CloudflareR2Options _options;
    private readonly ILogger<R2ImageStorageService> _logger;

    public R2ImageStorageService(
        IOptions<CloudflareR2Options> options,
        ILogger<R2ImageStorageService> logger)
    {
        _options = options.Value ?? throw new ArgumentNullException(nameof(options));
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));

        // 設定 S3 Client 連接 Cloudflare R2
        var config = new AmazonS3Config
        {
            ServiceURL = _options.EndpointUrl,
            ForcePathStyle = true, // R2 要求使用 Path Style
            UseHttp = false // 強制 HTTPS
        };

        _s3Client = new AmazonS3Client(_options.AccessKeyId, _options.SecretAccessKey, config);

        _logger.LogInformation("R2ImageStorageService initialized with bucket: {BucketName}",
            _options.BucketName);
    }

    public async Task<string> SaveImageAsync(Stream imageStream, string fileName)
    {
        try
        {
            var key = $"examples/{fileName}"; // R2 中的檔案路徑

            var request = new PutObjectRequest
            {
                BucketName = _options.BucketName,
                Key = key,
                InputStream = imageStream,
                ContentType = GetContentType(fileName),
                CannedACL = S3CannedACL.PublicRead // 設定為公開讀取
            };

            var response = await _s3Client.PutObjectAsync(request);

            if (response.HttpStatusCode == System.Net.HttpStatusCode.OK)
            {
                _logger.LogInformation("Image uploaded successfully to R2: {Key}", key);
                return key; // 回傳 R2 中的檔案路徑
            }

            throw new Exception($"Upload failed with status: {response.HttpStatusCode}");
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to save image to R2: {FileName}", fileName);
            throw;
        }
    }

    public Task<string> GetImageUrlAsync(string imagePath)
    {
        // 如果設定了 CDN 域名,使用公開 URL
        if (_options.UsePublicUrl && !string.IsNullOrEmpty(_options.PublicUrlBase))
        {
            var publicUrl = $"{_options.PublicUrlBase.TrimEnd('/')}/{imagePath.TrimStart('/')}";
            return Task.FromResult(publicUrl);
        }

        // 否則使用 R2 直接 URL
        var r2Url = $"{_options.EndpointUrl.TrimEnd('/')}/{_options.BucketName}/{imagePath.TrimStart('/')}";
        return Task.FromResult(r2Url);
    }

    public async Task<bool> DeleteImageAsync(string imagePath)
    {
        try
        {
            var request = new DeleteObjectRequest
            {
                BucketName = _options.BucketName,
                Key = imagePath
            };

            var response = await _s3Client.DeleteObjectAsync(request);

            _logger.LogInformation("Image deleted from R2: {Key}", imagePath);
            return response.HttpStatusCode == System.Net.HttpStatusCode.NoContent;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to delete image from R2: {ImagePath}", imagePath);
            return false;
        }
    }

    public async Task<bool> ImageExistsAsync(string imagePath)
    {
        try
        {
            var request = new GetObjectMetadataRequest
            {
                BucketName = _options.BucketName,
                Key = imagePath
            };

            await _s3Client.GetObjectMetadataAsync(request);
            return true;
        }
        catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
        {
            return false;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to check image existence in R2: {ImagePath}", imagePath);
            return false;
        }
    }

    public async Task<StorageInfo> GetStorageInfoAsync()
    {
        try
        {
            // 取得 bucket 資訊 (簡化版本R2 API 限制較多)
            var listRequest = new ListObjectsV2Request
            {
                BucketName = _options.BucketName,
                Prefix = "examples/",
                MaxKeys = 1000 // 限制查詢數量避免超時
            };

            var response = await _s3Client.ListObjectsV2Async(listRequest);

            var totalSize = response.S3Objects.Sum(obj => obj.Size);
            var fileCount = response.S3Objects.Count;

            return new StorageInfo
            {
                Provider = "Cloudflare R2",
                TotalSizeBytes = totalSize,
                FileCount = fileCount,
                Status = "Available"
            };
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to get R2 storage info");
            return new StorageInfo
            {
                Provider = "Cloudflare R2",
                Status = $"Error: {ex.Message}"
            };
        }
    }

    private static string GetContentType(string fileName)
    {
        var extension = Path.GetExtension(fileName).ToLowerInvariant();
        return extension switch
        {
            ".jpg" or ".jpeg" => "image/jpeg",
            ".png" => "image/png",
            ".gif" => "image/gif",
            ".webp" => "image/webp",
            _ => "application/octet-stream"
        };
    }

    public void Dispose()
    {
        _s3Client?.Dispose();
    }
}

Phase 4: 更新應用配置

4.1 更新 ServiceCollectionExtensions.cs

修改 AddBusinessServices 方法:

public static IServiceCollection AddBusinessServices(this IServiceCollection services, IConfiguration configuration)
{
    services.AddScoped<IAuthService, AuthService>();

    // 媒體服務
    services.AddScoped<IImageProcessingService, ImageProcessingService>();

    // 圖片儲存服務 - 根據設定選擇實現
    var useR2Storage = configuration.GetValue<bool>("CloudflareR2:Enabled", false);

    if (useR2Storage)
    {
        // 配置 Cloudflare R2 選項
        services.Configure<CloudflareR2Options>(configuration.GetSection(CloudflareR2Options.SectionName));
        services.AddSingleton<IValidateOptions<CloudflareR2Options>, CloudflareR2OptionsValidator>();

        // 注冊 R2 服務
        services.AddScoped<IImageStorageService, R2ImageStorageService>();

        // AWS SDK 設定 (R2 相容 S3 API)
        services.AddAWSService<IAmazonS3>();
    }
    else
    {
        // 使用本地儲存
        services.AddScoped<IImageStorageService, LocalImageStorageService>();
    }

    // 其他服務保持不變...
    return services;
}

4.2 更新 appsettings.json

{
  "CloudflareR2": {
    "Enabled": false,
    "AccessKeyId": "", // 從環境變數載入
    "SecretAccessKey": "", // 從環境變數載入
    "AccountId": "", // 從環境變數載入
    "BucketName": "dramaling-images",
    "EndpointUrl": "", // 會從 AccountId 計算
    "PublicUrlBase": "", // 如果有設定 CDN 域名
    "UsePublicUrl": false
  },
  "ImageStorage": {
    "Local": {
      "BasePath": "wwwroot/images/examples",
      "BaseUrl": "https://localhost:5008/images/examples"
    }
  }
}

4.3 生產環境配置 (appsettings.Production.json)

{
  "CloudflareR2": {
    "Enabled": true,
    "BucketName": "dramaling-images",
    "EndpointUrl": "https://YOUR_ACCOUNT_ID.r2.cloudflarestorage.com",
    "PublicUrlBase": "https://images.dramaling.com", // 如果設定了 CDN
    "UsePublicUrl": true
  }
}

Phase 5: 環境變數設定

5.1 開發環境 (.env 或 user secrets)

# Cloudflare R2 設定
CLOUDFLARE_R2_ACCESS_KEY_ID=你的AccessKeyId
CLOUDFLARE_R2_SECRET_ACCESS_KEY=你的SecretAccessKey
CLOUDFLARE_R2_ACCOUNT_ID=你的AccountId

5.2 生產環境 (Render 環境變數)

在 Render Dashboard 設定以下環境變數:

CLOUDFLARE_R2_ACCESS_KEY_ID=實際的AccessKeyId
CLOUDFLARE_R2_SECRET_ACCESS_KEY=實際的SecretAccessKey
CLOUDFLARE_R2_ACCOUNT_ID=實際的AccountId

5.3 配置載入邏輯

Program.cs 中添加環境變數覆蓋:

// 在 builder.Services.Configure<CloudflareR2Options> 之前添加
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string>
{
    ["CloudflareR2:AccessKeyId"] = Environment.GetEnvironmentVariable("CLOUDFLARE_R2_ACCESS_KEY_ID") ?? "",
    ["CloudflareR2:SecretAccessKey"] = Environment.GetEnvironmentVariable("CLOUDFLARE_R2_SECRET_ACCESS_KEY") ?? "",
    ["CloudflareR2:AccountId"] = Environment.GetEnvironmentVariable("CLOUDFLARE_R2_ACCOUNT_ID") ?? ""
});

// 動態計算 EndpointUrl
var accountId = Environment.GetEnvironmentVariable("CLOUDFLARE_R2_ACCOUNT_ID");
if (!string.IsNullOrEmpty(accountId))
{
    builder.Configuration["CloudflareR2:EndpointUrl"] = $"https://{accountId}.r2.cloudflarestorage.com";
}

Phase 6: 測試和部署

6.1 本地測試步驟

  1. 設定環境變數

    export CLOUDFLARE_R2_ACCESS_KEY_ID=你的AccessKeyId
    export CLOUDFLARE_R2_SECRET_ACCESS_KEY=你的SecretAccessKey
    export CLOUDFLARE_R2_ACCOUNT_ID=你的AccountId
    
  2. 修改 appsettings.Development.json

    {
      "CloudflareR2": {
        "Enabled": true
      }
    }
    
  3. 測試圖片生成功能

    • 前往 AI 生成頁面
    • 分析句子並生成例句圖
    • 檢查圖片是否正確上傳到 R2
    • 檢查圖片 URL 是否可正常存取

6.2 驗證清單

  • R2 Bucket 中出現新圖片
  • 圖片 URL 可在瀏覽器中正常開啟
  • 前端可正確顯示 R2 圖片
  • 圖片刪除功能正常
  • 錯誤處理和日誌記錄正常

6.3 回滾計劃

如果需要回滾到本地儲存:

  1. 修改設定

    {
      "CloudflareR2": {
        "Enabled": false
      }
    }
    
  2. 重啟應用

    • 系統自動切換回 LocalImageStorageService

Phase 7: 生產環境部署

7.1 Render 部署設定

  1. 設定環境變數

    • 在 Render Dashboard 設定上述的環境變數
  2. 更新生產配置

    {
      "CloudflareR2": {
        "Enabled": true
      }
    }
    
  3. 重新部署應用

7.2 CDN 設定 (可選)

如果需要 CDN 加速:

  1. 設定 Custom Domain

    Cloudflare Dashboard → 你的域名 → DNS → Add record:
    Type: CNAME
    Name: images
    Content: YOUR_ACCOUNT_ID.r2.cloudflarestorage.com
    
  2. 更新應用設定

    {
      "CloudflareR2": {
        "PublicUrlBase": "https://images.yourdomain.com",
        "UsePublicUrl": true
      }
    }
    

成本效益分析

Cloudflare R2 優勢

  • 成本效益: 無 egress 費用
  • 效能: CDN 全球加速
  • 可靠性: 99.999999999% 耐久性
  • 擴展性: 無限容量
  • 相容性: S3 API 相容

預期成本 (以1000張圖片為例)

  • 儲存費用: ~$0.015/GB/月
  • 操作費用: $4.50/百萬次請求
  • CDN: 免費 (Cloudflare 域名)

注意事項

  1. 圖片命名: 保持現有的檔案命名邏輯
  2. 錯誤處理: 網路問題時的重試機制
  3. 快取: 考慮前端圖片快取策略
  4. 安全性: API 金鑰務必使用環境變數
  5. 監控: 設定 R2 使用量監控

實施時間表

  • Phase 1-2: 1-2 小時 (環境準備)
  • Phase 3: 2-3 小時 (代碼實現)
  • Phase 4-5: 1 小時 (設定和測試)
  • Phase 6-7: 1 小時 (部署和驗證)

總計: 約 5-7 小時完成完整遷移

檔案清單

新增檔案

  • Models/Configuration/CloudflareR2Options.cs
  • Services/Media/Storage/R2ImageStorageService.cs

修改檔案

  • Extensions/ServiceCollectionExtensions.cs
  • appsettings.json
  • appsettings.Production.json
  • DramaLing.Api.csproj

環境設定

  • Cloudflare R2 Dashboard 設定
  • Render 環境變數設定