原文:Running async tasks on app startup in ASP.NET Core (Part 1)
作者:Andrew Lock
譯者:Lamond Lu
背景
當我們做專案的時候,有時候希望自己的ASP.NET Core應用在啟動前執行一些初始化邏輯。例如,你希望驗證配置是否合法,填充快取資料,或者執行資料庫遷移指令碼。在本篇部落格中,我將介紹幾種可選的方案,並且透過展示一些簡單的方法和擴充套件點來說明我想要解決的問題。
開始我將先描述一下ASP.NET Core內建的解決方案,使用IStartupFilter
來運行同步任務。然後我將描述幾種可選的執行非同步任務的方案。你可以(但是可能不應該這樣做)使用IStartupFilter
或者IApplicationLifetime
事件來執行非同步任務。你也可以使用IHostService
介面來執行一次性任務且不會阻塞ASP.NET Core應用啟動。最後唯一合理的方案是在program.cs
檔案中手動執行任務。在下一篇部落格中,我會展示一個可以簡化這個流程的推薦方案。
為什麼我們需要在程式啟動時執行非同步任務?
在程式啟動,開始監聽請求之前,執行一些初始化程式碼是非常普遍的。對於一個ASP.NET Core應用程式,啟動前有許多工需要執行,例如:
-
確定當前的託管環境
-
從appsetting.json檔案和環境變數中讀取配置
-
配置依賴註入容器
-
構建依賴註入容器
-
配置中介軟體管道
以上幾步都四發生在應用程式引導時。然而有些一次性任務需要在WebHost
啟動,監聽請求前執行。例如
-
檢查強型別配置是否合法
-
使用資料庫或者API填充快取
-
執行資料庫遷移指令碼(這通常不是一個很好的方案,但是對於一些應用來說夠用了)
有些時候,一些任務並不是非要在程式啟動,監聽請求前執行。這裡我們以填充快取為例,如果它是設計的比較好的話,在程式啟動前是否填充快取資料是無關緊要的。但是,相對的,你肯定也希望在應用程式開始監聽請求之前,遷移你的資料庫!
其實ASP.NET Core框架自己也需要執行一些一次性初始化任務。這個最好的例子就是資料保護,它常用來做資料加密,這個模組必須要在應用啟動前初始化。為了實現初始化,它們使用了IStartupFilter
。
使用IStartupFilter
來運行同步任務
在之前的部落格中,我已經介紹過IStartupFilter
, 它是一個自定義ASP.NET Core應用的強力介面。
如果你是第一次接觸Filter, 我建議你去我之前的部落格,這裡我只會提供一個簡短的總結。
IStartupFilter
會在配置中介軟體管道的行程中被執行(通常在Startup.Configure()
中完成)。它們允許你透過插入額外的中介軟體,分叉或執行任何其他操作來自定義應用程式實際建立的中介軟體管道。例如下麵程式碼展示的AutoRequestServiceStartupFilter
這非常有用,但它與ASP.NET Core應用程式啟動時執行一次性任務有什麼關係呢?
IStartupFilter
的主要功能是為開發人員提供了一個鉤子(hook), 這個鉤子觸發的時機是在在應用程式配置完成並配置依賴註入容器之後,應用程式啟動之前。這意味著,你可以在實現IStartupFilter
的類中使用依賴註入,這樣你就可以在這裡完成許多希望在應用程式啟用前需要執行的任務。以ASP.NET Core內建的DataProtectionStartupFilter為例,它會在程式啟用前初始化整個資料保護模組。
IStartupFilter
提供的另外一個重要功能就是,它允許你透過向依賴註入容器註冊服務來新增要執行的任務。這意味著如果你自己編寫了一個Library, 你可以在應用程式啟動時註冊一個任務,而不需要應用程式顯式呼叫它。
問題是IStartupFilter
基本上是同步的。Configure
方法的傳回值不是Task
,因此我們只能使用同步方式執行非同步任務,這顯然不是好的實現方案。 我稍後會討論這個,但現在讓我們先跳過它。
為什麼不用健康檢查?
ASP.NET Core 2.2中加入了一個新的健康檢查功能,它透過暴露一個HTTP節點,讓你可以查詢當前應用的健康狀態。當應用部署之後,像Kubernetes這樣的編排引擎或HAProxy和NGINX等反向代理可以查詢此HTTP節點以檢查你應用是否已準備好開始接收請求。
你可以使用健康檢查功能來確保你的應用程式不會開始處理請求,直到所有必需的一次性初始化任務完成為止。然而,這有一些缺點:
-
WebHost和Kestrel本身將在執行一次性初始化任務之前啟動,雖然他們不會收到可能存在問題的“真實”請求(僅健康檢查請求)。
-
這種方式會引入了額外的複雜度,除了新增執行一次性任務的程式碼之外,還需要新增執行狀況檢查以測試任務是否完成,並同步任務的狀態。
-
應用程式的啟動會有延遲,因為需要等待所有任務完成,所以不太可能減少啟動時間。
-
如果任務失敗,應用程式不會終止,而且健康檢查也永遠不會透過。這可能是可以接受的,但是我個人更喜歡讓應用程式立刻終止。
-
使用健康檢查,並不能知道一次性任務執行的怎麼樣,你只能瞭解到任務是否完成。
在我看來,健康檢查並不適合一次性任務的場景,他們可能對我描述的一些例子很有用,但我不認為它適用於所有情況。我真的希望能在WebHost
啟動之前,執行一些一次性任務。
執行非同步任務
我已經花了很長的篇幅來討論了所有不能完成我的標的的所有方法,那麼哪些才是可行的方案!在這一節中,我將描述幾種執行非同步任務的方案(即方法傳回Task
, 並且需要等待的),其中有一些較好的方案,也有一些需要規避的方案。
這裡為了更清楚的描述這些方案,我選用資料庫遷移作為例子。在EF Core中,你可以在執行時呼叫myDbContext.Database.MigrateAsync()
來遷移資料庫,其中myDbContext
是當前應用程式的資料庫背景關係實體。
EF還提供了一個同步的資料庫遷移方法
Database.Migrate()
,但是這裡我們不需要使用它。
使用IStartupFilter
我之前描述過如何使用IStartupFilter
在應用程式啟動時運行同步任務。 不過,這裡為了非同步方法,我們使用了GetAwaiter()
和GetResult()
阻塞了執行緒, 將非同步方法變成了一個同步方法。
警告:這是一種非常不好的非同步實踐方式
這段程式碼可能不會引起任何問題,它會在應用程式啟動且未開始監聽請求時執行,所以不太可能出現死鎖。但是坦率的說,我會盡可能不用這種方式。
使用IApplicationLifetime
事件
我之前還沒有討論過和這個事件相關的內容,但是當你的應用程式啟動和關閉前,你可以使用IApplicationLifetime
介面接收到通知。這裡我不會詳細介紹它,因為使用它來實現我們的目的會有一些問題。
IApplicationLifetime
使用CancellationTokens
來註冊回呼,這意味著你只能同步執行回呼。 這實際上意味著無論你做什麼,你都會遇到同步非同步樣式。
ApplicationStarted事件僅在WebHost啟動後觸發,因此任務在應用程式開始接受請求後執行。
鑒於他們沒有解決IStartupFilter
使用同步方式處理非同步任務的問題,也沒有阻止應用啟動,所以我只是將它列出來僅供參考。
使用IHostedService
執行非同步事件
IHostService
允許在ASP.NET Core應用程式生命週期內,以後臺程式的方式執行長時間執行的任務。它有許多不同的用途,你可以使用它在計數器上執行定期任務,或者監聽RabbitMQ訊息。在ASP.NET Core 3.0中, Web Host也可能是使用IHostService
構建的。
IHostService
本質上是非同步的,他提供了StartAsync
和StopAsync
方法。這對我們來說非常的有用,它再在是使用同步方式處理非同步任務了。使用IHostService
,我們的資料庫遷移任務可以變成一個託管服務。
不幸的是,IHostedService
並不是我們希望的靈丹妙藥。 它允許我們編寫真正的非同步程式碼,但它有幾個問題:
-
IHostService
的典型實現期望StartAsync
方法能夠相對快速傳回。對於後臺任務來說,它希望你能夠以非同步分當時啟動服務,但是大多數任務都是在啟動程式碼之外。遷移資料庫的任務會阻止其他IHostService
啟動(這裡我不太理解作者的意思,只是按字面意思翻譯,後續會更新這裡)。 -
第二個問題是最大的問題,你的應用程式會在
IHostService
執行資料庫遷移之前開始接受請求,這顯然不是我們想要的。
在Program.cs
中手動執行任務
到現在為止,我們都沒有提供一種完善的解決方案,他們或者是使用同步方式處理非同步任務,或者是不能阻止程式啟動。
現在讓我們停止嘗試使用框架機制,手動來完成工作。
ASP.NET Core模板中使用的預設Program.cs
在Main
函式的一個陳述句中構建並執行IWebHost
:
這裡你可能會發現在Build()
方法之後, Run()
方法之前,你可以新增一些自定義的程式碼,再加上C# 7.1中允許使用非同步方式執行Main
方法,所以這裡我們有了一個合理的方案。
這個方案有以下優點:
-
我們使用的是真正的非同步,而不是使用同步方式處理非同步任務
-
我們可以使用非同步方式執行任務
-
只有當我們的非同步任務都完成之後,WebHost才會啟動
-
在這個時間點,依賴註入容易已經構建完成,我們可以使用它來建立服務
但是這種方法也存在一些問題:
-
即使依賴註入容器構建完成,但是中介軟體管道卻還沒有完成構建。只有當你呼叫
Run()
或者RunAsync()
方法之後,中介軟體管道才開始構建。當構建中介軟體管道時,IStartupFilter
才會被執行,然後程式啟動。如果你的非同步任務需要在以上任何步驟中配置,那你就不走運了。 -
我們失去了透過向依賴註入容器新增服務來自動執行任務的能力。 我們只能手動執行任務。
如果這些問題都不是問題,那麼我認為這個最終選項提供瞭解決問題的最佳方案。 在我的下一篇文章中,我將展示一些方法,我們可以在這個例子的基礎上構建,以使某些內容更容易使用。
總結
在這篇文章中,我討論了在ASP.NET Core應用程式啟動時執行非同步執行任務的必要性。 我描述了這樣做的一些問題和挑戰。 對於同步任務,IStartupFilter
為ASP.NET Core應用程式啟動過程提供了一個有用的鉤子,但是需要使用同步方式執行非同步任務,這通常是一個壞主意。 我描述了執行非同步任務的一些可能的選項,我發現其中最好的是在Program.cs
中“手動”執行任務。 在下一篇文章中,我將介紹一些程式碼,使這個樣式更容易使用。