17 KiB
17 KiB
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
-
登入 Cloudflare Dashboard
- 前往 https://dash.cloudflare.com/
- 選擇你的帳戶
-
建立 R2 Bucket
左側導航 → R2 Object Storage → Create bucket Bucket 名稱: dramaling-images 區域: 建議選擇離用戶較近的區域 (如 Asia-Pacific)
1.2 設定 API 憑證
-
建立 R2 API Token
R2 Dashboard → Manage R2 API tokens → Create API token Permission: Object Read & Write Bucket: dramaling-images TTL: 永不過期 (或根據需求設定) -
記錄重要資訊
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 本地測試步驟
-
設定環境變數
export CLOUDFLARE_R2_ACCESS_KEY_ID=你的AccessKeyId export CLOUDFLARE_R2_SECRET_ACCESS_KEY=你的SecretAccessKey export CLOUDFLARE_R2_ACCOUNT_ID=你的AccountId -
修改 appsettings.Development.json
{ "CloudflareR2": { "Enabled": true } } -
測試圖片生成功能
- 前往 AI 生成頁面
- 分析句子並生成例句圖
- 檢查圖片是否正確上傳到 R2
- 檢查圖片 URL 是否可正常存取
6.2 驗證清單
- R2 Bucket 中出現新圖片
- 圖片 URL 可在瀏覽器中正常開啟
- 前端可正確顯示 R2 圖片
- 圖片刪除功能正常
- 錯誤處理和日誌記錄正常
6.3 回滾計劃
如果需要回滾到本地儲存:
-
修改設定
{ "CloudflareR2": { "Enabled": false } } -
重啟應用
- 系統自動切換回 LocalImageStorageService
Phase 7: 生產環境部署
7.1 Render 部署設定
-
設定環境變數
- 在 Render Dashboard 設定上述的環境變數
-
更新生產配置
{ "CloudflareR2": { "Enabled": true } } -
重新部署應用
7.2 CDN 設定 (可選)
如果需要 CDN 加速:
-
設定 Custom Domain
Cloudflare Dashboard → 你的域名 → DNS → Add record: Type: CNAME Name: images Content: YOUR_ACCOUNT_ID.r2.cloudflarestorage.com -
更新應用設定
{ "CloudflareR2": { "PublicUrlBase": "https://images.yourdomain.com", "UsePublicUrl": true } }
成本效益分析
Cloudflare R2 優勢
- 成本效益: 無 egress 費用
- 效能: CDN 全球加速
- 可靠性: 99.999999999% 耐久性
- 擴展性: 無限容量
- 相容性: S3 API 相容
預期成本 (以1000張圖片為例)
- 儲存費用: ~$0.015/GB/月
- 操作費用: $4.50/百萬次請求
- CDN: 免費 (Cloudflare 域名)
注意事項
- 圖片命名: 保持現有的檔案命名邏輯
- 錯誤處理: 網路問題時的重試機制
- 快取: 考慮前端圖片快取策略
- 安全性: API 金鑰務必使用環境變數
- 監控: 設定 R2 使用量監控
實施時間表
- Phase 1-2: 1-2 小時 (環境準備)
- Phase 3: 2-3 小時 (代碼實現)
- Phase 4-5: 1 小時 (設定和測試)
- Phase 6-7: 1 小時 (部署和驗證)
總計: 約 5-7 小時完成完整遷移
檔案清單
新增檔案
Models/Configuration/CloudflareR2Options.csServices/Media/Storage/R2ImageStorageService.cs
修改檔案
Extensions/ServiceCollectionExtensions.csappsettings.jsonappsettings.Production.jsonDramaLing.Api.csproj
環境設定
- Cloudflare R2 Dashboard 設定
- Render 環境變數設定