ASP.NET Core 3.x啟動時執行非同步任務(一)

老王Plus發表於2020-09-16

這是一個大的題目,需要用幾篇文章來說清楚。這是第一篇。

一、前言

在我們的專案中,有時候我們需要在應用程式啟動前執行一些一次性的邏輯。比方說:驗證配置的正確性、填充快取、或者執行資料庫清理/遷移等。

如何合理、有效、優雅地完成這個任務,是這個文章討論的主要內容。

要實現這樣一個功能,其實我們有幾個選擇:

  1. 使用IStartupFilter執行同步任務。這是一個內建的解決方案,可以通過一些設定和技巧來執行非同步任務;
  2. 使用IStartupFilterIApplicationLifetime事件來執行非同步任務,這是一個可選的方案,但有不足,我們會在後面講;
  3. 使用IHostedService,在不阻塞應用啟動的情況下,執行一些一次性的任務;(關於這個內容,我在前一篇文章ASP.NET Core 3.x控制IHostedService啟動順序淺探中有涉及到一部分內容)
  4. Program.cs中執行非同步任務。在大多數情況下,從程式碼的複雜度到效率上,這都是一個比較好的選擇。

    為防止非授權轉發,這兒給出本文的原文連結:https://www.cnblogs.com/tiger-wang/p/13673046.html

先提個問題:為什麼要在應用啟動時執行任務?

二、為什麼要在應用啟動時執行任務?

在應用啟動並開始請求服務之前,很多時候需要執行各種初始化工作。

一個ASP.NET應用啟動時,需要完成很多事,例如:

  • 確定當前的宿主環境
  • 載入appsetting.json配置和環境變數
  • 配置並建立依賴注入的容器
  • 配置中介軟體管道

這是應用啟動時要完成的引導內容。

在完成這些內容,執行WebHost並開始監聽請求之前,還會有一些一次性任務需要啟動,例如:

  • 檢查強型別配置的有效性
  • 填充或恢復快取
  • 資料庫清理/遷移(通常來說這不是個好主意,但很多時候沒有別的辦法)

當然,有些任務也不是一定要在開始監聽請求之前執行,這要看具體的執行任務的架構。一般來說,如果快取處理的完善,是不需要提前啟動的。當然,清理/遷移資料庫,是必須放在服務啟動之前。

在微軟官網上,有一個例子是資料保護子系統,用於即時加密(cookie、防偽令牌等),這個就必須在應用監聽請求之前完成初始化並載入,這個例子使用了IStartupFilter

三、使用IStartupFilter執行同步任務

IStartupFilters作為配置中介軟體管道的一部分,通常在Startup.Configure()中執行。它允許我們定製應用的中介軟體管道,處理我們希望進行的所有任務。

看一個簡單的例子:

public class AutoRequestServicesStartupFilter : IStartupFilter
{
    public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
    {
        return builder =>
        {
            builder.UseMiddleware<RequestServicesContainerMiddleware>();
            next(builder);
        };
    }
}

IStartupFilter提供了一種可能,在依賴注入容器配置完成之後、應用程式啟動之前執行一些程式碼。因此,我們可以在IStartupFilters中直接使用依賴注入。這表示我們可以執行有關係統的任何程式碼。在前邊提到的微軟官網的例子中,就是建立了一個基於IStartupFiltersDataProtectionStartupFilter來初始化資料保護子系統。

此外,IStartupFilter允許我們通過向依賴注入容器註冊服務來增加要執行的任務。這是一個很有用的特性,表示我們可以註冊一個在應用啟動時執行的任務,而不需要顯式的呼叫。

但是,這兒有個問題。IStartupFilters通常執行的是同步的任務。看一下上面的程式碼,Configure()方法不返回任務。當然,我們硬要使用非同步也是可以的,但一般來說,這不算個好主意。原因我後面會寫。

寫到這兒,如果對ASP.NET Core架構熟悉,就會引出另一個問題:為什麼不用健康檢查來確認一次性任務的執行結果?

四、為什麼不用健康檢查?

執行健康檢查,是ASP.NET Core 2.2新引入的一個特性,允許查詢通過API(HTTP Endpoint)公開的應用的健康狀況。當應用部署在Kubernetes,或反向代理HAProxyNginx後面時,可以提供給代理用來檢測應用是否準備好開始提供服務。

我們可以使用健康檢查來確保應用所有必需的一次性任務完成之前不會開始監聽服務。

但是,這種方式會有一點問題。

WebHostKestrel本身會在一次性任務執行前啟動。當然,這時他們還不會接收和處理服務請求,但仍然引出了一些問題:

首先是增加了程式碼的複雜性。除了一次性任務的程式碼外,還要增加健康檢查來測試任務是否完成,並同步和保持任務的狀態;其次,如果任務失敗了,應用程式的健康檢查將會讓應用後續的任務無法繼續執行。合理的流程是:應用應該立即失敗返回。

這兒主要的原因是:健康檢查沒有定義如何實際執行任務,而只是定義了任務是否成功完成。相對來說,這種狀態機制比較單一,在一些簡單的任務中可能適用,但不能全面覆蓋一次性任務的全部場景。

五、執行非同步任務

前邊寫了一些不太完美的方法。

現在,我們開始進入執行非同步方法的一些步驟。當然,執行非同步也會有幾種方式,適用性上會有一定的區別。

方式1:使用IStartupFilter

前邊說過,使用IStartupFilter時,執行的是同步任務。所以,我們可以通過GetAwater().GetResult()來呼叫非同步。

我們拿資料遷移來舉個例子。在EF Core中,通過myDBContext.database.migrateasync()在執行時進行資料庫遷移。其中,myDBContext是應用程式中DBContext的一個例項。

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;
    }
}

通常,GetAwaiter().GetResult()要注意避免死鎖的問題。但這兒可能不需要,因為這個程式碼只在啟動時執行,這時候還沒有需要處理的請求,所以不太會死鎖。

只能說,這樣可以用。不過習慣上我會避免這麼做。

方式2:使用IApplicationLifetime事件

這是另一個選擇。可以通過IApplicationLifetime事件,在應用啟動和關閉時接收通知,處理任務。

但這個方式也有侷限性。

首先,IApplicationLifetime使用cancellationtoken來註冊回撥,也就是說,這又是一個同步方式,又需要使用GetAwaiter().GetResult()來呼叫非同步。

其次,ApplicationStarted事件是在WebHost啟動之後才會觸發,因此非同步任務也是在應用開始監聽請求後才執行。

方式3:使用IHostedService

IHostedService可以讓ASP.NET Core應用在後臺執行長時間的任務。

一般來說,IHostedService用在週期性任務、訊息傳遞等任務上,但實際上它並不限於執行這些任務。在ASP.NET Core 3.x上,WebHost本身也是建立在IHostedService上的。

而且,IHostedService本身就是非同步的,它提供了StartAsyncStopAsync

這種方式下,我們的程式碼會是這樣:

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可以直接執行非同步任務。

但是,IHostedService也有侷限性。從微軟官網的說明來看,IHostedService實現期望StartAsync能相對較快的返回。對於後臺任務,傾向於非同步啟動,但主要任務在啟動後執行。

在上面這個例子中,資料遷移本身不是問題,但這個長時任務會阻止其它`IHostedService啟動和執行。而且,應用會在IHostedService完成資料遷移前開始監聽並響應請求,這是一個嚴重的問題。

方式4:在Program.cs中執行

上面三個方式,都可以解決啟動時執行非同步任務的問題,但都不夠完美,要麼要求使用同步(非同步轉同步可以用,但有隱藏問題),要麼不能阻止應用啟動,會造成應用啟動完成後,可能非同步任務還未完成的情況。

我在前邊的博文中寫到過關於Program.cs中執行IHostedService的方式。具體可以去看ASP.NET Core 3.x控制IHostedService啟動順序淺探

看一下Program.cs的預設程式碼:

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()建立WebHost之後,呼叫Run()之前,完全可以加入我們需要的程式碼。同時,C# 7.1後主函式可以改為非同步執行。

因此,我們可以在這兒做些文章:

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>();
}

這個方案的好處是:

  • 這是真正的非同步;
  • 任務完成後,應用程式才可以監聽並接受請求;
  • 此時已經構建了依賴注入容器,所以可以建立服務;

當然,同樣也會有不足:這兒只是構建了DI容器,但並沒有建立管道(管道在Run()RunAsync()後才建立,然後是IStartupFilters執行,再然後是應用程式啟動)。因此非同步任務不能使用管道、IStartupFilters中的配置。不過,這種需求的情況很少。

六、總結

這個部分牽扯到的框架內容比較多。

我們從應用啟動時非同步執行任務開始,說到了必要性,也說到了幾種解決方法,及各自的優缺點。

下一篇文章,我會用一些具體的例子,來說清楚這個方式的具體使用,敬請關注。

(未完待續)

 

 


 

微信公眾號:老王Plus

掃描二維碼,關注個人公眾號,可以第一時間得到最新的個人文章和內容推送

本文版權歸作者所有,轉載請保留此宣告和原文連結

相關文章