作者: 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, 提供更好的用户体验, 但感觉更加笨拙.
来源: https://www.cnblogs.com/lwqlun/p/10354149.html