## [.NET 筆記 021] FusionCache:L1+L2 混合快取、Fail-safe、Backplane ###### 📅 2026-05-06 --- ### 📌 介紹-1. 為什麼需要 FusionCache? [上一篇 (020)](https://hackmd.io/@dada00321/S1wWC4v0bx) 用 `IDistributedCache` 把資料存進 Redis,每次存取都要經過網路,速度還是有限制 FusionCache 在 `IDistributedCache` 之上多了一層 **L1 Memory 快取**,讓最熱門的資料直接在記憶體裡拿,不需要每次都連 Redis ``` IDistributedCache (020) : 每次請求 → Redis (網路, ~1ms) FusionCache (021) : 每次請求 → L1 Memory (本機記憶體, ~0.01ms) → L2 Redis (網路, ~1ms,L1 沒有才查) → 資料庫 (最慢,L2 也沒有才查) ``` --- ### 📌 介紹-2. FusionCache 四大核心功能 #### 1. Hybrid Cache (L1 + L2) 同時管理 L1 (Memory) 和 L2 (Redis),自動決定從哪一層取資料 #### 2. Fail-safe (失效保護) 資料庫或 Redis 掛掉時,FusionCache 不直接回錯誤,而是把**過期的舊資料先拿來用**,讓服務維持可用狀態 ``` 正常情況: 快取過期 → 查資料庫 → 更新快取 → 回傳新資料 Fail-safe 啟動 (資料庫掛掉) : 快取過期 → 查資料庫 → 無回應 → 拿過期的舊資料先回傳 (服務還活著,只是資料稍舊) ``` #### 3. Backplane (多節點同步) 有多台伺服器時,某一台更新了 L1 快取,其他台不會知道,資料會不一致 Backplane 透過 Redis 的 Pub/Sub 機制,讓所有節點的 L1 快取自動同步失效 ``` 節點 A 更新資料 → 通知 Backplane (Redis Pub/Sub) → 節點 B, C 的 L1 快取自動失效 → 下次讀取時重新載入 ``` #### 4. Cache Stampede 保護 同一瞬間大量請求同時發現快取失效,全部都去打資料庫,造成資料庫過載 FusionCache 會讓只有一個請求去查資料庫,其他請求等待結果,避免雪崩 --- ### 📌 介紹-3. FusionCache vs IDistributedCache vs 進階範例專案 | | IDistributedCache (020) | FusionCache (021) | 進階範例專案 | |:--|:----------------------|:-----------------|:-----------| | L1 Memory | ❌ | ✅ | ✅ | | L2 Redis | ✅ | ✅ | ✅ | | Fail-safe | ❌ | ✅ | ✅ | | Backplane | ❌ | ✅ | ✅ | | Cache Profiles | ❌ | ✅ (自訂) | ✅ (Static, Default, Realtime, Volatile, and List) | | Tags (批次失效) | ❌ | ✅ | ✅ | > 💡 進階範例專案直接使用 FusionCache,020 的 `IDistributedCache` 是作為理解底層的前置知識 --- ### 📌 介紹-4. 進階範例專案的 Cache Profiles 進階範例專案定義了 5 種快取設定檔,根據資料的變動頻率選不同的設定: | Profile | 適用資料 | L1 時間 | L2 時間 | Fail-safe | |:--------|:--------|:--------|:--------|:----------| | `Static` | 系統設定、分類 | 10 分鐘 | 30 分鐘 | ✅ 4 小時 | | `Default` | 商品、會員資料 | 1 分鐘 | 5 分鐘 | ✅ 2 小時 | | `Realtime` | 餘額、庫存 | 30 秒 | 1 分鐘 | ✅ 30 分鐘 | | `Volatile` | OTP, 驗證碼 | 15 秒 | 30 秒 | ❌ 不使用 | | `List` | 分頁查詢結果 | 30 秒 | 2 分鐘 | ✅ 30 分鐘 | > 💡 `Volatile` 不使用 Fail-safe,確保過期就是真的過期,不會拿舊的驗證碼來用 --- ### 📌 套件版本 | 套件 | 版本 | 說明 | |:----|:----|:----| | `ZiggyCreatures.FusionCache` | 2.6.0 | FusionCache 核心 | | `ZiggyCreatures.FusionCache.Backplane.StackExchangeRedis` | 2.6.0 | 用 Redis 做 Backplane | | `ZiggyCreatures.FusionCache.Serialization.SystemTextJson` | 2.5.0 | 序列化,把物件存入 Redis | | `StackExchange.Redis` | 已安裝 2.12.14 | Redis 連線驅動,不需重複安裝 | | `Microsoft.Extensions.Caching.StackExchangeRedis` | 已安裝 8.0.22 | L2 快取後端,不需重複安裝 | --- ### 📌 Step-0. 開啟專案 繼上一篇 [[.NET 筆記 020]](https://hackmd.io/@dada00321/S1wWC4v0bx) ➡️ 專案: API_test_260504 --- ### 📌 Step-1. 安裝套件 鑒於範例專案指定 .NET 版本為 8.0,在終端機依序執行: ``` dotnet add package ZiggyCreatures.FusionCache --version 2.6.0 dotnet add package ZiggyCreatures.FusionCache.Backplane.StackExchangeRedis --version 2.6.0 dotnet add package ZiggyCreatures.FusionCache.Serialization.SystemTextJson --version 2.5.0 ``` > ℹ️ 安裝完後可在終端機: `dotnet list package` (或: 相依性 > 套件) 確認已經安裝 --- ### 📌 Step-2. 更新 CacheSettings 打開 `Settings/CacheSettings.cs`,補上 FusionCache 需要的設定欄位 (仿照進階範例專案) : ```csharp namespace API_test_260504.Settings { public class CacheSettings { public const string SectionName = "CacheSettings"; /// <summary>Redis Key 前綴,避免不同專案的 Key 衝突</summary> public string KeyPrefix { get; set; } = "api_test"; // ── L1 Memory 快取 ───────────────────────────────────── /// <summary>L1 預設快取時間 (分鐘)</summary> public int MemoryCacheDurationMinutes { get; set; } = 1; // ── L2 Redis 快取 ────────────────────────────────────── /// <summary>L2 預設快取時間 (分鐘),通常比 L1 長</summary> public int DistributedCacheDurationMinutes { get; set; } = 5; // ── Fail-safe ────────────────────────────────────────── /// <summary>Fail-safe 最長可使用舊資料的時間 (小時)</summary> public double FailSafeMaxDurationHours { get; set; } = 2; /// <summary>Fail-safe 觸發後的節流時間 (秒),避免頻繁觸發</summary> public int FailSafeThrottleDurationSeconds { get; set; } = 30; // ── 斷路器 ──────────────────────────────────────────── /// <summary>Redis 掛掉時暫停嘗試連線的時間 (秒)</summary> public int DistributedCacheCircuitBreakerDurationSeconds { get; set; } = 10; // ── 向下相容 (020 筆記的欄位保留) ──────────────────── /// <summary>020 筆記用的欄位,FusionCache 改用 KeyPrefix</summary> public string InstanceName { get; set; } = "api_test:"; /// <summary>預設絕對過期時間 (分鐘),020 向下相容</summary> public int DefaultAbsoluteExpirationMinutes { get; set; } = 5; /// <summary>預設滑動過期時間 (分鐘),020 向下相容</summary> public int DefaultSlidingExpirationMinutes { get; set; } = 2; } } ``` --- ### 📌 Step-3. 更新 appsettings.json 在 `appsettings.json` 的 `CacheSettings` 補上新欄位: ```json "CacheSettings": { "KeyPrefix": "api_test", "MemoryCacheDurationMinutes": 1, "DistributedCacheDurationMinutes": 5, "FailSafeMaxDurationHours": 2, "FailSafeThrottleDurationSeconds": 30, "DistributedCacheCircuitBreakerDurationSeconds": 10, "InstanceName": "api_test:", "DefaultAbsoluteExpirationMinutes": 5, "DefaultSlidingExpirationMinutes": 2 } ``` --- ### 📌 Step-4. 建立 Cache Profiles 新增 `Caching/CacheProfiles.cs` (仿照進階範例專案的五種設定檔) : ```csharp using ZiggyCreatures.Caching.Fusion; namespace API_test_260504.Caching { /// <summary> /// 不同資料類型的快取設定檔 /// 根據資料的變動頻率選擇不同的設定 /// </summary> public static class CacheProfiles { /// <summary>靜態資料 — 變動極少 (系統設定、分類)</summary> public static FusionCacheEntryOptions Static => new() { Duration = TimeSpan.FromMinutes(10), DistributedCacheDuration = TimeSpan.FromMinutes(30), IsFailSafeEnabled = true, FailSafeMaxDuration = TimeSpan.FromHours(4), FailSafeThrottleDuration = TimeSpan.FromMinutes(1) }; /// <summary>一般資料 — 中等變動頻率 (商品資訊、裝置列表)</summary> public static FusionCacheEntryOptions Default => new() { Duration = TimeSpan.FromMinutes(1), DistributedCacheDuration = TimeSpan.FromMinutes(5), IsFailSafeEnabled = true, FailSafeMaxDuration = TimeSpan.FromHours(2), FailSafeThrottleDuration = TimeSpan.FromSeconds(30), FactorySoftTimeout = TimeSpan.FromMilliseconds(500), FactoryHardTimeout = TimeSpan.FromSeconds(5), AllowTimedOutFactoryBackgroundCompletion = true }; /// <summary>即時資料 — 高變動頻率 (庫存、狀態)</summary> public static FusionCacheEntryOptions Realtime => new() { Duration = TimeSpan.FromSeconds(30), DistributedCacheDuration = TimeSpan.FromMinutes(1), IsFailSafeEnabled = true, FailSafeMaxDuration = TimeSpan.FromMinutes(30), FailSafeThrottleDuration = TimeSpan.FromSeconds(10) }; /// <summary>短暫資料 — 極高變動或敏感資料 (OTP, 驗證碼)</summary> public static FusionCacheEntryOptions Volatile => new() { Duration = TimeSpan.FromSeconds(15), DistributedCacheDuration = TimeSpan.FromSeconds(30), IsFailSafeEnabled = false // 敏感資料不使用 Fail-safe }; /// <summary>列表資料 — 分頁查詢結果</summary> public static FusionCacheEntryOptions List => new() { Duration = TimeSpan.FromSeconds(30), DistributedCacheDuration = TimeSpan.FromMinutes(2), IsFailSafeEnabled = true, FailSafeMaxDuration = TimeSpan.FromMinutes(30), FailSafeThrottleDuration = TimeSpan.FromSeconds(15) }; } /// <summary>快取資料類型 Enum,對應 CacheProfiles</summary> public enum CacheDataType { Static, Default, Realtime, Volatile, List } } ``` --- ### 📌 Step-5. 更新 ICacheService 介面 打開 `Caching/ICacheService.cs`,改成和進階範例專案一致,加入 `CacheDataType`, `tags`, and `CancellationToken`: ```csharp namespace API_test_260504.Caching { public interface ICacheService { /// <summary>取得快取,沒有就執行 factory 查詢並存入快取</summary> Task<T?> GetOrSetAsync<T>( string key, Func<CancellationToken, Task<T?>> factory, CacheDataType dataType = CacheDataType.Default, string[]? tags = null, CancellationToken cancellationToken = default); /// <summary>直接取得快取</summary> Task<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default); /// <summary>直接設定快取</summary> Task SetAsync<T>( string key, T value, CacheDataType dataType = CacheDataType.Default, string[]? tags = null, CancellationToken cancellationToken = default); /// <summary>刪除快取</summary> Task RemoveAsync(string key, CancellationToken cancellationToken = default); /// <summary>標記快取為過期 (Fail-safe 仍可使用舊資料)</summary> Task ExpireAsync(string key, CancellationToken cancellationToken = default); /// <summary>根據 Tags 批次讓快取失效</summary> Task RemoveByTagsAsync(string[] tags, CancellationToken cancellationToken = default); } } ``` --- ### 📌 Step-6. 建立 FusionCacheService 新增 `Caching/FusionCacheService.cs` (取代 020 的 `DistributedCacheService`) : ```csharp using API_test_260504.Abstractions; using API_test_260504.Settings; using Microsoft.Extensions.Options; using ZiggyCreatures.Caching.Fusion; namespace API_test_260504.Caching { /// <summary> /// FusionCache 實作 /// 封裝 FusionCache 並提供統一的快取操作介面 /// 仿照進階範例專案的 FusionCacheService 架構 /// </summary> public sealed class FusionCacheService : ICacheService, IScopedService { private readonly IFusionCache _cache; private readonly CacheSettings _settings; private readonly ILogger<FusionCacheService> _logger; public FusionCacheService( IFusionCache cache, IOptions<CacheSettings> settings, ILogger<FusionCacheService> logger) { _cache = cache; _settings = settings.Value; _logger = logger; } public async Task<T?> GetOrSetAsync<T>( string key, Func<CancellationToken, Task<T?>> factory, CacheDataType dataType = CacheDataType.Default, string[]? tags = null, CancellationToken cancellationToken = default) { var fullKey = BuildKey(key); var options = GetOptions(dataType); try { return await _cache.GetOrSetAsync<T?>( key: fullKey, factory: async (ctx, ct) => { _logger.LogInformation( "Cache miss: {Key}, HasStaleValue: {HasStale}", fullKey, ctx.HasStaleValue); return await factory(ct); }, options: options, tags: tags, token: cancellationToken); } catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogError(ex, "Cache GetOrSet error for key {Key}", fullKey); // 快取完全失效時,直接執行 factory 確保服務不中斷 return await factory(cancellationToken); } } public async Task<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default) { var fullKey = BuildKey(key); try { var result = await _cache.TryGetAsync<T>(fullKey, token: cancellationToken); if (result.HasValue) _logger.LogInformation("Cache hit: {Key}", fullKey); return result.HasValue ? result.Value : default; } catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogWarning(ex, "Cache get error for key {Key}", fullKey); return default; } } public async Task SetAsync<T>( string key, T value, CacheDataType dataType = CacheDataType.Default, string[]? tags = null, CancellationToken cancellationToken = default) { var fullKey = BuildKey(key); var options = GetOptions(dataType); try { await _cache.SetAsync( key: fullKey, value: value, options: options, tags: tags, token: cancellationToken); _logger.LogInformation("Cache set: {Key}, Profile: {Profile}", fullKey, dataType); } catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogWarning(ex, "Cache set error for key {Key}", fullKey); } } public async Task RemoveAsync(string key, CancellationToken cancellationToken = default) { var fullKey = BuildKey(key); try { await _cache.RemoveAsync(fullKey, token: cancellationToken); _logger.LogInformation("Cache removed: {Key}", fullKey); } catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogWarning(ex, "Cache remove error for key {Key}", fullKey); } } public async Task ExpireAsync(string key, CancellationToken cancellationToken = default) { var fullKey = BuildKey(key); try { // Expire 標記為過期,但 Fail-safe 仍可使用舊資料 // Remove 則是完全刪除,Fail-safe 也無法使用 await _cache.ExpireAsync(fullKey, token: cancellationToken); _logger.LogInformation("Cache expired: {Key}", fullKey); } catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogWarning(ex, "Cache expire error for key {Key}", fullKey); } } public async Task RemoveByTagsAsync(string[] tags, CancellationToken cancellationToken = default) { try { await _cache.RemoveByTagAsync(tags, token: cancellationToken); _logger.LogInformation("Cache removed by tags: {Tags}", string.Join(", ", tags)); } catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogWarning(ex, "Cache remove by tags error: {Tags}", string.Join(", ", tags)); } } // 仿照進階範例專案:前綴由 Service 層統一管理 private string BuildKey(string key) => string.IsNullOrWhiteSpace(_settings.KeyPrefix) ? key : $"{_settings.KeyPrefix}:{key}"; private static FusionCacheEntryOptions GetOptions(CacheDataType dataType) => dataType switch { CacheDataType.Static => CacheProfiles.Static, CacheDataType.Realtime => CacheProfiles.Realtime, CacheDataType.Volatile => CacheProfiles.Volatile, CacheDataType.List => CacheProfiles.List, _ => CacheProfiles.Default }; } } ``` --- ### 📌 Step-7. 建立 FusionCacheExtensions 新增 `Caching/FusionCacheExtensions.cs`,把 FusionCache 的 DI 註冊邏輯包成擴充方法 (仿照進階範例專案) : ```csharp using API_test_260504.Settings; using Microsoft.Extensions.Caching.StackExchangeRedis; using StackExchange.Redis; using ZiggyCreatures.Caching.Fusion; using ZiggyCreatures.Caching.Fusion.Backplane.StackExchangeRedis; using ZiggyCreatures.Caching.Fusion.Serialization.SystemTextJson; namespace API_test_260504.Caching { public static class FusionCacheExtensions { /// <summary> /// 註冊 FusionCache 服務 /// 包含 L1 (Memory) + L2 (Redis) + Backplane /// </summary> public static IServiceCollection AddFusionCacheServices( this IServiceCollection services, IConfiguration configuration) { var cacheSettings = configuration .GetSection(CacheSettings.SectionName) .Get<CacheSettings>() ?? new CacheSettings(); // 1. 綁定 CacheSettings services.Configure<CacheSettings>( configuration.GetSection(CacheSettings.SectionName)); // 2. 建立 Redis 連線 (Singleton,全程共用一個連線) services.AddSingleton<IConnectionMultiplexer>(sp => { var logger = sp.GetRequiredService<ILogger<IConnectionMultiplexer>>(); var redisConn = configuration.GetConnectionString("Redis") ?? "localhost:6379"; var options = ConfigurationOptions.Parse(redisConn); options.AbortOnConnectFail = false; // 連線失敗不立即中止,讓 Fail-safe 接管 options.ConnectRetry = 3; options.ConnectTimeout = 5000; var multiplexer = ConnectionMultiplexer.Connect(options); // 訂閱連線事件,記錄 Log multiplexer.ConnectionFailed += (_, args) => logger.LogWarning("Redis connection failed: {EndPoint}, {FailureType}", args.EndPoint, args.FailureType); multiplexer.ConnectionRestored += (_, args) => logger.LogInformation("Redis connection restored: {EndPoint}", args.EndPoint); return multiplexer; }); // 3. 註冊 FusionCache (L1 + L2 + Backplane) services.AddFusionCache() .WithDefaultEntryOptions(options => { // 預設使用 Default Profile 的設定 options.Duration = TimeSpan.FromMinutes(cacheSettings.MemoryCacheDurationMinutes); options.DistributedCacheDuration = TimeSpan.FromMinutes(cacheSettings.DistributedCacheDurationMinutes); options.IsFailSafeEnabled = true; options.FailSafeMaxDuration = TimeSpan.FromHours(cacheSettings.FailSafeMaxDurationHours); options.FailSafeThrottleDuration = TimeSpan.FromSeconds(cacheSettings.FailSafeThrottleDurationSeconds); }) // JSON 序列化,把物件存進 Redis .WithSerializer(new FusionCacheSystemTextJsonSerializer( new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true })) // L2:Redis 分散式快取 .WithDistributedCache(sp => { var multiplexer = sp.GetRequiredService<IConnectionMultiplexer>(); return new RedisCache(new RedisCacheOptions { ConnectionMultiplexerFactory = () => Task.FromResult(multiplexer) }); }) // Backplane:多節點 L1 同步 .WithBackplane(sp => { var multiplexer = sp.GetRequiredService<IConnectionMultiplexer>(); return new RedisBackplane(new RedisBackplaneOptions { ConnectionMultiplexerFactory = () => Task.FromResult(multiplexer) }); }); return services; } } } ``` --- ### 📌 Step-8. 更新 Program.cs 移除 020 的 `AddStackExchangeRedisCache`,改用 FusionCache: ```csharp using API_test_260504.Caching; // [.NET 筆記 021] FusionCache 取代 020 的 IDistributedCache 設定 // 020 的設定已被取代,改在 AddFusionCacheServices 裡統一處理 // [已移除] builder.Services.Configure<CacheSettings>(...); // [已移除] builder.Services.AddStackExchangeRedisCache(...); services.AddFusionCacheServices(builder.Configuration); ``` 完整的 Program.cs 修改段落位置 (放在 Scrutor 掃描之前) : ```csharp // [.NET 筆記 021] FusionCache:L1 + L2 (Redis) + Backplane builder.Services.AddFusionCacheServices(builder.Configuration); // Scrutor 自動掃描 builder.Services.Scan(scan => scan .FromAssemblyOf<Program>() .AddClasses(c => c.AssignableTo<IScopedService>()) .AsImplementedInterfaces() .AsSelf() .WithScopedLifetime()); ``` > 💡 `FusionCacheService` 掛上了 `IScopedService`,Scrutor 會自動登記,不需要額外寫 `AddScoped` --- ### 📌 Step-9. 更新 DeviceService `ICacheService` 的 `GetOrSetAsync` 簽名改了 (新增 `CancellationToken` 參數),需要更新呼叫方式: ```csharp public async Task<List<DeviceDto>> GetAllAsync() { return await _cache.GetOrSetAsync( key: AllDevicesCacheKey, factory: async ct => { _logger.LogInformation("Cache miss,查詢資料庫取得所有裝置"); using var conn = _db.CreateConnection(); var devices = await conn.QueryAsync<Device>("SELECT * FROM Devices"); return devices.Select(d => d.Adapt<DeviceDto>()).ToList(); }, dataType: CacheDataType.List) // 指定 Cache Profile ?? []; } public async Task<DeviceDto?> GetByIdAsync(int deviceId) { return await _cache.GetOrSetAsync( key: DeviceCacheKey(deviceId), factory: async ct => { using var conn = _db.CreateConnection(); var device = await conn.QueryFirstOrDefaultAsync<Device>( "SELECT * FROM Devices WHERE Id = @Id", new { Id = deviceId }); return device?.Adapt<DeviceDto>(); }, dataType: CacheDataType.Default); // 指定 Cache Profile } ``` 再修改 Log 對應的 CacheService 介面 (下面兩處) (1) 將 `private readonly ILogger<DistributedCacheService> _logger;` 改為 `private readonly ILogger<FusionCacheService> _logger;` (2) 將 `ILogger<DistributedCacheService> logger,` 改為 `ILogger<FusionCacheService> logger,` --- ### 📌 Step-10. 驗證結果 確認 Redis 已啟動,執行 `https`,用 Swagger 測試: - **第一次** `GET /api/Device` - ➡️ Result ![image](https://hackmd.io/_uploads/r1CEJt_Abl.png) - ➡️ Explanation 124 - **第二次 (1 分鐘內,L1 快取有效)** `GET /api/Device` - ➡️ Result ![image](https://hackmd.io/_uploads/B1fvJtdRWg.png) - ➡️ Explanation 資料從 L1 Memory 拿,不走網路,速度最快 - **第 3 次及以後 (超過 1 分鐘後)** `GET /api/Device` (L1 過期,L2 還有效) - ![image](https://hackmd.io/_uploads/B1X5ytORWe.png) - ➡️ Explanation FusionCache 自動去 L2 (Redis) 查,並重新暖起 L1,無需開發者手動處理 - **用 redis-cli 驗證 Key**: ➡️ Result - ![image](https://hackmd.io/_uploads/B1v7zKu0Ze.png) - 補充:[FusionCache Log 解讀:三次呼叫的完整流程](https://hackmd.io/@dada00321/ryABfYu0Wx) --- ### 📌 總結:020 vs 021 對比 | | IDistributedCache (020) | FusionCache (021) | |:--|:----------------------|:-----------------| | 快取層級 | 只有 L2 Redis | L1 Memory + L2 Redis | | Fail-safe | ❌ | ✅ | | Backplane | ❌ | ✅ | | Cache Profile | ❌ (只有固定過期時間) | ✅ (Static, Default, Realtime, Volatile, List) | | Tags 批次失效 | ❌ | ✅ | | 使用難度 | 較低 | 較高 | > 💡 020 的 `DistributedCacheService` 和 `ICacheService` 介面已被 `FusionCacheService` 取代,但架構概念相同 (都有 `BuildKey`, `GetOrSetAsync`, `RemoveAsync`) > 進階範例專案還有 `FusionCacheAlertingService` (告警服務) 和 Cache Tags 的進階應用,這些屬於進階用法,熟悉基本操作後可以參考進階範例專案的實作