ASP.NET Core 的 HostedService 在数据库创建之前尝试访问它。

4

我正在使用.NET 5.0开发一个ASP.NET项目;在这个项目中,我使用Entity Framework,code first,并配合一个Sql Lite文件数据库。

startup.cs文件中,我使用以下代码来以编程方式创建和更新数据库模式:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env, DataContext dataContext)
{
    // migrate any database changes on startup (includes initial db creation)
    dataContext.Database.Migrate();

    ...
}

然后我有一个服务,用于清理数据库中某些表的旧数据。 它在启动时和定期地清理它们:

public class TimedDbCleanerService : IHostedService, IDisposable
{

    ...
}

有了这些功能:

public Task StartAsync(CancellationToken stoppingToken)
{
    _timer = new Timer(DoWork, null, TimeSpan.Zero,
        TimeSpan.FromHours(_dbCleanerSettings.Hours));

    return Task.CompletedTask;
}

private void DoWork(object state)
{
    // create scoped dbcontext
    using (var scope = _scopeFactory.CreateScope())
    {
        var dbContext = scope.ServiceProvider.GetRequiredService<DataContext>();

        // check and remove stuff

        dbContext.SaveChanges();
    }
}

startup.cs中使用以下代码进行注册:

services.AddHostedService<TimedDbCleanerService>();

这里的问题是,如果在执行项目之前数据库文件不存在,清理服务会尝试访问尚不存在的数据库表。

使用调试器,我可以看到在服务访问数据库之前调用了Database.Migrate(),但迁移似乎是一个异步任务,需要一些时间才能完成。

有没有办法在创建和启动清理服务之前等待迁移完全执行其工作?


1
你可以使用全局静态的 CancellationToken 并在后台服务中等待它被设置。当迁移结束时,你设置该令牌,后台服务继续执行。 - abdusco
@abdusco 看起来是一个不错的解决方案,你能否提供一个示例作为答案? - sorioli computec
2个回答

5
更好的选择可能是在启动应用程序之前执行迁移,这将使事情变得更加简单。不需要在线程之间进行同步。
public static void Main(string[] args)
{
    var host = CreateHostBuilder(args).Build();

    using (var scope = host.Services.CreateScope())
    {
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        db.Database.Migrate();
    }
    
    // database migration is completed
    
    host.Run();
}

由于此操作是在后台服务开始运行之前完成的,因此不存在竞态条件的风险。

现在后台服务变得更加简单:

class CleanerService : BackgroundService
{
    private readonly IServiceScopeFactory _serviceScopeFactory;

    public CleanerService(IServiceScopeFactory serviceScopeFactory)
    {
        _serviceScopeFactory = serviceScopeFactory;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await PerformCleanup();
        while (!stoppingToken.IsCancellationRequested)
        {
            await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
            await PerformCleanup();
        }
    }

    private async Task PerformCleanup()
    {
        using var scope = _serviceScopeFactory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        // ... clean things up
    }
}

2
创建一个类来协调迁移。这将执行迁移并使用SemaphoreSlim帮助其他人等待完成。
在这个类中,我们注入DbContext并运行迁移,然后让等待迁移的线程继续执行。
public class DatabaseMigrator
{
    private readonly AppDbContext _dbContext;

    private static readonly SemaphoreSlim _migrationEvent = new SemaphoreSlim(0);

    public DatabaseMigrator(AppDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public void Migrate()
    {
        _dbContext.Database.Migrate();
        _migrationEvent.Release(1);
    }

    public Task WaitForMigrationAsync(CancellationToken cancellationToken = default)
    {
        return _migrationEvent.WaitAsync(cancellationToken);
    }
}

将此类注册到 DI:

services.AddSingleton<DatabaseMigrator>();

在您的Main函数或Startup.Configure中,注入此函数并运行迁移:
public void Configure(IApplicationBuilder app, IWebHostEnvironment env, DatabaseMigrator databaseMigrator)
{
    databaseMigrator.Migrate();
    // ...
}

在后台服务中,我们不会注入迁移器,因为它依赖于作用域服务,如DbContext。相反,我们从创建的作用域中解析一个迁移器,并等待迁移完成。
class CleanerService : BackgroundService
{
    private IServiceScopeFactory _serviceScopeFactory;

    public CleanerService(IServiceScopeFactory serviceScopeFactory)
    {
        _serviceScopeFactory = serviceScopeFactory;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using (var scope = _serviceScopeFactory.CreateScope())
        {
            var migrator = scope.ServiceProvider.GetRequiredService<DatabaseMigrator>();
            await migrator.WaitForMigrationAsync(stoppingToken);
        }

        await PerformCleanup();
        while (!stoppingToken.IsCancellationRequested)
        {
            await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
            await PerformCleanup();
        }
    }

    private async Task PerformCleanup()
    {
        using var scope = _serviceScopeFactory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        // ... clean things up
    }
}

一个小建议:在异步上下文中,不要使用Timer,因为它会强制你使用void,这会在异步工作时带来大量问题。相反,你可以在循环中调用Task.Delay,这将释放线程到线程池,让它执行其他任务而不是等待:

await PerformCleanup();
while (!stoppingToken.IsCancellationRequested)
{
    await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
    await PerformCleanup();
}

你为什么发布了两个答案?是为了(a)回答实际问题,然后(b)提供一个未被问到但被推荐的更好问题的答案吗? - Konrad Viltersten
1
@KonradViltersten 是的,基本上是这样。在这个答案中,我提供了一个解决方案,但意识到 OP 实际想要做什么并给出了另一个更简单的解决方案。由于两个解决方案在其范围和方法上都相当不相关,所以我分别提交了答案。 - abdusco

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接