65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 Blazor 的 OwningComponentBase

starIconstarIconstarIconstarIconstarIcon

5.00/5 (3投票s)

2022年9月1日

CPOL

6分钟阅读

viewsIcon

8142

深入了解如何使用 Blazor 的 OwningComponentBase

引言

OwningComponentBase 是一个 Blazor 组件,它拥有自己的依赖注入容器。其目的是比 SPA 级别的容器提供更精细地控制作用域(scoped)和服务(transient)的生命周期。

在本文中,我将深入探讨如何使用它。它有一些特点,并且其设计本身存在一些固有的问题。

引用微软文档的话说:

使用 OwningComponentBase 类作为基类来编写控制服务提供程序作用域生命周期的组件。当使用需要释放的瞬时(transient)或作用域(scoped)服务(如存储库或数据库抽象)时,这非常有用。使用 OwningComponentBase 作为基类可确保服务提供程序作用域与组件一起被释放。

听起来很棒,但很少有人使用它。我不知道是因为他们不知道它,认为它太难使用,还是尝试过它并被它的问题所困扰。

OwningComponentBase 有两种形式

  1. OwningComponentBase,您可以在其中手动设置和使用组件 DI 服务容器中的任何服务。
  2. 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 表单在网站的多个上下文中都使用:预订会议的成员列表、未付款的成员等,因此我们不希望使用单个作用域的视图服务。

有两种方法

  1. 将表单设置为继承自 OwningComponentBase 并使用内置的 DI 服务容器。
  2. 将视图服务设置为瞬时服务,每次使用表单时都创建一个新实例。

一些测试服务和一个测试页面来查看这两种选项。

测试服务

一个瞬时服务类。

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 服务容器中的实例不同。

如果 ViewServiceNotificationService 上发出通知,它会在组件 DI 实例上发出,而不是 SPA DI 实例上。反之亦然,它不会收到 SPA 实例的任何通知,因为它在组件实例上注册了一个处理程序。

我相信许多程序员都会在这个障碍处跌倒并放弃。

修复实例问题

修复它的关键在于:

  1. 对 DI 基础知识有深刻的理解
  2. 理解需要注入哪些服务实例

视图服务更改

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 容器会保留它创建的所有实现 IDisposableIAsyncDisposable 的对象。这样做是为了在容器本身被释放时运行这些对象的 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

因此,一般规则是永远不要在瞬时服务中实现 IDisposableIAsyncDisposable 或任何需要处置的功能。由于几乎**所有**数据库活动都需要某种形式的托管处置,因此最好不要在瞬时服务中执行任何与数据库相关的操作。

正如您在上面所见,使用 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 是一个很好的工具,您应该将其纳入您的工具箱。您只需要知道如何部署它。

一些规则/指南

  1. 确保您了解您想使用哪些 DI 服务实例。
  2. 在开发过程中,使用 GUID 和日志记录来跟踪实例的创建/处置。这可能非常有启发性!
  3. 瞬时服务永远不应实现或需要实现处置。
  4. 继承 OwningComponentBase 时,重写 Dispose(bool disposing),切勿重写 Dispose()

历史

  • 2022年9月1日:初始版本
© . All rights reserved.