使用 Blazor 的 OwningComponentBase





5.00/5 (3投票s)
深入了解如何使用 Blazor 的 OwningComponentBase
引言
OwningComponentBase
是一个 Blazor 组件,它拥有自己的依赖注入容器。其目的是比 SPA 级别的容器提供更精细地控制作用域(scoped)和服务(transient)的生命周期。
在本文中,我将深入探讨如何使用它。它有一些特点,并且其设计本身存在一些固有的问题。
引用微软文档的话说:
使用 OwningComponentBase 类作为基类来编写控制服务提供程序作用域生命周期的组件。当使用需要释放的瞬时(transient)或作用域(scoped)服务(如存储库或数据库抽象)时,这非常有用。使用 OwningComponentBase 作为基类可确保服务提供程序作用域与组件一起被释放。
听起来很棒,但很少有人使用它。我不知道是因为他们不知道它,认为它太难使用,还是尝试过它并被它的问题所困扰。
OwningComponentBase
有两种形式
OwningComponentBase
,您可以在其中手动设置和使用组件 DI 服务容器中的任何服务。OwningComponentBase<TService>
,其中TService
被添加到容器中并作为Service
提供。您可以手动添加其他服务。
我不会重复关于基本用法的那些陈旧信息。您可以在微软文档和几篇文章及视频中找到它们。
存储库
本文的存储库在这里 - Blazr.OwningComponentBase。
术语
在 Blazor 中,术语很容易混淆。
- DI 是依赖注入(Dependency Injection)的缩写
- 组件 DI 容器 是与实现
OwningComponentBase
的组件相关联的 DI 容器 - 通常是页面(另一个令人困惑的术语)。 - SPA DI 容器 是与浏览器标签页中运行的当前单页应用程序实例相关联的 DI 容器。请注意,F5 会关闭并重新初始化 SPA DI 容器。每个浏览器标签页都有一个单独的 SPA DI 容器。
- 应用程序 DI 容器 是最高级别的 DI 实例,所有单例(Singleton)实例都在此运行。只有一个这样的容器,它在应用程序实例的整个生命周期中都存在。
典型场景
我们有一个 UI 表单,用于显示俱乐部成员列表。它使用一个连接到数据管道的视图服务来管理列表,并使用一个通知服务来通知实时表单,如果底层数据发生更改(例如,在模态对话框中进行编辑或删除)。UI 表单在网站的多个上下文中都使用:预订会议的成员列表、未付款的成员等,因此我们不希望使用单个作用域的视图服务。
有两种方法
- 将表单设置为继承自
OwningComponentBase
并使用内置的 DI 服务容器。 - 将视图服务设置为瞬时服务,每次使用表单时都创建一个新实例。
一些测试服务和一个测试页面来查看这两种选项。
测试服务
一个瞬时服务类。
public class TransientService : IDisposable
{
public Guid Uid = Guid.NewGuid();
public TransientService() =>
Debug.WriteLine($"{this.GetType().Name} - created instance: {Uid}");
public virtual void Dispose() =>
Debug.WriteLine($"{this.GetType().Name} - Disposed instance: {Uid}");
}
一个实现了基本通知模式的通知服务。
public class NotificationService : IDisposable
{
public Guid Uid = Guid.NewGuid();
public event EventHandler? Updated;
public string Message { get; private set; } = string.Empty;
public NotificationService() =>
Debug.WriteLine($"{this.GetType().Name} - created instance: {Uid}");
public void Dispose() =>
Debug.WriteLine($"{this.GetType().Name} - Disposed instance: {Uid}");
public void NotifyChanged()
{
this.Message = $"Updated at {DateTime.Now.ToLongTimeString()}";
this.Updated?.Invoke(this, EventArgs.Empty);
}
}
以及一个使用这两个服务的视图服务。
using System.Diagnostics;
namespace Blazr.OwningComponentBase.Data;
public class ViewService : IDisposable
{
public Guid Uid = Guid.NewGuid();
public NotificationService NotificationService;
public TransientService TransientService;
public ViewService(NotificationService notificationService,
TransientService transientService)
{
Debug.WriteLine($"{this.GetType().Name} - created instance: {Uid}");
NotificationService = notificationService;
TransientService = transientService;
}
public void UpdateView()
=> NotificationService.NotifyChanged();
public void Dispose()
=> Debug.WriteLine($"{this.GetType().Name} - Disposed instance: {Uid}");
}
这些服务注册到应用程序服务容器如下:
builder.Services.AddScoped<ViewService>(); builder.Services.AddScoped<NotificationService>(); builder.Services.AddTransient<TransientService>();
实例问题
一个测试页面来演示这一点。它继承自 OwningComponentBase<TService>
。
@page "/"
@inherits OwningComponentBase<ViewService>
@implements IDisposable
@inject NotificationService NotificationService
<h1>OwningComponentBase Test 1</h1>
<div class="alert alert-primary">
<h5>Service Info</h5>
<div>
Service Id: @Service.Uid
</div>
<div>
Service => Notification Service Id: @Service.NotificationService?.Uid
</div>
<div>
Service => Notification Service Message: @Service.NotificationService?.Message
</div>
<div class="text-end">
<button class="btn btn-primary"
@onclick=this.UpdateView>Update View Notification Service Message</button>
</div>
</div>
<div class="alert alert-info">
<h5>Component Info</h5>
<div>
Local Notification Service Id: @NotificationService.Uid
</div>
<div>
Local Component Message: @NotificationService.Message
</div>
<div class="text-end">
<button class="btn btn-primary"
@onclick=this.UpdateLocal>Update Component Notification Service Message
</button>
</div>
</div>
@code {
protected override void OnInitialized()
=> NotificationService.Updated += this.OnUpdate;
private void OnUpdate(object? sender, EventArgs e)
=> this.InvokeAsync(StateHasChanged);
private void UpdateView()
=> Service.UpdateView();
private void UpdateLocal()
=> NotificationService.NotifyChanged();
protected override void Dispose(bool disposing)
{
NotificationService.Updated -= this.OnUpdate;
base.Dispose(disposing);
}
}
当您运行解决方案时,页面如下所示:
检查 NotificationService
实例上的 GUID:有两个,因此有两个活跃的服务实例。
ViewService
实例在组件 DI 容器中创建。它在其构造函数中定义了一个 NotificationService
。组件 DI 容器从应用程序级别的服务工厂获取服务定义。由于它是一个作用域服务,它会检查自己当前是否有实例,发现没有,因此会创建一个。
测试页面注入了 NotificationService
。此请求在页面创建期间由 SPA 容器处理,SPA 容器提供了其实例。这与(即将创建的)组件 DI 服务容器中的实例不同。
如果 ViewService
在 NotificationService
上发出通知,它会在组件 DI 实例上发出,而不是 SPA DI 实例上。反之亦然,它不会收到 SPA 实例的任何通知,因为它在组件实例上注册了一个处理程序。
我相信许多程序员都会在这个障碍处跌倒并放弃。
修复实例问题
修复它的关键在于:
- 对 DI 基础知识有深刻的理解
- 理解需要注入哪些服务实例
视图服务更改
将 NotificationService
更改为属性,并添加一些异常检查,以确保我们在服务设置好之前不使用它。
private NotificationService? _notificationService;
public NotificationService NotificationService
{
get
{
if (_notificationService is null)
throw new InvalidOperationException("No service is registered.
You must run SetParentServices before using the service.");
return _notificationService!;
}
}
更新构造函数以删除 NotificationService
注入。
public ViewService(TransientService transientService)
{
Debug.WriteLine($"{this.GetType().Name} - created instance: {Uid}");
TransientService = transientService;
}
添加一个 SetServices
方法。此方法从提供的 IServiceProvider
实例获取 NotificationService
。
我们从组件调用此方法,并传入 SPA DI 服务提供程序。如果我们不这样做,第一次尝试使用它时就会出现我们刚刚编写的代码所导致的异常!
public void SetServices(IServiceProvider serviceProvider)
=> _notificationService = serviceProvider.GetService<NotificationService>();
Test.razor 的更改
注入 SPA 服务提供程序。
@inject IServiceProvider SpaServiceProvider
并在 OnInitialized
中调用 Service
上的 SetServices
。
protected override void OnInitialized()
{
Service.SetServices(SpaServiceProvider);
NotificationService.Updated += this.OnUpdate;
}
现在我们看到所有人都使用了相同的 NotificationService
DI 实例。GUID 相同,通知也正常工作。
处置问题
DI 容器会保留它创建的所有实现 IDisposable
或 IAsyncDisposable
的对象。这样做是为了在容器本身被释放时运行这些对象的 Dispose
方法。垃圾回收器不会销毁这些对象,因为它们仍然被(容器)引用。瞬时对象开始累积,导致内存泄漏,直到 SPA 会话结束。
您可以在我们的测试中看到这个问题。这是服务创建和处置的日志。查看 TransientService
条目。瞬时服务 4984252f
在我们退出页面时被处置,因为它存在于组件 DI 容器中。瞬时服务 333012f6
在 SPA DI 容器中由页面创建,并且没有被处置。下次访问页面时,会创建另一个实例。
// First Load
Page => TransientService - created instance: 333012f6-452b-4105-9372-a67fb45c5b16
Page => NotificationService - created instance: 03817ba1-4dc1-4f62-b111-d1a8ceb28aac
OCB => TransientService - created instance: 4984252f-2308-4feb-b64c-4df34bc4688d
Page => ViewService - created instance: dfb929fc-3d54-4d42-a501-77be735d61c0
// Exit Page
Page => ViewService - Disposed instance: dfb929fc-3d54-4d42-a501-77be735d61c0
OCB => TransientService - Disposed instance: 4984252f-2308-4feb-b64c-4df34bc4688d
// Back to Page
Page => TransientService - created instance: 07bf1685-24b3-4714-8ecc-f9c82d6cc4ca
OCB => TransientService - created instance: 11f35c6f-7cf1-420a-9bf3-90f29108d8e3
Page => ViewService - created instance: f1451c57-9c87-4924-b171-f0fcbd7b5741
// Exit Page
Page => ViewService - Disposed instance: f1451c57-9c87-4924-b171-f0fcbd7b5741
OCB => TransientService - Disposed instance: 11f35c6f-7cf1-420a-9bf3-90f29108d8e3
因此,一般规则是永远不要在瞬时服务中实现 IDisposable
或 IAsyncDisposable
或任何需要处置的功能。由于几乎**所有**数据库活动都需要某种形式的托管处置,因此最好不要在瞬时服务中执行任何与数据库相关的操作。
正如您在上面所见,使用 OwningComponentBase
及其短暂的 DI 容器有一种解决方法。在上面的代码中,瞬时服务在每次离开页面时都会被处置。
但是,我建议不要以这种方式实现服务。很容易意外地开始在主 SPA 容器中使用此类服务。最好将服务设置为 Scoped
,然后仅在 OwningComponentBase
中使用它。
Dispose 问题
在测试页面中,我们连接了一个事件处理程序,需要在处置时分离它。
public void Dispose()
=> NotificationService.Updated -= this.OnUpdate;
这会隐藏 OwningComponentBase
的实现,因此它永远不会被运行。它包含一些相当重要的代码来处置 DI 容器。
void IDisposable.Dispose()
{
if (!IsDisposed)
{
_scope?.Dispose();
_scope = null;
Dispose(disposing: true);
IsDisposed = true;
}
}
正确的方法是重写 Dispose(bool disposing)
。
protected override void Dispose(bool disposing)
{
NotificationService.Updated -= this.OnUpdate;
base.Dispose(disposing);
}
摘要
OwningComponentBase
是一个很好的工具,您应该将其纳入您的工具箱。您只需要知道如何部署它。
一些规则/指南
- 确保您了解您想使用哪些 DI 服务实例。
- 在开发过程中,使用 GUID 和日志记录来跟踪实例的创建/处置。这可能非常有启发性!
- 瞬时服务永远不应实现或需要实现处置。
- 继承
OwningComponentBase
时,重写Dispose(bool disposing)
,切勿重写Dispose()
。
历史
- 2022年9月1日:初始版本