由 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 錯誤,看是否也出現兩部機器結果不同;或再找第三台、第四台機器投票,看有問題的是誰,再針對它實驗查原因。

Post a comment