在 ASP.NET Core Singleton 生命週期服務引用 Scoped 物件
| | 5 | |
由 ASP.NET MVC 轉進 ASP.NET Core,感受到的最大差異就是「依賴注入無所不在!」,要使用服務元件,不靠靜態方法也不能自己用 new 建構。標準做法是在 Startup.ConfigureServices 用 AddScoped、AddSingleton 或 AddTransient 逐一註冊會用到的服務及元件,Razor Page、Controller、View 要使用這些服務,要在建構式加入該服務型別作為輸入參數,ASP.NET Core 便在建構時幫你準備好。
【延伸閱讀】
但這裡有個限制,ASP.NET Core DI 的服務元件生命週期依長短分為:Singleton (從頭到尾只存在一份共用) > Scoped (整個 Request 期間共用一份) > Transient (每次需要時就建一顆新的),而你不能在 Singleton 建構式引進註冊為 Scoped 的服務。
只需踩過幾次雷,開發人員很快便會牢記這點限制,必要時調降範圍配合;前一篇提到的 IHostedService,也會遇到類似問題。例如:如果我想在 DoWork 中存取 DB,勢必要引用 DbContext 服務,依據 DI 標準做法在建構式加入 JournalDbContext 參數:
private readonly JournalDbContext dbCtx;
public MemoryUsageMonitor(JournalDbContext dbCtx)
{
this.dbCtx = dbCtx;
}
但這個寫法是有問題的,執行時將噴出以下錯誤:
System.AggregateException: 'Some services are not able to be constructed (Error while validating the service descriptor 'ServiceType: Microsoft.Extensions.Hosting.IHostedService Lifetime: Singleton ImplementationType: CRUDExample.Models.MemoryUsageMonitor': Cannot consume scoped service 'CRUDExample.Models.JournalDbContext' from singleton 'Microsoft.Extensions.Hosting.IHostedService'.)'
理由是:services.AddHostedService<MemoryUsageMonitor>(); 註冊會走 Singleton,而 DbContext 則屬 Scoped,長命服務不能使用短命服務。但受限 IHostedService 必須是 Singleton,不像一般服務可以調降為 Scoped 因應,而我們明確知道 IHostedService 只在 DoWork() 短暫使用 DbContext,用完即可拋,不用擔心 DbContext 壽命較短導致問題,此時該如何處置?
有個實用小技巧 - 建構式改引用 IServiceProvider,在執行定期作業時用自建一個 Scope,便能在其中引用 Scoped 生命週期的服務。
public class MemoryUsageMonitor : IHostedService, IDisposable
{
static Timer _timer;
ILogger _logger = NLog.LogManager.GetCurrentClassLogger();
private readonly IServiceProvider services;
public MemoryUsageMonitor(IServiceProvider services)
{
this.services = services;
}
public Task StartAsync(CancellationToken cancellationToken)
{
_timer = new Timer(DoWork, null,
TimeSpan.Zero,
TimeSpan.FromSeconds(10));
return Task.CompletedTask;
}
private int execCount = 0;
public void DoWork(object state)
{
//利用 Interlocked 計數防止重複執行
Interlocked.Increment(ref execCount);
if (execCount == 1)
{
try
{
using (var scope = services.CreateScope())
{
var dbCtx = scope.ServiceProvider.GetRequiredService<JournalDbContext>();
var count = dbCtx.Records.Count();
_logger.Debug($"Records.Count = {count}");
}
_logger.Info(
$"Memory Usage = {Process.GetCurrentProcess().WorkingSet64}"
);
}
catch (Exception ex)
{
_logger.Error("發生錯誤 = " + ex);
}
}
Interlocked.Decrement(ref execCount);
}
//...省略
如以上範例,我們在建構時改取 IServiceProvider,DoWork() 期間存取 DB 時先用 IServiceProvider.CreateScope() 建立 IServiceScope,再呼叫 IServiceScope.ServiceProvider.GetRequiredService<JournalDbContext>(),這樣便能在 Singleton 服務裡引用 Scoped 服務了。
學會這個小技巧,使可打破 Singleton 不能引用 Scoped 的限制,不必再為了要用 Scoped 物件硬將 Singleton 性質服務調成 Scoped,讓系統架構更趨理想。
Tips of how to using scoped services in a singleton service in ASP.NET Core.
Comments
# by Mars
為何要再開一個 scope 而不直接使用 ```services.GetRequiredService<JournalDbContext>();```
# by Jeffrey
to Mars, DbContext 有快取具狀態性,以支援欄位變動追蹤、啟用交易等功能,用 Scope 可明顯界定其生命週期,確保每次用後就釋放,以免耗用資源或殘留狀態影響下次使用,一般不建議從到尾使用同一顆 DbContext。
# by Mars
原來如此,謝謝暗黑大!
# by Jerry
想請教暗黑大,目前遇到一個情況是在同樣的 code base 同樣的 硬體架構 (Apple M1 ) 同樣的 dotnet SDK 但唯獨在我的環境會出現 Cannot consume scoped service xxx from singleton yyy 。 查詢相關的錯誤訊息剛好看到這篇文章,我判斷應該是不需要也不應該改 code 但我也沒頭緒還有什麼可能的原因 即使我將repo 砍掉重新 clone 一份也還是會出現這個狀況,不知道暗黑大有沒有知道什麼可能造成這個現象的原因呢? 謝謝
# by Jeffrey
to Jerry,症狀聽起來也太玄了。程式結構面的錯誤,SDK 版本相同,沒理由換到不同環境編譯結果不同。我的話會多做幾組對照實驗,例如用文章的例子重現 Cannot consume scoped service 錯誤,看是否也出現兩部機器結果不同;或再找第三台、第四台機器投票,看有問題的是誰,再針對它實驗查原因。