原文: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,提供更好的用戶體驗,但感覺更加笨拙。