如何在ASP.NET Core程式啟動時執行非同步任務(2)
原文: Running async tasks on app startup in ASP.NET Core (Part 2)
作者:Andrew Lock
譯者:Lamond Lu
在我的上一篇部落格中,我介紹瞭如何在ASP.NET Core應用程式啟動時執行一些一次性非同步任務。本篇部落格將繼續討論上一篇的內容,如果你還沒有讀過,我建議你先讀一下前一篇。
在本篇部落格中,我將展示上一篇博文中提出的“在 Program.cs
中手動執行非同步任務”的實現方法。該實現會使用一些簡單的介面和類來封裝應用程式啟動時的執行任務邏輯。我還會展示一個替代方法,這個替代方法是在Kestral伺服器啟動時,使用 IServer
介面。
在應用程式啟動時執行非同步任務
這裡我們先回顧一下上一遍部落格內容,在上一篇中,我們試圖尋找一種方案,允許我們在ASP.NET Core應用程式啟動時執行一些非同步任務。這些任務應該是在ASP.NET Core應用程式啟動之前執行,但是由於這些任務可能需要讀取配置或者使用服務,所以它們只能在ASP.NET Core的依賴注入容器配置完成後執行。資料庫遷移,填充快取都可以這種非同步任務的使用場景。
我們在一篇文章的末尾提出了一個相對完善的解決方案,這個方案是在 Program.cs
中“手動”執行任務。執行任務的時機是在 IWebHostBuilder.Build()
和 IWebHost.RunAsync()
之間。
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>(); }
這種實現方式是可行的,但是有點亂。這裡我們將許多不應該屬於 Program.cs
職責的程式碼放在了 Program.cs
中,讓它看起來有點臃腫了,所以這裡我們需要將資料庫遷移相關的程式碼移到另外一個類中。
這裡更麻煩的問題是,我們必須要手動呼叫任務。如果你在多個應用程式中使用相同的模式,那麼最好能改成自動呼叫任務。
在依賴注入容器中註冊啟動任務
這裡我將使用基於 IStartupFilter
和 IHostService
使用的模式。它們允許你在依賴注入容器中註冊它們的實現類,並在應用程式啟動前獲取到這些介面的所有實現類,並依次執行它們。
所以,這裡首先我們建立一個簡單的介面來啟動任務。
public interface IStartupTask { Task ExecuteAsync(CancellationToken cancellationToken = default); }
並且建立一個在依賴注入容器中註冊任務的便捷方法。
public static class ServiceCollectionExtensions { public static IServiceCollection AddStartupTask<T>(this IServiceCollection services) where T : class, IStartupTask => services.AddTransient<IStartupTask, T>(); }
最後,我們新增一個擴充套件方法,在應用程式啟動時找到所有已註冊的IStartupTasks,按順序執行它們,然後啟動IWebHost:
public static class StartupTaskWebHostExtensions { public static async Task RunWithTasksAsync(this IWebHost webHost, CancellationToken cancellationToken = default) { var startupTasks = webHost.Services.GetServices<IStartupTask>(); foreach (var startupTask in startupTasks) { await startupTask.ExecuteAsync(cancellationToken); } await webHost.RunAsync(cancellationToken); } }
以上就是所有的程式碼。
下面為了看一下它的實際效果,我將繼續使用上一篇中EF Core資料庫遷移的例子
例子:非同步遷移資料庫
實現 IStartupTask
和實現 IStartupFilter
非常的相似。你可以從依賴注入容器中注入服務。為了使用依賴注入容器中的服務,這裡我們需要手動注入一個 IServiceProvider
物件,並手動建立一個Scoped服務。
EF Core的資料庫遷移啟動任務類似以下程式碼:
public class MigratorStartupFilter: IStartupTask { private readonly IServiceProvider _serviceProvider; public MigratorStartupFilter(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider; } public Task ExecuteAsync(CancellationToken cancellationToken = default) { using(var scope = _seviceProvider.CreateScope()) { var myDbContext = scope.ServiceProvider.GetRequiredService<MyDbContext>(); await myDbContext.Database.MigrateAsync(); } } }
現在,我們可以在 ConfigureServices
方法中使用依賴注入容器新增啟動任務了。
public void ConfigureServices(IServiceCollection services) { services.MyDbContext<ApplicationDbContext>(options => options.UseSqlServer(Configuration .GetConnectionString("DefaultConnection"))); services.AddMvc() .SetCompatibilityVersion(CompatibilityVersion.Version_2_1); services.AddStartupTask<MigrationStartupTask>(); }
最後我們更新一下 Program.cs
, 使用 RunWithTasksAsync()
方法替換 Run()
方法。
public class Program { public static async Task Main(string[] args) { await CreateWebHostBuilder(args) .Build() .RunWithTasksAsync(); } public static IWebHostBuilder CreateWebHostBuilder(string[] args) => WebHost.CreateDefaultBuilder(args) .UseStartup<Startup>(); }
以上程式碼利用了C# 7.1中引入的非同步Task Main的特性。從功能上來說,它與我上一篇部落格中的手動程式碼等同,但是它有一些優點。
Program.cs RunAsync
對於以上方案,有一個問題需要注意。這裡我們定義的任務會在 IConfiguration
和依賴注入容器配置完成之後執行,這也就意味著,當任務執行時,所有的 IStartupFilter
都沒有執行,中介軟體管道也沒有配置。
就我個人而言,我不認為這是一個問題,因為我暫時想不出任何可能。到目前為止,我所編寫的任務都不依賴於 IStartupFilter
和中介軟體管道。但這也並不意味著沒有這種可能。
不幸的是,使用當前的WebHost程式碼並沒有簡單的方法(儘管 在.NET Core 3.0中當ASP.NET Core作為IHostedService執行時,這可能會發生變化)。 問題是應用程式是引導(通過配置中介軟體管道並執行IStartupFilters)和啟動在同一個函式中。 當你在Program.cs中呼叫 WebHost.Run()
時,在內部程式會呼叫 WebHost.StartAsync
,如下所示,為簡潔起見,其中只包含了日誌記錄和一些其他次要程式碼:
public virtual async Task StartAsync(CancellationToken cancellationToken = default) { _logger = _applicationServices.GetRequiredService<ILogger<WebHost>>(); var application = BuildApplication(); _applicationLifetime = _applicationServices.GetRequiredService<IApplicationLifetime>() as ApplicationLifetime; _hostedServiceExecutor = _applicationServices.GetRequiredService<HostedServiceExecutor>(); var diagnosticSource = _applicationServices.GetRequiredService<DiagnosticListener>(); var httpContextFactory = _applicationServices.GetRequiredService<IHttpContextFactory>(); var hostingApp = new HostingApplication(application, _logger, diagnosticSource, httpContextFactory); await Server.StartAsync(hostingApp, cancellationToken).ConfigureAwait(false); _applicationLifetime?.NotifyStarted(); await _hostedServiceExecutor.StartAsync(cancellationToken).ConfigureAwait(false); }
這裡問題是我們想要在 BuildApplication()
和 Server.StartAsync
之間插入程式碼,但是現在沒有這樣做的機制。
我不確定我所給出的解決方案是否優雅,但它可以工作,併為消費者提供更好的體驗,因為他們不需要修改Program.cs
使用 IServer
的替代方案
為了實現在 BuildApplication()
和 Server.StartAsync()
之間執行非同步程式碼,我能想到的唯一辦法是我們自己的實現一個IServer實現(Kestrel)! 對你來說,聽到這個可能感覺非常可怕 - 但是我們真的不打算更換伺服器,我們只是去裝飾它。
public class TaskExecutingServer : IServer { private readonly IServer _server; private readonly IEnumerable<IStartupTask> _startupTasks; public TaskExecutingServer(IServer server, IEnumerable<IStartupTask> startupTasks) { _server = server; _startupTasks = startupTasks; } public async Task StartAsync<TContext>(IHttpApplication<TContext> application, CancellationToken cancellationToken) { foreach (var startupTask in _startupTasks) { await startupTask.ExecuteAsync(cancellationToken); } await _server.StartAsync(application, cancellationToken); } public IFeatureCollection Features => _server.Features; public void Dispose() => _server.Dispose(); public Task StopAsync(CancellationToken cancellationToken) => _server.StopAsync(cancellationToken); }
TaskExecutingServer
在其建構函式中獲取了一個 IServer
例項 - 這是 ASP.NET Core
註冊的原始Kestral伺服器。我們將大部分 IServer
的介面實現直接委託給Kestrel,我們只是攔截對 StartAsync
的呼叫並首先執行注入的任務。
這個實現最困難部分是使裝飾器正常工作。正如我在上一篇文章中所討論的那樣,使用帶有預設ASP.NET Core容器的裝飾可能會非常棘手。我通常使用Scrutor來建立裝飾器,但是如果你不想依賴另一個庫,你總是可以手動進行裝飾, 但一定要看看Scrutor是如何做到這一點的!
下面我們新增一個用於新增 IStartupTask
的擴充套件方法, 這個擴充套件方法做了兩件事,一是將 IStartupTask
註冊到依賴注入容器中,二是裝飾了之前註冊的 IServer
例項(這裡為了簡潔,我省略了 Decorate
方法的實現)。如果它發現 IServer
已經被裝飾,它會跳過第二步,這樣你就可以安全的多次呼叫 AddStartupTask
方法。
public static class ServiceCollectionExtensions { public static IServiceCollection AddStartupTask<TStartupTask>(this IServiceCollection services) where TStartupTask : class, IStartupTask => services .AddTransient<IStartupTask, TStartupTask>() .AddTaskExecutingServer(); private static IServiceCollection AddTaskExecutingServer(this IServiceCollection services) { var decoratorType = typeof(TaskExecutingServer); if (services.Any(service => service.ImplementationType == decoratorType)) { return services; } return services.Decorate<IServer, TaskExecutingServer>(); } }
使用這兩段程式碼,我們不再需要再對Program.cs檔案進行任何更改,並且我們是在完全構建應用程式後執行我們的任務,這其中也包括IStartupFilters和中介軟體管道。
啟動過程的序列圖現在看起來有點像這樣:
以上就是這種實現方式全部的內容。它的程式碼非常少, 以至於我自己都在考慮是否要自己編寫一個庫。不過最後我還是在 GitHub 和 Nuget 上建立了一個庫NetEscapades.AspNetCore.StartupTasks
這裡我只編寫了使用後一種 IServer
實現的庫,因為它更容易使用,而且Thomas Levesque已經編寫針對第一種方法可用的 NuGet 包。
在GitHub的實現中,我手動構造了裝飾器,以避免強制依賴Scrutor。 但最好的方法可能就是將程式碼複製並貼上到您自己的專案中。
總結
在這篇博文中,我展示了兩種在ASP.NET Core應用程式啟動時非同步執行任務的方法。 第一種方法需要稍微修改Program.cs,但是“更安全”,因為它不需要修改像IServer這樣的內部實現細節。 第二種方法是裝飾IServer,提供更好的使用者體驗,但感覺更加笨拙。