Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 109 additions & 6 deletions docs/lookup-cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@ public class CityCode : BasePoco
### 2. 在任意 VM 或 Controller 內存取

```csharp
// 取全表
// 取全表(回傳 IReadOnlyList<T>,防止意外修改快取內容)
var cities = Wtm.GetLookup<CityCode>();

// 在記憶體中過濾(不觸發額外 DB 查詢)
// 在記憶體中過濾(Func<T, bool>,不觸發額外 DB 查詢)
var active = Wtm.GetLookup<CityCode>(x => x.IsActive && x.Province == "北部");

// 非同步版本
Expand All @@ -42,15 +42,95 @@ var active = await Wtm.GetLookupAsync<CityCode>(x => x.IsActive);

> **零設定**:快取失效已整合進 `FrameworkContext.SaveChanges()` 與 `SaveChangesAsync()`。只要透過 WTM 的 DC 寫入資料,快取即自動失效,無需任何 `Program.cs` 修改。

> **注意**:透過 `EmptyContext` 或原生 EF Core `DbContext` 直接寫入時,**不會**觸發自動失效。

> **IReadOnlyList 回傳型別**:`GetLookup` 和 `GetLookupAsync` 回傳 `IReadOnlyList<T>` 而非 `List<T>`,防止呼叫端意外修改快取中的物件。若需要可變集合,可 `.ToList()` 複製一份。

---

## Attribute 參數

| 參數 | 預設值 | 說明 |
|------|--------|------|
| `TtlMinutes` | `30` | 快取存活時間(分鐘) |
| `TenantIsolation` | `true` | 依 TenantCode 隔離快取鍵,避免租戶資料互串 |
| `TenantIsolation` | 未設定(使用全域預設) | 依 TenantCode 隔離快取鍵,避免租戶資料互串。未設定時使用 `LookupCacheOptions.DefaultTenantIsolation`(預設 `true`) |
| `WarmOnStartup` | `true` | 應用啟動後在後台預熱快取(不阻擋啟動) |
| `ConnectionKey` | `null` | 指定此 Model 所在的資料庫連線鍵(對應 appsettings.json 中 Connections 的 Key)。為 null 時使用預設 DC |

---

## 全域 TenantIsolation 預設值

單一租戶的應用不需要每個 Model 都設定 `TenantIsolation = false`。在 `Program.cs` 註冊全域預設值:

```csharp
// Program.cs
builder.Services.AddSingleton(new LookupCacheOptions
{
DefaultTenantIsolation = false // 單租戶應用:關閉租戶隔離
});

// 之後照常呼叫
builder.Services.AddWtmContext(builder.Configuration);
```

個別 Attribute 可覆蓋全域預設:

```csharp
// 即使全域預設 false,此 Model 仍啟用租戶隔離
[CacheLookup(TenantIsolation = true)]
public class TenantSpecificDict : BasePoco { ... }
```

---

## 多資料庫支援(ConnectionKey)

若應用有多個資料庫(例如主庫 + 唯讀 ORSS 庫),可透過 `ConnectionKey` 指定 Model 所在的連線:

```json
// appsettings.json
{
"Connections": [
{ "Key": "default", "Value": "...", "DbType": "SqlServer" },
{ "Key": "orss", "Value": "...", "DbType": "SqlServer" }
]
}
```

```csharp
[CacheLookup(TtlMinutes = 120, ConnectionKey = "orss")]
public class OrssProduct : BasePoco
{
public string ProductCode { get; set; } = null!;
public string ProductName { get; set; } = null!;
}

// 使用方式不變,框架會自動使用 orss 連線
var products = Wtm.GetLookup<OrssProduct>();
```

---

## 便利方法

### GetLookupItem — 查找單筆資料

```csharp
// 從快取中查找單筆,回傳 T? (找不到時為 null)
var city = Wtm.GetLookupItem<CityCode>(x => x.Name == "台北");
```

### GetLookupSelectList — 下拉選單整合

```csharp
// 從快取生成 ComboSelectListItem 下拉選項
var options = Wtm.GetLookupSelectList<CityCode>(
valueField: x => x.ID,
textField: x => x.Name,
filter: x => x.IsActive
);
```

---

Expand All @@ -73,13 +153,23 @@ wtm:lookup:{type.FullName}:{tenantId}
| 手動 | `ILookupCacheService.Invalidate<T>()` | 指定型別 + 租戶 |
| 手動(跨租戶) | `ILookupCacheService.InvalidateType(type)` | 指定型別全部租戶 |

> **注意**:`EmptyContext` 不繼承 `FrameworkContext`,因此透過 `EmptyContext` 寫入不會觸發自動失效。

### Startup Warm-up

標記 `WarmOnStartup = true`(預設)的型別會在應用啟動後 3 秒由 `LookupCacheWarmupService` 在後台預熱。

- 失敗只記 warning log,不阻擋啟動
- 僅預熱 main tenant(`TenantCode = null`),其他租戶在首次請求時自動暖機

### Stampede Protection

`IMemoryCache.GetOrCreate` / `GetOrCreateAsync` 並非原子操作 — 在 cache miss 時,多個並發請求可能各自執行一次 DB 查詢。這是安全的,因為每個呼叫者使用自己的 scoped `DbContext`,最壞情況是一次 burst 內多讀一次 DB。

### 過濾語義

`GetLookup<T>(predicate)` 的 `predicate` 參數是 `Func<T, bool>`,在記憶體中執行,不會轉換為 SQL。全表資料先從快取載入,再在記憶體中過濾。

---

## 多副本(多 Pod)部署說明
Expand Down Expand Up @@ -117,6 +207,8 @@ LookupCacheService(Singleton)
├── startup 掃描所有 Assembly,建立型別白名單
├── GetAll<T>(): IMemoryCache.GetOrCreate(防 stampede)
├── InvalidateType(): 取消 per-type CancellationTokenSource,批次清除所有租戶 key
├── GetAttribute(): 查詢型別的 CacheLookupAttribute
├── DefaultTenantIsolation: 全域預設值(從 LookupCacheOptions 取得)
└── GetWarmupTypes(): 供 LookupCacheWarmupService 使用

FrameworkContext(內建,無需設定)
Expand All @@ -128,21 +220,32 @@ LookupCacheWarmupService(BackgroundService)
└── ExecuteAsync: 3s 延遲後預熱 WarmOnStartup=true 的型別

WTMContext.GetLookup<T>()
└── DC as DbContext → ILookupCacheService.GetAll<T>
├── ConnectionKey → CreateDC(cskey) 或使用預設 DC
├── TenantIsolationOrNull → 全域預設 fallback
└── ILookupCacheService.GetAll<T> → IReadOnlyList<T>
```

---

## 常見問題

**Q: 貼了 `[CacheLookup]` 但快取沒有失效?**
A: 確認寫入是透過 WTM 的 `DC`(即 `FrameworkContext` 子類別)進行的。若使用原生 EF Core `DbContext` 直接寫入,則不會觸發自動失效。
A: 確認寫入是透過 WTM 的 `DC`(即 `FrameworkContext` 子類別)進行的。若使用原生 EF Core `DbContext` 或 `EmptyContext` 直接寫入,則不會觸發自動失效。

**Q: Warm-up 失敗日誌顯示「DbContext not available」?**
A: `IDataContext` 必須設定正確。確認 `AddWtmContext()` 已在 `services.AddDbContext()` 之後呼叫。

**Q: 不同租戶的 Model 要標記什麼?**
A: 保持 `TenantIsolation = true`(預設)。若該表的資料在所有租戶間完全相同(如幣別代碼),可設 `TenantIsolation = false` 減少重複快取。
A: 保持 `TenantIsolation = true`(或不設定,使用全域預設 `true`)。若該表的資料在所有租戶間完全相同(如幣別代碼),可設 `TenantIsolation = false` 減少重複快取。

**Q: 可以關掉 Warm-up 嗎?**
A: 在 Attribute 上設 `WarmOnStartup = false` 即可。

**Q: 單租戶應用每個 Model 都要設 `TenantIsolation = false`?**
A: 不用。註冊 `LookupCacheOptions { DefaultTenantIsolation = false }` 即可全域關閉,個別 Model 仍可覆蓋。

**Q: Model 在不同的資料庫(如 ORSS)怎麼辦?**
A: 在 Attribute 上設 `ConnectionKey = "orss"`(對應 appsettings.json 的 Connections Key),框架會自動使用該連線。

**Q: `GetLookup<T>(predicate)` 是 DB 查詢嗎?**
A: 不是。`predicate` 是 `Func<T, bool>`,在記憶體中對已快取的全表資料執行過濾。
32 changes: 27 additions & 5 deletions src/WalkingTec.Mvvm.Core/Cache/CacheLookupAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,25 +18,47 @@ namespace WalkingTec.Mvvm.Core.Cache
/// var active = Wtm.GetLookup&lt;CityCode&gt;(x =&gt; x.IsActive);
/// </code>
///
/// 失效時機:透過 <see cref="LookupInvalidationInterceptor"/> 在 SaveChanges 時自動失效。
/// 應用需在 DbContextOptions 加入該 Interceptor 才能觸發自動失效(見 docs/lookup-cache.md)
/// 失效時機:透過 <see cref="FrameworkContext.SaveChanges()"/> 在 SaveChanges 時自動失效。
/// 只要透過 WTM 的 DC(FrameworkContext 子類別)寫入資料,快取即自動失效
/// </remarks>
[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
public sealed class CacheLookupAttribute : Attribute
{
/// <summary>快取存活時間(分鐘),預設 30 分鐘</summary>
public int TtlMinutes { get; set; } = 30;

// Attribute 參數不支援 bool?,使用 sentinel 模式模擬三態
private const int Unset = -1;
private int _tenantIsolation = Unset;

/// <summary>
/// 是否依租戶隔離快取鍵。未設定時使用全域預設值(見 <see cref="LookupCacheOptions.DefaultTenantIsolation"/>)。
/// 明確設為 true/false 會覆蓋全域預設。
/// 實作 ITenant 的 Model 建議保持預設(或明確設為 true),避免租戶資料互串。
/// </summary>
public bool TenantIsolation
{
get => _tenantIsolation != Unset && _tenantIsolation != 0;
set => _tenantIsolation = value ? 1 : 0;
}

/// <summary>
/// 是否依租戶隔離快取鍵
/// 實作 ITenant 的 Model 建議保持預設值 true,避免租戶資料互串
/// 取得 TenantIsolation 的三態值:true、false 或 null(未設定,使用全域預設)
/// 此屬性供框架內部使用,不在 Attribute 語法中顯示
/// </summary>
public bool TenantIsolation { get; set; } = true;
internal bool? TenantIsolationOrNull =>
_tenantIsolation == Unset ? null : _tenantIsolation != 0;

/// <summary>
/// 是否在應用啟動時預熱快取(BackgroundService 背景執行,不阻擋啟動)。
/// 預設 true。
/// </summary>
public bool WarmOnStartup { get; set; } = true;

/// <summary>
/// 指定此 Model 所在的資料庫連線鍵(對應 appsettings.json 中 Connections 的 Key)。
/// 為 null 時使用預設的 Wtm.DC。
/// </summary>
public string? ConnectionKey { get; set; }
}
}
10 changes: 8 additions & 2 deletions src/WalkingTec.Mvvm.Core/Cache/ILookupCacheService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ public interface ILookupCacheService
/// <summary>
/// 取得指定型別的全表快取資料(cache miss 時自動從 DB 補充)。
/// </summary>
List<T> GetAll<T>(DbContext dc, string? tenantId = null) where T : TopBasePoco;
IReadOnlyList<T> GetAll<T>(DbContext dc, string? tenantId = null) where T : TopBasePoco;

/// <summary>
/// 非同步取得指定型別的全表快取資料(cache miss 時自動從 DB 補充)。
/// </summary>
Task<List<T>> GetAllAsync<T>(DbContext dc, string? tenantId = null, CancellationToken ct = default)
Task<IReadOnlyList<T>> GetAllAsync<T>(DbContext dc, string? tenantId = null, CancellationToken ct = default)
where T : TopBasePoco;

/// <summary>使指定型別、指定租戶的快取失效。</summary>
Expand All @@ -35,5 +35,11 @@ Task<List<T>> GetAllAsync<T>(DbContext dc, string? tenantId = null, Cancellation

/// <summary>取得所有標記 WarmOnStartup=true 的型別清單(供 warmup service 使用)。</summary>
IReadOnlyList<Type> GetWarmupTypes();

/// <summary>取得指定型別的 <see cref="CacheLookupAttribute"/>,未標記時回傳 null。</summary>
CacheLookupAttribute? GetAttribute(Type entityType);

/// <summary>全域預設 TenantIsolation 值。</summary>
bool DefaultTenantIsolation { get; }
}
}
12 changes: 12 additions & 0 deletions src/WalkingTec.Mvvm.Core/Cache/LookupCacheOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#nullable enable
namespace WalkingTec.Mvvm.Core.Cache
{
/// <summary>
/// Lookup Cache 全域設定。透過 DI 注入,個別 <see cref="CacheLookupAttribute"/> 可覆蓋。
/// </summary>
public class LookupCacheOptions
{
/// <summary>全域預設 TenantIsolation 值(預設 true)。個別 Attribute 可覆蓋。</summary>
public bool DefaultTenantIsolation { get; set; } = true;
}
}
13 changes: 10 additions & 3 deletions src/WalkingTec.Mvvm.Core/Cache/LookupCacheService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ namespace WalkingTec.Mvvm.Core.Cache
public class LookupCacheService : ILookupCacheService
{
private readonly IMemoryCache _cache;
private readonly LookupCacheOptions _options;

// per-type CTS,用於 InvalidateType(IMemoryCache 無 Clear 方法)
private readonly Dictionary<Type, CancellationTokenSource> _ctsByType = new();
Expand All @@ -29,15 +30,16 @@ public class LookupCacheService : ILookupCacheService
// 啟動時掃描結果
private readonly Dictionary<Type, CacheLookupAttribute> _registry;

public LookupCacheService(IMemoryCache cache, IEnumerable<Assembly> assemblies)
public LookupCacheService(IMemoryCache cache, IEnumerable<Assembly> assemblies, LookupCacheOptions? options = null)
{
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_options = options ?? new LookupCacheOptions();
_registry = ScanAssemblies(assemblies);
}

// ─── ILookupCacheService ──────────────────────────────────────────────

public List<T> GetAll<T>(DbContext dc, string? tenantId = null) where T : TopBasePoco
public IReadOnlyList<T> GetAll<T>(DbContext dc, string? tenantId = null) where T : TopBasePoco
{
var key = BuildKey(typeof(T), tenantId);
return _cache.GetOrCreate(key, entry =>
Expand All @@ -47,7 +49,7 @@ public List<T> GetAll<T>(DbContext dc, string? tenantId = null) where T : TopBas
}) ?? new List<T>();
}

public async Task<List<T>> GetAllAsync<T>(
public async Task<IReadOnlyList<T>> GetAllAsync<T>(
DbContext dc,
string? tenantId = null,
CancellationToken ct = default) where T : TopBasePoco
Expand Down Expand Up @@ -96,6 +98,11 @@ public IReadOnlyList<Type> GetWarmupTypes() =>
.Select(kv => kv.Key)
.ToList();

public CacheLookupAttribute? GetAttribute(Type entityType) =>
_registry.TryGetValue(entityType, out var attr) ? attr : null;

public bool DefaultTenantIsolation => _options.DefaultTenantIsolation;

// ─── 私有輔助 ─────────────────────────────────────────────────────────

private static string BuildKey(Type type, string? tenantId)
Expand Down
Loading