如何在 EF Core 中具有作用域 Dbcontext 的同时实现数据库弹性?

How to implement database resiliency while having a scoped Dbcontext in EF Core?

根据 CQRS 模式,我有一个简单的命令如下:

public sealed class EditPersonalInfoCommandHandler : ICommandHandler<EditPersonalInfoCommand> {

        private readonly AppDbContext _context;

        public EditPersonalInfoCommandHandler(AppDbContext context) {
            _context = context;
        }

        public Result Handle(EditPersonalInfoCommand command) {
            var studentRepo = new StudentRepository(_context);
            Student student = studentRepo.GetById(command.Id);
            if (student == null) {
                return Result.Failure($"No Student found for Id {command.Id}");
            }

            student.Name = command.Name;
            student.Email = command.Email;

            _context.SaveChanges();
            return Result.Success();
        }

}

现在我需要尝试 _context.SaveChanges() 最多 5 次,如果失败并出现异常。为此,我可以简单地在方法中使用一个 for 循环:

for(int i = 0; i < 5; i++) {
    try {
        //required logic
    } catch(SomeDatabaseException e) {
        if(i == 4) {
           throw;
        }
    }
}

要求将方法作为一个单元来执行。问题是,一旦 _context.SaveChanges() 抛出异常,就不能使用相同的 _context 重新尝试逻辑。文档说:

Discard the current DbContext. Create a new DbContext and restore the state of your application from the database. Inform the user that the last operation might not have been completed successfully.

但是,在 Startup.cs 中,我将 AppDbContext 作为作用域依赖项。为了重新尝试方法逻辑,我需要一个 AppDbContext 的新实例,但注册为作用域将不允许这样做。

我想到的一个解决方案是使 AppDbContext 成为瞬态。但我有一种感觉,这样做会为我自己打开一整套新问题。有人可以帮我吗?

保存上下文时至少有太多错误。第一个发生在命令执行期间。第二次发生在提交期间(这种情况很少发生)。 即使数据已成功更新,也可能会发生第二个错误。所以你的代码只处理第一种错误,而没有考虑第二种错误。

对于第一种错误,您可以在命令处理程序中注入 DbContext 工厂或直接使用 IServiceProvider。这是一种反模式,但在这种情况下我们别无选择,如下所示:

readonly IServiceProvider _serviceProvider;
public EditPersonalInfoCommandHandler(IServiceProvider serviceProvider) {
        _serviceProvider = serviceProvider;
}

for(int i = 0; i < 5; i++) {
  try {
    using var dbContext = _serviceProvider.GetRequiredService<AppDbContext>();
    //consume the dbContext
    //required logic
  } catch(SomeDatabaseException e) {
    if(i == 4) {
       throw;
    }
  }
}

然而正如我所说,要处理这两种错误,我们应该在 EFCore 中使用所谓的 IExecutionStrategy。介绍了几个选项 here。但我认为以下内容最适合您的情况:

public Result Handle(EditPersonalInfoCommand command) {
    var strategy = _context.Database.CreateExecutionStrategy();
    
    var studentRepo = new StudentRepository(_context);
    Student student = studentRepo.GetById(command.Id);
    if (student == null) {
        return Result.Failure($"No Student found for Id {command.Id}");
    }

    student.Name = command.Name;
    student.Email = command.Email;
    const int maxRetries = 5;
    int retries = 0;
    strategy.ExecuteInTransaction(_context,
                                  context => {
                                      if(++retries > maxRetries) {
                                         //you need to define your custom exception to be used here
                                         throw new CustomException(...);
                                      }
                                      context.SaveChanges(acceptAllChangesOnSuccess: false);
                                  },
                                  context => context.Students.AsNoTracking()
                                                    .Any(e => e.Id == command.Id && 
                                                              e.Name == command.Name &&
                                                              e.Email == command.Email));

    _context.ChangeTracker.AcceptAllChanges();
    return Result.Success();
}

请注意,我想您的上下文通过 属性 Students 公开了 DbSet<Student>。 如果您有任何其他与连接无关的错误,IExecutionStrategy 将不会处理它,这很有意义。因为那是您需要修复逻辑的时候,所以重试数千次无济于事,而且总是以该错误告终。这就是为什么我们不需要关心详细的最初抛出的Exception(当我们使用IExecutionStrategy时不会暴露)。相反,我们使用自定义异常(如我上面的代码中所述)来通知由于某些与连接相关的问题而导致保存更改失败。