Blazor Server应用EF Core DbContext实体跟踪与生命周期问题求助
我正在开发Blazor Server应用,执行操作时遇到两个关联DbContext生命周期和EF Core实体跟踪的错误。我的解决方案分为4个项目:
- 后端项目:采用仓储-服务架构,使用MySQL数据库
- 类库项目:存放所有模型定义
- Blazor Server前端项目
- xUnit测试项目
错误1:实体跟踪冲突
System.InvalidOperationException:实体类型“Provincia”的实例无法被跟踪,因为另一个具有相同{'IdProvincia'}键值的实例已被跟踪。附加现有实体时,请确保仅附加一个具有给定键值的实体实例。可考虑使用'DbContextOptionsBuilder.EnableSensitiveDataLogging'查看冲突的键值
该错误出现在Crear.razor中尝试持久化Localidad实体时,此时DbContext的服务生命周期为Transient。
错误2:Scoped生命周期下刷新报错
如果将DbContext的服务生命周期改为Scoped(或使用默认值),CRUD操作可以正常运行,但按F5刷新页面会报如下错误:
InvalidOperationException:上一个操作完成前,上下文又启动了第二个操作。这通常是由不同线程使用同一个DbContext实例导致的。
- 服务生命周期不匹配:你将Service注册为Scoped,就算DbContext是Transient,Scoped的Service在初始化时只会解析一次DbContext,整个Blazor连接生命周期内都会复用同一个DbContext实例,导致之前查询的实体一直被跟踪,插入关联实体时触发主键冲突。
- Blazor Server的Scoped生命周期是跨请求的:和传统MVC/Razor Pages的请求级Scoped不同,Blazor Server的Scoped生命周期对应整个SignalR连接,用户打开页面后DbContext会一直存活,并发操作或刷新时容易触发线程安全问题。
- 代码存在不合理操作:插入
Localidad时重复查询关联的Provincia实体,同时存在async void这类不安全的异步写法。
步骤1:改用DbContextFactory(Blazor Server官方推荐方案)
修改Startup.cs的服务注册,替换原有DbContext注册逻辑:
public void ConfigureServices(IServiceCollection services){ services.AddRazorPages(); services.AddServerSideBlazor(); services.AddLocalization(opts => { opts.ResourcesPath = "Resources"; }); // 替换原有AddDbContext,改为AddDbContextFactory services.AddDbContextFactory<LengaContext>(opt => opt.UseSqlServer( Configuration.GetConnectionString("MyDB")), ServiceLifetime.Transient); services.AddScoped<IProvinciaService, ProvinciaService>(); services.AddScoped<ILocalidadService, LocalidadService>(); services.AddTransient<IAuthorizationHandler, IsOperadorHandler>(); services.AddScoped<IStringLocalizer<App>, StringLocalizer<App>>(); }
步骤2:修改仓储基类,每次操作创建新的DbContext
修改Service.cs,改为注入IDbContextFactory<LengaContext>,每次数据库操作都创建新的上下文,操作完成后自动释放:
namespace CapacitacionesBE.Data { public class Service<T> : IRepository<T> where T : class { protected readonly IDbContextFactory<LengaContext> _contextFactory; public Service(IDbContextFactory<LengaContext> contextFactory) { _contextFactory = contextFactory; } public async Task Delete(int Id) { using var context = _contextFactory.CreateDbContext(); var ItemToDelete = context.Set<T>().Find(Id); if (ItemToDelete != null) { context.Set<T>().Remove(ItemToDelete); try { await context.SaveChangesAsync(); } catch (DbUpdateConcurrencyException) { throw; } } } public void Dispose() { // 不再需要手动释放Context,每次using会自动释放 } public async Task<List<T>> GetAll() { try { using var context = _contextFactory.CreateDbContext(); var Items = await context.Set<T>().ToListAsync(); return Items; } catch (Exception ex) { throw new Exception($"无法拉取实体列表: {ex.Message}"); } } public async Task<T> GetById(int Id) { using var context = _contextFactory.CreateDbContext(); var Item = await context.Set<T>().FindAsync(Id); return Item; } public async Task<T> Insert(T Element) { if (Element != null) { using var context = _contextFactory.CreateDbContext(); context.Set<T>().Add(Element); try { await context.SaveChangesAsync(); } catch (DbUpdateConcurrencyException) { throw; } return Element; } else { return null; } } public async Task<T> Update(T Element) { using var context = _contextFactory.CreateDbContext(); context.Set<T>().Update(Element); try { await context.SaveChangesAsync(); } catch (DbUpdateConcurrencyException) { throw; } return Element; } } }
其他Service(比如LocalidadService)同理,修改构造函数注入的参数为IDbContextFactory<LengaContext>,所有数据库操作都用CreateDbContext创建的上下文执行。
步骤3:优化前端代码,消除冗余操作和不安全写法
修改Crear.razor的代码:
- 去掉冗余的Provincia查询,直接给外键字段
IdProvincia赋值 - 把
async void改为async Task,避免异步操作异常泄漏 - 去掉不必要的
Task.Run包裹
@code { List<Provincia> Provincias; public Localidad Localidad = new Localidad(); public int SelectedProvincia { get; set; } private async Task HandleSubmit() { Logger.LogInformation("Handling submit"); await CrearLocalidad(); } protected async Task CrearLocalidad() { // 直接赋值外键,不需要查询Provincia实体,避免跟踪冲突 Localidad.IdProvincia = SelectedProvincia; await _serviceLocalidad.Insert(Localidad); NavigationManager.NavigateTo("Localidades"); } protected override async Task OnInitializedAsync() { // 去掉不必要的Task.Run Provincias = await _serviceProvincia.GetAll(); var PrimerProvincia = Provincias.FirstOrDefault(); if (PrimerProvincia != null) { SelectedProvincia = PrimerProvincia.IdProvincia; } } void Cancel() { NavigationManager.NavigateTo("Localidades"); } }
如果需要在插入后返回带关联Provincia的Localidad实体,可以在Insert操作后手动Include查询,不需要直接赋值导航属性插入,进一步降低跟踪冲突概率。
内容的提问来源于stack exchange,提问作者Mauro Alvarez

