ASP.NET Core 3.x啟動(dòng)時(shí)運(yùn)行異步任務(wù)(一)
這是一個(gè)大的題目,需要用幾篇文章來(lái)說(shuō)清楚。這是第一篇。
?
一、前言
在我們的項(xiàng)目中,有時(shí)候我們需要在應(yīng)用程序啟動(dòng)前執(zhí)行一些一次性的邏輯。比方說(shuō):驗(yàn)證配置的正確性、填充緩存、或者運(yùn)行數(shù)據(jù)庫(kù)清理/遷移等。
如何合理、有效、優(yōu)雅地完成這個(gè)任務(wù),是這個(gè)文章討論的主要內(nèi)容。
?
要實(shí)現(xiàn)這樣一個(gè)功能,其實(shí)我們有幾個(gè)選擇:
使用
IStartupFilter運(yùn)行同步任務(wù)。這是一個(gè)內(nèi)置的解決方案,可以通過(guò)一些設(shè)置和技巧來(lái)運(yùn)行異步任務(wù);使用
IStartupFilter或IApplicationLifetime事件來(lái)運(yùn)行異步任務(wù),這是一個(gè)可選的方案,但有不足,我們會(huì)在后面講;使用
IHostedService,在不阻塞應(yīng)用啟動(dòng)的情況下,運(yùn)行一些一次性的任務(wù);(關(guān)于這個(gè)內(nèi)容,我在前一篇文章ASP.NET Core 3.x控制IHostedService啟動(dòng)順序淺探中有涉及到一部分內(nèi)容)在
Program.cs中運(yùn)行異步任務(wù)。在大多數(shù)情況下,從代碼的復(fù)雜度到效率上,這都是一個(gè)比較好的選擇。
?
先提個(gè)問(wèn)題:為什么要在應(yīng)用啟動(dòng)時(shí)運(yùn)行任務(wù)?
二、為什么要在應(yīng)用啟動(dòng)時(shí)運(yùn)行任務(wù)?
在應(yīng)用啟動(dòng)并開(kāi)始請(qǐng)求服務(wù)之前,很多時(shí)候需要運(yùn)行各種初始化工作。
一個(gè)ASP.NET應(yīng)用啟動(dòng)時(shí),需要完成很多事,例如:
確定當(dāng)前的宿主環(huán)境
加載
appsetting.json配置和環(huán)境變量配置并創(chuàng)建依賴注入的容器
配置中間件管道
這是應(yīng)用啟動(dòng)時(shí)要完成的引導(dǎo)內(nèi)容。
在完成這些內(nèi)容,運(yùn)行WebHost并開(kāi)始監(jiān)聽(tīng)請(qǐng)求之前,還會(huì)有一些一次性任務(wù)需要啟動(dòng),例如:
檢查強(qiáng)類型配置的有效性
填充或恢復(fù)緩存
數(shù)據(jù)庫(kù)清理/遷移(通常來(lái)說(shuō)這不是個(gè)好主意,但很多時(shí)候沒(méi)有別的辦法)
當(dāng)然,有些任務(wù)也不是一定要在開(kāi)始監(jiān)聽(tīng)請(qǐng)求之前運(yùn)行,這要看具體的運(yùn)行任務(wù)的架構(gòu)。一般來(lái)說(shuō),如果緩存處理的完善,是不需要提前啟動(dòng)的。當(dāng)然,清理/遷移數(shù)據(jù)庫(kù),是必須放在服務(wù)啟動(dòng)之前。
在微軟官網(wǎng)上,有一個(gè)例子是數(shù)據(jù)保護(hù)子系統(tǒng),用于即時(shí)加密(cookie、防偽令牌等),這個(gè)就必須在應(yīng)用監(jiān)聽(tīng)請(qǐng)求之前完成初始化并加載,這個(gè)例子使用了IStartupFilter。
三、使用IStartupFilter運(yùn)行同步任務(wù)
IStartupFilters作為配置中間件管道的一部分,通常在Startup.Configure()中運(yùn)行。它允許我們定制應(yīng)用的中間件管道,處理我們希望進(jìn)行的所有任務(wù)。
看一個(gè)簡(jiǎn)單的例子:
public?class?AutoRequestServicesStartupFilter?:?IStartupFilter
{
????public?Action?Configure(Action?next)
????{
????????return?builder?=>
????????{
????????????builder.UseMiddleware();
????????????next(builder);
????????};
????}
}
IStartupFilter提供了一種可能,在依賴注入容器配置完成之后、應(yīng)用程序啟動(dòng)之前運(yùn)行一些代碼。因此,我們可以在IStartupFilters中直接使用依賴注入。這表示我們可以運(yùn)行有關(guān)系統(tǒng)的任何代碼。在前邊提到的微軟官網(wǎng)的例子中,就是創(chuàng)建了一個(gè)基于IStartupFilters的DataProtectionStartupFilter來(lái)初始化數(shù)據(jù)保護(hù)子系統(tǒng)。
此外,IStartupFilter允許我們通過(guò)向依賴注入容器注冊(cè)服務(wù)來(lái)增加要執(zhí)行的任務(wù)。這是一個(gè)很有用的特性,表示我們可以注冊(cè)一個(gè)在應(yīng)用啟動(dòng)時(shí)運(yùn)行的任務(wù),而不需要顯式的調(diào)用。
但是,這兒有個(gè)問(wèn)題。IStartupFilters通常運(yùn)行的是同步的任務(wù)??匆幌律厦娴拇a,Configure()方法不返回任務(wù)。當(dāng)然,我們硬要使用異步也是可以的,但一般來(lái)說(shuō),這不算個(gè)好主意。原因我后面會(huì)寫(xiě)。
?
寫(xiě)到這兒,如果對(duì)ASP.NET Core架構(gòu)熟悉,就會(huì)引出另一個(gè)問(wèn)題:為什么不用健康檢查來(lái)確認(rèn)一次性任務(wù)的執(zhí)行結(jié)果?
四、為什么不用健康檢查?
運(yùn)行健康檢查,是ASP.NET Core 2.2新引入的一個(gè)特性,允許查詢通過(guò)API(HTTP Endpoint)公開(kāi)的應(yīng)用的健康狀況。當(dāng)應(yīng)用部署在Kubernetes,或反向代理HAProxy或Nginx后面時(shí),可以提供給代理用來(lái)檢測(cè)應(yīng)用是否準(zhǔn)備好開(kāi)始提供服務(wù)。
我們可以使用健康檢查來(lái)確保應(yīng)用所有必需的一次性任務(wù)完成之前不會(huì)開(kāi)始監(jiān)聽(tīng)服務(wù)。
但是,這種方式會(huì)有一點(diǎn)問(wèn)題。
WebHost和Kestrel本身會(huì)在一次性任務(wù)執(zhí)行前啟動(dòng)。當(dāng)然,這時(shí)他們還不會(huì)接收和處理服務(wù)請(qǐng)求,但仍然引出了一些問(wèn)題:
首先是增加了代碼的復(fù)雜性。除了一次性任務(wù)的代碼外,還要增加健康檢查來(lái)測(cè)試任務(wù)是否完成,并同步和保持任務(wù)的狀態(tài);其次,如果任務(wù)失敗了,應(yīng)用程序的健康檢查將會(huì)讓?xiě)?yīng)用后續(xù)的任務(wù)無(wú)法繼續(xù)執(zhí)行。合理的流程是:應(yīng)用應(yīng)該立即失敗返回。
這兒主要的原因是:健康檢查沒(méi)有定義如何實(shí)際運(yùn)行任務(wù),而只是定義了任務(wù)是否成功完成。相對(duì)來(lái)說(shuō),這種狀態(tài)機(jī)制比較單一,在一些簡(jiǎn)單的任務(wù)中可能適用,但不能全面覆蓋一次性任務(wù)的全部場(chǎng)景。
五、運(yùn)行異步任務(wù)
前邊寫(xiě)了一些不太完美的方法。
現(xiàn)在,我們開(kāi)始進(jìn)入運(yùn)行異步方法的一些步驟。當(dāng)然,運(yùn)行異步也會(huì)有幾種方式,適用性上會(huì)有一定的區(qū)別。
方式1:使用IStartupFilter
前邊說(shuō)過(guò),使用IStartupFilter時(shí),執(zhí)行的是同步任務(wù)。所以,我們可以通過(guò)GetAwater().GetResult()來(lái)調(diào)用異步。
?
我們拿數(shù)據(jù)遷移來(lái)舉個(gè)例子。在EF Core中,通過(guò)myDBContext.database.migrateasync()在運(yùn)行時(shí)進(jìn)行數(shù)據(jù)庫(kù)遷移。其中,myDBContext是應(yīng)用程序中DBContext的一個(gè)實(shí)例。
public?class?MigratorStartupFilter:?IStartupFilter
{
????private?readonly?IServiceProvider?_serviceProvider;
????public?MigratorStartupFilter(IServiceProvider?serviceProvider)
????{
????????_serviceProvider?=?serviceProvider;
????}
????public?Action?Configure(Action?next)
????{
????????using(var?scope?=?_seviceProvider.CreateScope())
????????{
????????????var?myDbContext?=?scope.ServiceProvider.GetRequiredService();
????????????myDbContext.Database.MigrateAsync()
????????????????.GetAwaiter()
????????????????.GetResult();
????????}
????????return?next;
????}
}
通常,GetAwaiter().GetResult()要注意避免死鎖的問(wèn)題。但這兒可能不需要,因?yàn)檫@個(gè)代碼只在啟動(dòng)時(shí)運(yùn)行,這時(shí)候還沒(méi)有需要處理的請(qǐng)求,所以不太會(huì)死鎖。
只能說(shuō),這樣可以用。不過(guò)習(xí)慣上我會(huì)避免這么做。
方式2:使用IApplicationLifetime事件
這是另一個(gè)選擇。可以通過(guò)IApplicationLifetime事件,在應(yīng)用啟動(dòng)和關(guān)閉時(shí)接收通知,處理任務(wù)。
但這個(gè)方式也有局限性。
首先,IApplicationLifetime使用cancellationtoken來(lái)注冊(cè)回調(diào),也就是說(shuō),這又是一個(gè)同步方式,又需要使用GetAwaiter().GetResult()來(lái)調(diào)用異步。
其次,ApplicationStarted事件是在WebHost啟動(dòng)之后才會(huì)觸發(fā),因此異步任務(wù)也是在應(yīng)用開(kāi)始監(jiān)聽(tīng)請(qǐng)求后才運(yùn)行。
方式3:使用IHostedService
IHostedService可以讓ASP.NET Core應(yīng)用在后臺(tái)執(zhí)行長(zhǎng)時(shí)間的任務(wù)。
一般來(lái)說(shuō),IHostedService用在周期性任務(wù)、消息傳遞等任務(wù)上,但實(shí)際上它并不限于運(yùn)行這些任務(wù)。在ASP.NET Core 3.x上,WebHost本身也是建立在IHostedService上的。
而且,IHostedService本身就是異步的,它提供了StartAsync和StopAsync。
這種方式下,我們的代碼會(huì)是這樣:
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();
????????????await?myDbContext.Database.MigrateAsync();
????????}
????}
????public?Task?StopAsync(CancellationToken?cancellationToken)
????{
????????return?Task.CompletedTask;
????}
}
根據(jù)例子可以看出,IHostedService可以直接運(yùn)行異步任務(wù)。
但是,IHostedService也有局限性。從微軟官網(wǎng)的說(shuō)明來(lái)看,IHostedService實(shí)現(xiàn)期望StartAsync能相對(duì)較快的返回。對(duì)于后臺(tái)任務(wù),傾向于異步啟動(dòng),但主要任務(wù)在啟動(dòng)后執(zhí)行。
在上面這個(gè)例子中,數(shù)據(jù)遷移本身不是問(wèn)題,但這個(gè)長(zhǎng)時(shí)任務(wù)會(huì)阻止其它`IHostedService啟動(dòng)和運(yùn)行。而且,應(yīng)用會(huì)在IHostedService完成數(shù)據(jù)遷移前開(kāi)始監(jiān)聽(tīng)并響應(yīng)請(qǐng)求,這是一個(gè)嚴(yán)重的問(wèn)題。
方式4:在Program.cs中運(yùn)行
上面三個(gè)方式,都可以解決啟動(dòng)時(shí)運(yùn)行異步任務(wù)的問(wèn)題,但都不夠完美,要么要求使用同步(異步轉(zhuǎn)同步可以用,但有隱藏問(wèn)題),要么不能阻止應(yīng)用啟動(dòng),會(huì)造成應(yīng)用啟動(dòng)完成后,可能異步任務(wù)還未完成的情況。
我在前邊的博文中寫(xiě)到過(guò)關(guān)于Program.cs中運(yùn)行IHostedService的方式。具體可以去看ASP.NET Core 3.x控制IHostedService啟動(dòng)順序淺探
看一下Program.cs的默認(rèn)代碼:
public?class?Program
{
????public?static?void?Main(string[]?args)
????{
????????CreateWebHostBuilder(args).Build().Run();
????}
????public?static?IWebHostBuilder?CreateWebHostBuilder(string[]?args)?=>
????????WebHost.CreateDefaultBuilder(args)
????????????.UseStartup();
}
在Build()創(chuàng)建WebHost之后,調(diào)用Run()之前,完全可以加入我們需要的代碼。同時(shí),C# 7.1后主函數(shù)可以改為異步運(yùn)行。
因此,我們可以在這兒做些文章:
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();
????????????await?myDbContext.Database.MigrateAsync();
????????}
????????await?webHost.RunAsync();
????}
????public?static?IWebHostBuilder?CreateWebHostBuilder(string[]?args)?=>
????????WebHost.CreateDefaultBuilder(args)
????????????.UseStartup();
}
這個(gè)方案的好處是:
這是真正的異步;
任務(wù)完成后,應(yīng)用程序才可以監(jiān)聽(tīng)并接受請(qǐng)求;
此時(shí)已經(jīng)構(gòu)建了依賴注入容器,所以可以創(chuàng)建服務(wù);
當(dāng)然,同樣也會(huì)有不足:這兒只是構(gòu)建了DI容器,但并沒(méi)有建立管道(管道在Run()、RunAsync()后才建立,然后是IStartupFilters執(zhí)行,再然后是應(yīng)用程序啟動(dòng))。因此異步任務(wù)不能使用管道、IStartupFilters中的配置。不過(guò),這種需求的情況很少。
六、總結(jié)
這個(gè)部分牽扯到的框架內(nèi)容比較多。
我們從應(yīng)用啟動(dòng)時(shí)異步運(yùn)行任務(wù)開(kāi)始,說(shuō)到了必要性,也說(shuō)到了幾種解決方法,及各自的優(yōu)缺點(diǎn)。
下一篇文章,我會(huì)用一些具體的例子,來(lái)說(shuō)清楚這個(gè)方式的具體使用,敬請(qǐng)關(guān)注。
(未完待續(xù))
