多租戶與並行控制
DomainKit 提供以 AsyncLocal 傳遞租戶脈絡的 AmbientCurrentTenant、EF Core 租戶 convention、寫入時的租戶填值攔截器,以及 provider 中立的 RowVersion 設定。
租戶脈絡
ICurrentTenant 暴露目前的 TenantId,並以 Change 暫時切換租戶。AmbientCurrentTenant 支援巢狀 scope,釋放 scope 後會還原上一層租戶。
services.AddSingleton<ICurrentTenant, AmbientCurrentTenant>();
using IDisposable scope = currentTenant.Change(tenantId);
await applicationService.ExecuteAsync(ct).ConfigureAwait(false);
AmbientCurrentTenant 應共用同一個服務執行個體,才能讓 Middleware、DbContext 與背景工作看到相同的非同步脈絡。
DbContext 契約
啟用租戶篩選的 DbContext 實作 ITenantDbContext,將目前租戶提供給 EF Core query filter。
public sealed class AppDbContext : DbContext, ITenantDbContext {
private readonly ICurrentTenant currentTenant;
public AppDbContext(
DbContextOptions<AppDbContext> options,
ICurrentTenant currentTenant
) : base(options) {
this.currentTenant = currentTenant;
}
public Guid? CurrentTenantId => currentTenant.TenantId;
protected override void OnModelCreating(ModelBuilder modelBuilder) {
modelBuilder.ApplyModuleConfigurations();
modelBuilder.ApplyTenantConventions(
this,
typeof(GlobalPermission),
typeof(OutboxMessage),
typeof(ProcessedEvent)
);
}
}
ApplyTenantConventions 對所有具主鍵、非 owned、非衍生且未排除的實體執行下列設定:
- 加入必要的
TenantIdshadow property。 - 加入目前租戶的全域查詢篩選。
- 將
TenantId放到既有索引的第一欄。 - 保留先前設定的軟刪除 query filter,並與租戶條件合併。
此 convention 不以 IMustHaveTenant 判定是否套用。IMustHaveTenant 用來表達領域模型的租戶語意;不屬於租戶資料的型別必須透過 excludedEntityTypes 明確排除。啟用 Outbox 時應排除 OutboxMessage 與 ProcessedEvent,讓 dispatcher 能跨租戶掃描並依訊息內容還原租戶。
租戶範圍業務主鍵
同一個業務鍵可在不同租戶重複時,先使用 HasTenantPrimaryKey 標記業務鍵,再套用租戶 convention。
modelBuilder.HasTenantPrimaryKey<NumberSequence>(nameof(NumberSequence.Key));
modelBuilder.ApplyTenantConventions(this);
最後的主鍵會由 TenantId 與指定的業務鍵屬性組成。
寫入租戶欄位
TenantStampInterceptor 會在新增資料前,把目前租戶寫入具有 TenantId shadow property 的實體。只要本次提交包含租戶實體,缺少租戶脈絡就會拋出 InvalidOperationException。
services.AddScoped<TenantStampInterceptor>();
services.AddDbContextFactory<AppDbContext>(
(serviceProvider, options) => options
.UseSqlServer(connectionString)
.AddInterceptors(serviceProvider.GetRequiredService<TenantStampInterceptor>()),
ServiceLifetime.Scoped
);
AddDomainKit<AppDbContext> 會另外加入審計、事件與 RowVersion 攔截器,不會自動註冊 ICurrentTenant 或 TenantStampInterceptor。
背景工作與跨租戶處理
背景工作必須在建立 DbContext 或呼叫應用服務前切換租戶,並在每筆租戶工作完成後釋放 scope。
foreach (Guid tenantId in tenantIds) {
using IDisposable scope = currentTenant.Change(tenantId);
await processor.ProcessAsync(tenantId, ct).ConfigureAwait(false);
}
Outbox 會保存事件產生時的 TenantId,派發前再還原租戶脈絡。詳見事件、Outbox 與廣播。
RowVersion
需要樂觀鎖的實體實作 IHasRowVersion,並在 model 設定呼叫 ConfigureRowVersion。
public sealed class Customer : AggregateRootBase, IHasRowVersion {
public byte[] RowVersion { get; private set; } = [];
}
modelBuilder.Entity<Customer>(builder => {
builder.ConfigureFullAuditedEntity();
builder.ConfigureRowVersion();
});
預設設定為 provider 中立的 concurrency token。RowVersionStampInterceptor 會在新增與修改時產生新的 token。
SQL Server 應在 provider 專屬模型設定中覆寫為原生 rowversion:
modelBuilder.Entity<Customer>()
.Property(customer => customer.RowVersion)
.IsRowVersion();
EF Core 將屬性標成 ValueGenerated.OnAddOrUpdate 後,DomainKit 攔截器會略過應用程式填值,由 SQL Server 產生版本值。其他 provider 可維持 app-managed token,或由宿主提供相應的 provider 設定。
並行衝突
IUnitOfWork.SaveChangesAsync 會把 DbUpdateConcurrencyException 轉成 ConcurrencyConflictException。應用層可依操作語意選擇重新載入、提示使用者重新整理,或放棄目前工作單元。DomainKit 不自動重試業務寫入。
查詢篩選注意事項
一般查詢會同時套用租戶與軟刪除篩選。QueryService.IgnoreSoftDeletedFilter = true 目前透過 EF Core IgnoreQueryFilters() 實作,因此會略過該實體的所有全域 query filter,包含租戶篩選。這個開關只能用於受控的系統管理流程,且查詢必須自行加入明確的租戶條件。