服务生命周期与错误处理
Blazor组件会依赖各种服务、数据库上下文、定时器和异步任务。交互式Server应用中的对象可能随着用户电路长期存在,因此不能简单照搬普通ASP.NET Core请求的生命周期假设。这篇笔记介绍Scoped服务、EF Core上下文、取消与释放,以及组件错误边界。
Blazor Server的Scoped服务与电路
交互式Server为每个客户端维护一个电路(circuit),其中包含SignalR连接、组件树和相关的渲染状态。用户在应用内部导航时,只要电路仍然存在,同一个电路中的组件就可以继续共享该电路的依赖注入作用域。
因此,不同渲染方式下Scoped的含义并不相同:
- 静态SSR:作用域通常随一次HTTP请求创建和释放,与常见ASP.NET Core请求类似。
- 交互式Server:Scoped服务通常随电路创建,并在电路最终结束后释放,而不是在每次HTTP请求或每个组件销毁时释放。暂时断线后,服务器还可能在保留期内保存电路,等待客户端重连。
- 交互式WebAssembly:Scoped服务通常在整个浏览器应用生命周期内复用,效果更接近应用级单例。
这意味着在Blazor Server中直接向多个组件注入Scoped服务,它们可能拿到同一电路中的同一个实例。可以用它保存该用户的电路级状态,但不要把它误认为组件级或请求级对象;Singleton更是跨电路、跨用户共享,不应保存某个用户可变的私有状态。
如果服务必须与组件同生命周期,可以继承OwningComponentBase,通过其ScopedServices创建组件自己的DI作用域,而不是直接使用@inject取得的电路级实例。组件销毁时,这个自有作用域及其中可释放的服务也会释放。
使用IDbContextFactory管理DbContext
DbContext不是线程安全对象,也不适合作为长生命周期对象在整个电路中共享。Blazor Server中使用EF Core时,通常推荐注入IDbContextFactory<TContext>,按一个工作单元或组件生命周期创建独立上下文,并在用完后释放。
builder.Services.AddDbContextFactory<AppDbContext>(options =>
options.UseSqlServer(
builder.Configuration.GetConnectionString("DefaultConnection")));
对于一次独立操作,可以把上下文限制在方法内:
@inject IDbContextFactory<AppDbContext> DbFactory
@code {
private async Task SaveAsync(Student student)
{
await using var db = await DbFactory.CreateDbContextAsync();
db.Students.Add(student);
await db.SaveChangesAsync();
}
}
如果确实需要让上下文承担整个组件的变更跟踪工作,也可以在组件初始化时创建,并实现IAsyncDisposable:
@implements IAsyncDisposable
@inject IDbContextFactory<AppDbContext> DbFactory
@code {
private AppDbContext? db;
protected override void OnInitialized()
{
db = DbFactory.CreateDbContext();
}
public async ValueTask DisposeAsync()
{
if (db is not null)
{
await db.DisposeAsync();
}
}
}
不要在同一个DbContext实例上并发执行多个数据库操作,也不要把它保存到Singleton服务中供多个电路使用。工厂本身可以长期复用,但每次创建的上下文都应有清晰的所有者和释放位置。
取消异步工作与释放组件资源
用户导航离开组件时,正在执行的HTTP请求、数据库查询或后台任务不会自动取消。如果任务完成后还尝试更新已销毁的组件,可能产生无意义的工作或异常。组件可以持有CancellationTokenSource,把令牌传入异步操作,并在释放时发出取消信号。
@implements IDisposable
@inject StudentService StudentService
@code {
private readonly CancellationTokenSource cts = new();
private async Task LoadAsync()
{
try
{
var students = await StudentService.GetStudentsAsync(cts.Token);
cts.Token.ThrowIfCancellationRequested();
// 在这里更新组件状态。
}
catch (OperationCanceledException) when (cts.IsCancellationRequested)
{
// 组件销毁或主动取消导致的正常退出。
}
}
public void Dispose()
{
cts.Cancel();
cts.Dispose();
}
}
异步API必须真正接收并响应CancellationToken,取消才不只是停留在组件层面。取消只表示调用方不再希望继续,不应把它用于静默吞掉其他异常。
除了取消任务,组件还应在IDisposable.Dispose()或IAsyncDisposable.DisposeAsync()中释放自己拥有的资源,例如释放定时器、退订.NET事件、释放JS对象引用和异步资源。由DI容器管理的服务通常由容器释放,组件不要重复释放注入但不归自己所有的同一实例。
使用ErrorBoundary隔离组件错误
ErrorBoundary可以在其子组件发生未处理异常时显示替代UI,避免整棵组件树只能显示默认错误信息。通常应把错误边界放在较小、可独立恢复的UI区域,而不是不加区分地包住整个应用。
<ErrorBoundary @ref="errorBoundary">
<ChildContent>
<StudentList />
</ChildContent>
<ErrorContent>
<div class="alert alert-danger" role="alert">
学生列表加载失败。
<button @onclick="() => errorBoundary?.Recover()">重试</button>
</div>
</ErrorContent>
</ErrorBoundary>
@code {
private ErrorBoundary? errorBoundary;
}
进入错误状态后,边界会显示ErrorContent;调用Recover()可以清除当前异常并重新尝试呈现子内容。如果把错误边界放在布局中,它可能在页面导航后仍保留错误状态,通常需要在布局的OnParametersSet()中调用Recover(),让新页面获得正常的渲染机会。
ErrorBoundary不是全局异常处理中间件,也不能替代业务代码中的日志、数据访问异常处理和JS互操作异常处理。交互式Server中某些未处理异常仍可能导致电路终止,因此关键操作应在合适的位置捕获、记录并处理异常。