原文:如何在ASP.NET Core程序启动时运行异步任务(1)
原文:Running async tasks on app startup in ASP.NET Core (Part 1)
作者:Andrew Lock
译者:Lamond Lu
背景#
当我们做项目的时候,有时候希望自己的ASP.NET Core应用在启动前执行一些初始化逻辑。例如,你希望验证配置是否合法,填充缓存数据,或者运行数据库迁移脚本。在本篇博客中,我将介绍几种可选的方案,并且通过展示一些简单的方法和扩展点来说明我想要解决的问题。
开始我将先描述一下ASP.NET Core内置的解决方案,使用
为什么我们需要在程序启动时运行异步任务?#
在程序启动,开始监听请求之前,运行一些初始化代码是非常普遍的。对于一个ASP.NET Core应用程序,启动前有许多任务需要运行,例如:
- 确定当前的托管环境
- 从appsetting.json文件和环境变量中读取配置
- 配置依赖注入容器
- 构建依赖注入容器
- 配置中间件管道
以上几步都四发生在应用程序引导时。然而有些一次性任务需要在
- 检查强类型配置是否合法
- 使用数据库或者API填充缓存
- 运行数据库迁移脚本(这通常不是一个很好的方案,但是对于一些应用来说够用了)
有些时候,一些任务并不是非要在程序启动,监听请求前运行。这里我们以填充缓存为例,如果它是设计的比较好的话,在程序启动前是否填充缓存数据是无关紧要的。但是,相对的,你肯定也希望在应用程序开始监听请求之前,迁移你的数据库!
其实ASP.NET Core框架自己也需要运行一些一次性初始化任务。这个最好的例子就是数据保护,它常用来做数据加密,这个模块必须要在应用启动前初始化。为了实现初始化,它们使用了
使用IStartupFilter 来运行同步任务#
在之前的博客中,我已经介绍过
探索ASP.NET Core中的IStartupFilter
如何为ASP.NET Core的强类型配置对象添加验证
如果你是第一次接触Filter, 我建议你去我之前的博客,这里我只会提供一个简短的总结。
1 2 3 4 5 6 7 8 9 10 11 | <DIV class=esa-clipboard-button data-clipboard-target="#copy_target_0" data-tips="复制代码">Copy</DIV><wyn><SPAN class=hljs-keyword>public</SPAN> <SPAN class=hljs-keyword>class</SPAN> <SPAN class=hljs-title>AutoRequestServicesStartupFilter</SPAN> : <SPAN class=hljs-title>IStartupFilter</SPAN> { <SPAN class=hljs-function><SPAN class=hljs-keyword>public</SPAN> Action<IApplicationBuilder> <SPAN class=hljs-title>Configure</SPAN>(<SPAN class=hljs-params>Action<IApplicationBuilder> next</SPAN>) </SPAN>{ <SPAN class=hljs-keyword>return</SPAN> builder => { builder.UseMiddleware<RequestServicesContainerMiddleware>(); next(builder); }; } } |
这非常有用,但它与ASP.NET Core应用程序启动时运行一次性任务有什么关系呢?
问题是
为什么不用健康检查?#
ASP.NET Core 2.2中加入了一个新的健康检查功能,它通过暴露一个HTTP节点,让你可以查询当前应用的健康状态。当应用部署之后,像Kubernetes这样的编排引擎或HAProxy和NGINX等反向代理可以查询此HTTP节点以检查你应用是否已准备好开始接收请求。
你可以使用健康检查功能来确保你的应用程序不会开始处理请求,直到所有必需的一次性初始化任务完成为止。然而,这有一些缺点:
- WebHost和Kestrel本身将在执行一次性初始化任务之前启动,虽然他们不会收到可能存在问题的“真实”请求(仅健康检查请求)。
- 这种方式会引入了额外的复杂度,除了添加运行一次性任务的代码之外,还需要添加运行状况检查以测试任务是否完成,并同步任务的状态。
- 应用程序的启动会有延迟,因为需要等待所有任务完成,所以不太可能减少启动时间。
- 如果任务失败,应用程序不会终止,而且健康检查也永远不会通过。这可能是可以接受的,但是我个人更喜欢让应用程序立刻终止。
- 使用健康检查,并不能知道一次性任务运行的怎么样,你只能了解到任务是否完成。
在我看来,健康检查并不适合一次性任务的场景,他们可能对我描述的一些例子很有用,但我不认为它适用于所有情况。我真的希望能在
运行异步任务#
我已经花了很长的篇幅来讨论了所有不能完成我的目标的所有方法,那么哪些才是可行的方案!在这一节中,我将描述几种运行异步任务的方案(即方法返回
这里为了更清楚的描述这些方案,我选用数据库迁移作为例子。在EF Core中,你可以在运行时调用
EF还提供了一个同步的数据库迁移方法
Database.Migrate() ,但是这里我们不需要使用它。
使用IStartupFilter #
我之前描述过如何使用
警告:这是一种非常不好的异步实践方式
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | <DIV class=esa-clipboard-button data-clipboard-target="#copy_target_1" data-tips="复制代码">Copy</DIV><wyn><SPAN class=hljs-keyword>public</SPAN> <SPAN class=hljs-keyword>class</SPAN> <SPAN class=hljs-title>MigratorStartupFilter</SPAN>: <SPAN class=hljs-title>IStartupFilter</SPAN> { <SPAN class=hljs-keyword>private</SPAN> <SPAN class=hljs-keyword>readonly</SPAN> IServiceProvider _serviceProvider; <SPAN class=hljs-function><SPAN class=hljs-keyword>public</SPAN> <SPAN class=hljs-title>MigratorStartupFilter</SPAN>(<SPAN class=hljs-params>IServiceProvider serviceProvider</SPAN>) </SPAN>{ _serviceProvider = serviceProvider; } <SPAN class=hljs-function><SPAN class=hljs-keyword>public</SPAN> Action<IApplicationBuilder> <SPAN class=hljs-title>Configure</SPAN>(<SPAN class=hljs-params>Action<IApplicationBuilder> next</SPAN>) </SPAN>{ <SPAN class=hljs-keyword>using</SPAN>(<SPAN class=hljs-keyword>var</SPAN> scope = _seviceProvider.CreateScope()) { <SPAN class=hljs-keyword>var</SPAN> myDbContext = scope.ServiceProvider.GetRequiredService<MyDbContext>(); myDbContext.Database.MigrateAsync() .GetAwaiter() .GetResult(); } <SPAN class=hljs-keyword>return</SPAN> next; } } |
这段代码可能不会引起任何问题,它会在应用程序启动且未开始监听请求时运行,所以不太可能出现死锁。但是坦率的说,我会尽可能不用这种方式。
使用IApplicationLifetime 事件#
我之前还没有讨论过和这个事件相关的内容,但是当你的应用程序启动和关闭前,你可以使用
ApplicationStarted事件仅在WebHost启动后触发,因此任务在应用程序开始接受请求后运行。
鉴于他们没有解决
使用IHostedService 运行异步事件#
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | <DIV class=esa-clipboard-button data-clipboard-target="#copy_target_2" data-tips="复制代码">Copy</DIV><wyn><SPAN class=hljs-keyword>public</SPAN> <SPAN class=hljs-keyword>class</SPAN> <SPAN class=hljs-title>MigratorHostedService</SPAN>: <SPAN class=hljs-title>IHostedService</SPAN> { <SPAN class=hljs-keyword>private</SPAN> <SPAN class=hljs-keyword>readonly</SPAN> IServiceProvider _serviceProvider; <SPAN class=hljs-function><SPAN class=hljs-keyword>public</SPAN> <SPAN class=hljs-title>MigratorStartupFilter</SPAN>(<SPAN class=hljs-params>IServiceProvider serviceProvider</SPAN>) </SPAN>{ _serviceProvider = serviceProvider; } <SPAN class=hljs-function><SPAN class=hljs-keyword>public</SPAN> <SPAN class=hljs-keyword>async</SPAN> Task <SPAN class=hljs-title>StartAsync</SPAN>(<SPAN class=hljs-params>CancellationToken cancellationToken</SPAN>) </SPAN>{ <SPAN class=hljs-keyword>using</SPAN>(<SPAN class=hljs-keyword>var</SPAN> scope = _seviceProvider.CreateScope()) { <SPAN class=hljs-keyword>var</SPAN> myDbContext = scope.ServiceProvider.GetRequiredService<MyDbContext>(); <SPAN class=hljs-keyword>await</SPAN> myDbContext.Database.MigrateAsync(); } } <SPAN class=hljs-function><SPAN class=hljs-keyword>public</SPAN> Task <SPAN class=hljs-title>StopAsync</SPAN>(<SPAN class=hljs-params>CancellationToken cancellationToken</SPAN>) </SPAN>{ <SPAN class=hljs-keyword>return</SPAN> Task.CompletedTask; } } |
不幸的是,
IHostService 的典型实现期望StartAsync 方法能够相对快速返回。对于后台任务来说,它希望你能够以异步分当时启动服务,但是大多数任务都是在启动代码之外。迁移数据库的任务会阻止其他IHostService 启动(这里我不太理解作者的意思,只是按字面意思翻译,后续会更新这里)。
- 第二个问题是最大的问题,你的应用程序会在
IHostService 运行数据库迁移之前开始接受请求,这显然不是我们想要的。
在Program.cs 中手动运行任务
到现在为止,我们都没有提供一种完善的解决方案,他们或者是使用同步方式处理异步任务,或者是不能阻止程序启动。
现在让我们停止尝试使用框架机制,手动来完成工作。
ASP.NET Core模板中使用的默认
1 2 3 4 5 6 7 8 9 10 11 | <DIV class=esa-clipboard-button data-clipboard-target="#copy_target_3" data-tips="复制代码">Copy</DIV><wyn><SPAN class=hljs-keyword>public</SPAN> <SPAN class=hljs-keyword>class</SPAN> <SPAN class=hljs-title>Program</SPAN> { <SPAN class=hljs-function><SPAN class=hljs-keyword>public</SPAN> <SPAN class=hljs-keyword>static</SPAN> <SPAN class=hljs-keyword>void</SPAN> <SPAN class=hljs-title>Main</SPAN>(<SPAN class=hljs-params><SPAN class=hljs-keyword>string</SPAN>[] args</SPAN>) </SPAN>{ CreateWebHostBuilder(args).Build().Run(); } <SPAN class=hljs-function><SPAN class=hljs-keyword>public</SPAN> <SPAN class=hljs-keyword>static</SPAN> IWebHostBuilder <SPAN class=hljs-title>CreateWebHostBuilder</SPAN>(<SPAN class=hljs-params><SPAN class=hljs-keyword>string</SPAN>[] args</SPAN>) </SPAN>=> WebHost.CreateDefaultBuilder(args) .UseStartup<Startup>(); } |
这里你可能会发现在
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | <DIV class=esa-clipboard-button data-clipboard-target="#copy_target_4" data-tips="复制代码">Copy</DIV><wyn><SPAN class=hljs-keyword>public</SPAN> <SPAN class=hljs-keyword>class</SPAN> <SPAN class=hljs-title>Program</SPAN> { <SPAN class=hljs-function><SPAN class=hljs-keyword>public</SPAN> <SPAN class=hljs-keyword>static</SPAN> <SPAN class=hljs-keyword>async</SPAN> Task <SPAN class=hljs-title>Main</SPAN>(<SPAN class=hljs-params><SPAN class=hljs-keyword>string</SPAN>[] args</SPAN>) </SPAN>{ IWebHost webHost = CreateWebHostBuilder(args).Build(); <SPAN class=hljs-keyword>using</SPAN> (<SPAN class=hljs-keyword>var</SPAN> scope = webHost.Services.CreateScope()) { <SPAN class=hljs-keyword>var</SPAN> myDbContext = scope.ServiceProvider.GetRequiredService<MyDbContext>(); <SPAN class=hljs-keyword>await</SPAN> myDbContext.Database.MigrateAsync(); } <SPAN class=hljs-keyword>await</SPAN> webHost.RunAsync(); } <SPAN class=hljs-function><SPAN class=hljs-keyword>public</SPAN> <SPAN class=hljs-keyword>static</SPAN> IWebHostBuilder <SPAN class=hljs-title>CreateWebHostBuilder</SPAN>(<SPAN class=hljs-params><SPAN class=hljs-keyword>string</SPAN>[] args</SPAN>) </SPAN>=> WebHost.CreateDefaultBuilder(args) .UseStartup<Startup>(); } |
这个方案有以下优点:
- 我们使用的是真正的异步,而不是使用同步方式处理异步任务
- 我们可以使用异步方式运行任务
- 只有当我们的异步任务都完成之后,WebHost才会启动
- 在这个时间点,依赖注入容易已经构建完成,我们可以使用它来创建服务
但是这种方法也存在一些问题:
- 即使依赖注入容器构建完成,但是中间件管道却还没有完成构建。只有当你调用
Run() 或者RunAsync() 方法之后,中间件管道才开始构建。当构建中间件管道时,IStartupFilter 才会被执行,然后程序启动。如果你的异步任务需要在以上任何步骤中配置,那你就不走运了。
- 我们失去了通过向依赖注入容器添加服务来自动运行任务的能力。 我们只能手动运行任务。
如果这些问题都不是问题,那么我认为这个最终选项提供了解决问题的最佳方案。 在我的下一篇文章中,我将展示一些方法,我们可以在这个例子的基础上构建,以使某些内容更容易使用。
总结#
在这篇文章中,我讨论了在ASP.NET Core应用程序启动时执行异步运行任务的必要性。 我描述了这样做的一些问题和挑战。 对于同步任务,