原文: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
public class AutoRequestServicesStartupFilter : IStartupFilter
{
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
{
return builder =>
{
builder.UseMiddleware<RequestServicesContainerMiddleware>();
next(builder);
};
}
}
這非常有用,但它與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()
阻塞了線程, 將異步方法變成了一個同步方法。
警告:這是一種非常不好的異步實踐方式
public class MigratorStartupFilter: IStartupFilter
{
private readonly IServiceProvider _serviceProvider;
public MigratorStartupFilter(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
{
using(var scope = _seviceProvider.CreateScope())
{
var myDbContext = scope.ServiceProvider.GetRequiredService<MyDbContext>();
myDbContext.Database.MigrateAsync()
.GetAwaiter()
.GetResult();
}
return next;
}
}
這段代碼可能不會引起任何問題,它會在應用程序啟動且未開始監聽請求時運行,所以不太可能出現死鎖。但是坦率的說,我會盡可能不用這種方式。
使用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
,我們的數據庫遷移任務可以變成一個托管服務。
public class MigratorHostedService: IHostedService
{
private readonly IServiceProvider _serviceProvider;
public MigratorStartupFilter(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
using(var scope = _seviceProvider.CreateScope())
{
var myDbContext = scope.ServiceProvider.GetRequiredService<MyDbContext>();
await myDbContext.Database.MigrateAsync();
}
}
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
不幸的是,IHostedService
並不是我們希望的靈丹妙葯。 它允許我們編寫真正的異步代碼,但它有幾個問題:
IHostService
的典型實現期望StartAsync
方法能夠相對快速返回。對於后台任務來說,它希望你能夠以異步分當時啟動服務,但是大多數任務都是在啟動代碼之外。遷移數據庫的任務會阻止其他IHostService
啟動(這里我不太理解作者的意思,只是按字面意思翻譯,后續會更新這里)。- 第二個問題是最大的問題,你的應用程序會在
IHostService
運行數據庫遷移之前開始接受請求,這顯然不是我們想要的。
在Program.cs
中手動運行任務
到現在為止,我們都沒有提供一種完善的解決方案,他們或者是使用同步方式處理異步任務,或者是不能阻止程序啟動。
現在讓我們停止嘗試使用框架機制,手動來完成工作。
ASP.NET Core模板中使用的默認Program.cs
在Main
函數的一個語句中構建並運行IWebHost
:
public class Program
{
public static void Main(string[] args)
{
CreateWebHostBuilder(args).Build().Run();
}
public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
WebHost.CreateDefaultBuilder(args)
.UseStartup<Startup>();
}
這里你可能會發現在Build()
方法之后, Run()
方法之前,你可以添加一些自定義的代碼,再加上C# 7.1中允許使用異步方式運行Main
方法,所以這里我們有了一個合理的方案。
public class Program
{
public static async Task Main(string[] args)
{
IWebHost webHost = CreateWebHostBuilder(args).Build();
using (var scope = webHost.Services.CreateScope())
{
var myDbContext = scope.ServiceProvider.GetRequiredService<MyDbContext>();
await myDbContext.Database.MigrateAsync();
}
await webHost.RunAsync();
}
public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
WebHost.CreateDefaultBuilder(args)
.UseStartup<Startup>();
}
這個方案有以下優點:
- 我們使用的是真正的異步,而不是使用同步方式處理異步任務
- 我們可以使用異步方式運行任務
- 只有當我們的異步任務都完成之后,WebHost才會啟動
- 在這個時間點,依賴注入容易已經構建完成,我們可以使用它來創建服務
但是這種方法也存在一些問題:
- 即使依賴注入容器構建完成,但是中間件管道卻還沒有完成構建。只有當你調用
Run()
或者RunAsync()
方法之后,中間件管道才開始構建。當構建中間件管道時,IStartupFilter
才會被執行,然后程序啟動。如果你的異步任務需要在以上任何步驟中配置,那你就不走運了。 - 我們失去了通過向依賴注入容器添加服務來自動運行任務的能力。 我們只能手動運行任務。
如果這些問題都不是問題,那么我認為這個最終選項提供了解決問題的最佳方案。 在我的下一篇文章中,我將展示一些方法,我們可以在這個例子的基礎上構建,以使某些內容更容易使用。
總結
在這篇文章中,我討論了在ASP.NET Core應用程序啟動時執行異步運行任務的必要性。 我描述了這樣做的一些問題和挑戰。 對於同步任務,IStartupFilter
為ASP.NET Core應用程序啟動過程提供了一個有用的鈎子,但是需要使用同步方式運行異步任務,這通常是一個壞主意。 我描述了運行異步任務的一些可能的選項,我發現其中最好的是在Program.cs
中“手動”運行任務。 在下一篇文章中,我將介紹一些代碼,使這個模式更容易使用。