服务生命周期与错误处理

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中某些未处理异常仍可能导致电路终止,因此关键操作应在合适的位置捕获、记录并处理异常。

作者:Gacfox
版权声明:本网站为非盈利性质,文章如非特殊说明均为原创,版权遵循知识共享协议CC BY-NC-ND 4.0进行授权,转载必须署名,禁止用于商业目的或演绎修改后转载。