Dependency injection

Overview of dependency injection

依赖项注入是确保类保持松散耦合并使单元测试更容易的最佳实践软件开发技术。
以使用第三方服务发送电子邮件的服务为例。传统上,任何需要使用此服务的类都可能创建一个实例。

  1. public class NewsletterService
  2. {
  3. private readonly IEmailService EmailService;
  4. public NewsletterService()
  5. {
  6. EmailService = new SendGridEmailService();
  7. }
  8. public void SignUp(string emailAddress)
  9. {
  10. EmailService.SendEmail("noreply@sender.com", emailAddress, "Subject", "Body");
  11. }
  12. }

这种方法的问题在于它紧密耦合了类NewsletterServiceSendGridEmailService。在单元测试MyClass.SignUp时,被测试的方法实际上会尝试发送一封电子邮件。这不仅对您的收件箱不太好,对成本也不好(如果您的提供商对每封电子邮件收费),而且会给您的测试提供更多可能失败的点数,而实际上我们需要知道的是,注册方法试图发送电子邮件来欢迎新用户使用我们的服务。
使用依赖项注入,而不是每个消费类都必须创建正确的IEmailService实现者的实例,我们的消费类期望在创建时提供正确的实例。

  1. public class NewsletterService
  2. {
  3. private readonly IEmailService EmailService;
  4. public NewsletterService(IEmailService emailService)
  5. {
  6. EmailService = emailService;
  7. }
  8. public void SignUp(string emailAddress)
  9. {
  10. EmailService.SendEmail(...);
  11. }
  12. }

当我们要求依赖项注入框架(如ASP.NET MVC应用程序和Blazor应用程序中默认使用的框架)为我们构建NewsletterService实例时,它将自动注入正确类的实例。
这不仅通过使NewsletterService不知道实现IEmailService的类来解耦我们的类,而且还使单元测试变得非常简单。例如,使用Moq框架。

  1. [Fact]
  2. public void WhenSigningUp_ThenSendsAnEmail()
  3. {
  4. var mockEmailService = new Mock<IEmailService>();
  5. var subject = new NewsletterService(mockEmailService.Object);
  6. subject.SignUp("Bob@Monkhouse.com");
  7. mockEmailService
  8. .Verify(
  9. x => x.Send("noreply@sender.com", "Bob@Monkhouse.com", "Subject", "Body"),
  10. Times.Once);
  11. }

Injecting dependencies into Blazor components

Defining our dependency

在注入依赖项之前,我们需要创建一个依赖项。我们将使用古老的ToDo示例,但请不要担心,我们不会创建ToDo应用程序。
首先创建一个基本的TODO类。

  1. public class ToDo
  2. {
  3. public int Id { get; set; }
  4. public string Title { get; set; }
  5. public bool Completed { get; set; }
  6. }

接下来,我们将创建Blazor页面或组件可能需要的类。在本例中,它将是一个从服务检索ToDo项的API。我们实际上不会调用服务器,我们只返回一些模拟数据。

  1. using System.Collections.Generic;
  2. using System.Threading.Tasks;
  3. namespace BasicDependencyInjection
  4. {
  5. public interface IToDoApi
  6. {
  7. Task<IEnumerable<ToDo>> GetToDosAsync();
  8. }
  9. public class ToDoApi : IToDoApi
  10. {
  11. private readonly IEnumerable<ToDo> Data;
  12. public ToDoApi()
  13. {
  14. Data = new ToDo[]
  15. {
  16. new ToDo { Id = 1, Title = "To do 1", Completed = true},
  17. new ToDo { Id = 2, Title = "To do 2", Completed = false},
  18. new ToDo { Id = 3, Title = "To do 3", Completed = false},
  19. };
  20. }
  21. public Task<IEnumerable<ToDo>> GetToDosAsync() => Task.FromResult(Data);
  22. }
  23. }

我们通过实现接口对服务进行了抽象。如果我们希望在单元测试我们的类时传递in mock,这是一个很好的实践。

Registering injectable dependencies

当Blazor应用程序运行其启动代码时,它为我们做的一件事就是配置依赖项注入容器。依赖项注入容器负责构建类的实例(以及它们的依赖项的实例,等等)。
在这个引导过程中,我们需要注册哪些类可以作为自动注入依赖项使用。我们可以将类本身注册为可注入类,如下所示:

  1. services.AddSingleton<ToDoApi>();

或者,我们可以将接口注册为可注入接口,只要我们另外指定实现该接口的类。

  1. service.AddSingeton<IToDoApi, ToDoApi>();

注意:同样,推荐使用后一种方法,以使单元测试更加简单。它还允许在配置文件中指定实现类-例如,我们可以根据部署的平台是开发/测试/生产来指定不同的IEmailService。

注册依赖项的其他模式包括:

  1. // Register an existing object instance
  2. services.AddSingleton(existingObject);
  3. // Register an existing object instance, injected via an interface
  4. services.AddSingleton<ISomeInterface>(implementingInstance);
  5. // Lazy created instance, with manual build process and access to the current IServiceProvider
  6. services.AddSingleton<ISomeInterface>(serviceProvider => new ImplementingType(.......));

在Blazor Server和Blazor WASM应用程序中,引导代码不相同,因此,尽管服务注册是相同的,但我们注册可注入依赖项的位置略有不同。

Registering injectables in a Blazor Server app

在Blazor Server应用程序中,有一个带有ConfigureServices方法的Startup类。这是我们应该执行注册的地方。

  1. public void ConfigureServices(IServiceCollection services)
  2. {
  3. ... default Blazor registrations omitted ...
  4. // Register our own injectables
  5. services.AddSingleton<IToDoApi, ToDoApi>();
  6. }

当我们在创建应用程序时选中ASP.NET Core Hosted复选框时,这对于WASM应用程序是相同的。这是因为服务器负责引导整个应用程序。
image.png

Registering injectables in a Blazor WASM app

当Blazor项目是独立的WASM应用程序(不是ASP.NET Core托管的)时,该应用程序必须有自己的引导程序类。在这种类型的应用程序中,类被命名为Program,引导方法被命名为Main-就像在控制台应用程序中一样。

  1. public static async Task Main(string[] args)
  2. {
  3. var builder = WebAssemblyHostBuilder.CreateDefault(args);
  4. builder.RootComponents.Add<App>("app");
  5. builder.Services.AddTransient(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
  6. // Register our own injectables
  7. builder.Services.AddSingleton<IToDoApi, ToDoApi>();
  8. await builder.Build().RunAsync();
  9. }

Injecting dependencies

对于非Blazor类,比如其他可注入的服务,依赖项可以通过类的构造函数注入。

  1. public class NewsletterService : INewsletterService
  2. {
  3. private readonly IEmailService EmailService;
  4. public NewsletterService(IEmailService emailService)
  5. {
  6. EmailService = emailService;
  7. }
  8. }

然而,Blazor组件并非如此。目前,不支持构造函数注入。我们有两种方式可以指示我们的组件使用哪些依赖项;一种是在Razor标记中,另一种是在C#代码中。

  1. @inject IToDoApi ToDoApi
  2. @inject ISomeServiceType AnotherService
  3. @code
  4. {
  5. [Inject]
  6. private IYetAnotherServiceType PropertyInjectedDependency { get; set; }
  7. }

InjectAttribute只能应用于具有属性设置器的属性,属性的封装级别无关紧要。

注意:这两种方法是相同的。事实上,@Inject语法只是[Inject]语法的简写。在构建我们的应用程序时,Blazor会首先将标记转换为C#源代码。要查看@Inject语法是如何转换的,请打开文件夹\obj\Debug\netcoreapp3.1\Razor并查找与剃须刀文件对应的.cs文件。

Consuming injected dependencies

依赖项在Blazor组件实例创建之后、OnInitializedOnInitializedAsync生命周期事件执行之前注入。这意味着我们不能覆盖组件的构造函数并从那里使用这些依赖项,但是我们可以在OnInitialized*方法中使用它们。
要使用我们的IToDoApi服务,我们只需使用@Inject语法将其注入Index页面,然后在页面初始化时调用它。

  1. @page "/"
  2. @inject IToDoApi ToDoApi
  3. <h1>To do</h1>
  4. @if (Data.Any())
  5. {
  6. <table class="table">
  7. <thead>
  8. <tr>
  9. <th>Id</th>
  10. <th>Title</th>
  11. <th>Completed</th>
  12. </tr>
  13. </thead>
  14. <tbody>
  15. @foreach (ToDo item in Data)
  16. {
  17. <tr>
  18. <td>@item.Id</td>
  19. <td>@item.Title</td>
  20. <td>@item.Completed</td>
  21. </tr>
  22. }
  23. </tbody>
  24. </table>
  25. }
  26. @code
  27. {
  28. private IEnumerable<ToDo> Data = Array.Empty<ToDo>();
  29. protected override async Task OnInitializedAsync()
  30. {
  31. await base.OnInitializedAsync();
  32. Data = await ToDoApi.GetToDosAsync();
  33. }
  34. }
  • LIne 2

IToDoApp的一个实例被注入到我们的页面中,我们使用名称ToDoApi来引用注入的依赖项。

  • Line 35

对注入的服务调用GetDoToAsync方法。等待该方法,并将结果存储在数据中。

  • Line 16-23

使用@foreach循环迭代数据中的项,并将输出呈现为视图的一部分。
image.png

Dependency lifetimes and scopes

使用依赖项注入时需要问的两个重要问题是:这些依赖项的实例将存在多长时间,以及有多少其他对象可以访问同一实例?
答案视乎很多因素,主要是因为我们有不同的选择,让我们可以自行作出决定。第一个因素,也可能是最容易理解的因素,是依赖项注册到的ScopeMicrosoft.Extensions.DependencyInjection.ServiceLifetime定义的作用域是Singleton、Scoped和Transient的。

Transient dependencies

瞬时依赖是最容易理解的。在构造注册为瞬态的可注入依赖项时,依赖项容器只是充当工厂。一旦创建了实例并将其注入到依赖组件中,容器就不再对其感兴趣。

警告:这仅适用于未实现IDisposable的实例。为避免潜在的内存泄漏,请阅读本节底部的避免内存泄漏一节。

为了说明瞬时依赖的生存期,我们将创建一个简单的应用程序,以便我们可以看到对象实例何时创建。

Transient dependency example

首先,创建一个新的Blazor服务器应用。然后创建一个名为Services的新文件夹,并添加以下接口。这是我们的UI将用来显示由依赖项容器创建然后注入到我们的组件中的对象的实例号的服务。每个实例将其InstanceNumber设置为下一个可用序列号。

  1. public interface IMyTransientService
  2. {
  3. public int InstanceNumber { get; }
  4. }

为了实现这个接口,我们将编写一个带有静态整数字段的类,我们可以使用它来确定下一个可用的序列号。该字段将被标记为volatile,并使用System.Threading.Interlocked.Increment进行更改,这样我们就可以跨多个线程修改该字段,而无需执行任何线程锁定。

  1. public sealed class MyTransientService : IMyTransientService
  2. {
  3. public int InstanceNumber { get; }
  4. private static volatile int PreviousInstanceNumber;
  5. public MyTransientService()
  6. {
  7. InstanceNumber = System.Threading.Interlocked.Increment(ref PreviousInstanceNumber);
  8. }
  9. }

Registering our dependency

在服务器端应用程序中,编辑Startup.ConfigureServices并添加以下代码:

  1. services.AddTransient<IMyTransientService, MyTransientService>();

在WebAssembly应用程序中,编辑Program.Main并在调用builder.Build()之前添加以下代码。

  1. builder.Services.AddTransient<IMyTransientService, MyTransientService>();

Consuming the transient dependency

要使用此临时依赖项,我们将创建一个组件,以便可以在主页上显示使用组件的多个实例。该组件将简单地声明一个注入的依赖项,然后显示其InstanceNumber
在共享文件夹中,创建一个名为MyStandardComponent.razor的新组件,并为其添加以下标记。

  1. @inject IMyTransientService TransientService
  2. <p>Instance = @TransientService.InstanceNumber</p>

Demonstrating transient lifetimes

接下来,我们将编辑Index.razor,这样我们就可以通过选中或取消选中复选框来显示/隐藏该组件的实例。除非选中相关的复选框,否则我们的标记不会呈现组件的实例。因为Blazor只在需要的时候创建组件实例,所以在显示时切换复选框将重新创建我们的组件,并允许在不显示时对其进行垃圾收集。

  1. <div>
  2. <input id="show-first" type="checkbox" @bind=ShowFirst /><label for="show-first">Show first</label>
  3. @if (ShowFirst)
  4. {
  5. <MyStandardComponent />
  6. }
  7. </div>
  8. <div>
  9. <input id="show-second" type="checkbox" @bind=ShowSecond /><label for="show-second">Show second</label>
  10. @if (ShowSecond)
  11. {
  12. <MyStandardComponent />
  13. }
  14. </div>
  15. @code
  16. {
  17. bool ShowFirst;
  18. bool ShowSecond;
  19. }
  • Line 19-20

声明布尔域以确定是否应分别创建和呈现这两个组件。

  • Line 2

在复选框上使用双向绑定,以便我们可以在falsetrue之间切换布尔字段。

  • Line 5

如果相关字段为真,则显示MyStandardComponent的实例。

Running the app

当应用程序第一次运行时,两个布尔字段都将为false,因此两个MyStandardComponent标记都不会呈现。
image.png
当我们选中其中一个复选框时,它将双向绑定到其相关的布尔域,并将其设置为true。然后,用户交互将导致组件的重新呈现,从而导致呈现其中一个MyStandardComponent实例-因此Blazor将创建它的一个实例,并注入一个新创建的、InstanceNumber为1的MyTemperentService
下图显示了第一个选中“Show Second”复选框时的预期输出。
image.png
选中Other复选框将再次导致重新呈现,并且将使用InstanceNumber为2的依赖项创建和呈现另一个MyStandardComponent,因为注入其中的临时依赖项是按需创建的。
image.png
每当我们取消选中复选框时,页面的标记将不再呈现相关的MyStandardComponent,因为它只是基于@If语句有条件地呈现。如果取消选中该选项并重新呈现页面,则不再引用现有组件,并允许对其进行垃圾回收。
当我们选中相同的复选框时,我们的条件将被满足,我们的页面将呈现相关的MyStandardComponent,一个新的实例将被创建,一个新的MyInstanceService实例将被注入其中,我们将看到一个组件的InstanceNumber显示为3。
image.png

  1. 页面是在没有创建任何组件的情况下呈现的。
  2. 选中第二个复选框,将ShowSecond设置为true
  • 页面重新呈现,第二个组件可见。
  • 将创建我们的组件的一个新实例。
  • 我们的临时服务的一个新实例(实例1)被创建并注入到组件中。
  1. 选中第一个复选框,将ShowFirst设置为true
  • 页面重新呈现,第一个组件可见。
  • 将创建我们的组件的一个新实例。
  • 我们的临时服务的一个新实例被创建(实例2)并注入其中。
  1. 第一个复选框未选中,将ShowFirst设置为false
  • 第一个组件不再呈现,因此它有资格进行垃圾回收。
  • 再次选中第一个复选框,将ShowFirst设置为true
  • 页面将重新呈现。
  • 将创建我们的组件的一个新实例。
  • 我们的临时服务的一个新实例(实例3)被创建并注入其中。

    Avoiding memory leaks

    The short version

    如果类没有实现IDisposable,则仅将其注册为临时依赖项,否则,您的应用程序将泄漏内存。

    The longer version

    默认情况下,Microsoft依赖项注入容器将简单地创建注册为瞬态的依赖项实例,然后将其遗忘。当这些实例注入的组件被收集后,这些实例就会被垃圾收集。
    过去,Microsoft依赖项注入框架在ASP.NET应用程序中得到了相当广泛的使用,在ASP.NET应用程序中,为传入的Web请求创建一个容器,然后在请求结束时将其处理。
    为了省去开发人员必须处理任何注入的依赖项的不便,Microsoft依赖项注入容器在释放时,将自动调用实现IDisposable的任何对象上的Dispose
    为此,每当创建实现IDisposable的实例时,容器必须存储对创建的实例的引用,以便调用它的Dispose方法。这意味着在创建临时依赖项时,根据实例是否可处理,行为会有所不同。
    一旦瞬态对象被注入的对象符合垃圾回收的条件,它们通常就符合垃圾回收的条件-除非它们实现IDisposable,在这种情况下,注入容器也将持有对创建的瞬态IDisposable的引用,然后只有在创建它的容器符合垃圾回收的条件时,它才有资格进行垃圾回收。
    Blazor中的依赖项注入容器一直存在,直到用户关闭其浏览器中包含Blazor应用程序的选项卡。这意味着,除了在需要时创建临时依赖项的新实例之外,容器还将永远保留它们-有效地导致内存泄漏。
    有一种方法可以为每个组件创建依赖范围(因此,当组件被释放时,它将被释放),这将在后面的小节中介绍。
    如果希望将依赖项注册为瞬态依赖项,对于完全实现**IDisposable**的类,避免这样做是一条很好的规则。

    Summary

    将可注入的依赖项注册为瞬态会使我们的依赖项容器充当该类型实例的工厂。同一个实例不能自动注入到多个消费类中,每个注入的实例总是唯一的。
    实现IDisposable的类不应该注册为瞬态的,除非使用拥有自己作用域的组件,并且您确切地知道自己在做什么(您已经阅读了作用域依赖部分)。

    Singleton dependencies

    单例依赖项是由依赖它的每个对象共享的单个对象实例。在WebAssembly应用程序中,这是在浏览器的Current选项卡中运行的当前应用程序的生命周期。当类没有状态或(在服务器端应用程序中)具有可以在连接到同一服务器的所有用户之间共享的状态时,可以将依赖项注册为Singleton;Singleton依赖项必须是线程安全的。
    为了说明这种共享状态,让我们创建一个非常简单的(即不可伸缩的)聊天应用程序。

    The Singleton chat service

    首先,创建一个新的Blazor服务器应用。然后创建一个名为Services的新文件夹,并添加以下接口。这是我们的UI将用来向其他用户发送消息的服务,每当用户发送消息时都会收到通知,当我们的用户第一次连接时,他们将能够看到到目前为止聊天的有限历史记录。由于这是在Blazor服务器端应用程序上运行的Singleton依赖项,因此将由同一服务器上的所有用户共享。

    1. public interface IChatService
    2. {
    3. bool SendMessage(string username, string message);
    4. string ChatWindowText { get; }
    5. event EventHandler TextAdded;
    6. }

    要实现此服务,我们将使用List<String>来存储聊天历史记录,并在队列中超过100条时从列表开头删除消息。我们将使用lock()语句来确保线程安全。

    1. public class ChatService : IChatService
    2. {
    3. public event EventHandler TextAdded;
    4. public string ChatWindowText { get; private set; }
    5. private readonly object SyncRoot = new object();
    6. private List<string> ChatHistory = new List<string>();
    7. public bool SendMessage(string username, string message)
    8. {
    9. if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(message))
    10. return false;
    11. string line = $"<{username}> {message}";
    12. lock (SyncRoot)
    13. {
    14. ChatHistory.Add(line);
    15. while (ChatHistory.Count > 50)
    16. ChatHistory.RemoveAt(0);
    17. ChatWindowText = string.Join("\r\n", ChatHistory.Take(50));
    18. }
    19. TextAdded?.Invoke(this, EventArgs.Empty);
    20. return true;
    21. }
    22. }
  • Line 3

每当有新消息发布到我们的聊天服务器时,我们的UI都可以连接到该事件并得到通知。

  • Line 4

表示最多50行聊天历史记录的字符串。

  • Lines 16-23

锁定SyncRoot以防止并发问题,将当前行添加到聊天历史记录,如果超过50行则删除最旧的历史记录,然后重新创建ChatWindowText属性的内容。

  • Line 25

通知聊天服务的所有使用者ChatWindowText已更新。
要注册服务,请打开Startup.cs,然后在ConfigureServices中添加以下内容

  1. services.AddSingleton<IChatService, ChatService>();

Defining the user interface

要将C#聊天代码与显示标记分开,我们将使用代码隐藏方法。在Pages文件夹中创建一个名为Index.razor.cs的新文件,Visual Studio应该会自动将其嵌入到Index.Razor文件下。然后,我们需要将新的Index类标记为partial

  1. public partial class Index
  2. {
  3. }

我们需要我们的组件类来执行以下操作

  1. 初始化时,订阅ChatService.TextAdded
  2. 为了避免Singleton保留已释放对象的引用,当我们的组件被释放时,我们应该取消订阅ChatService.TextAdded
  3. 无论何时触发ChatService.TextAdded,我们都应该更新用户界面以显示新的IChatService.ChatWindowText内容。
  4. 我们应该允许用户输入他们的名字+一些文本以发送给其他用户。

让我们从最简单的步骤(即步骤4)开始,然后按照列出的顺序实现其他需求。
为简单起见,我们将NameText属性添加到当前类中,而不是创建视图模型,我们还将使用RequiredAttribute来装饰它们,以便在用户尝试在不填写所需输入的情况下发布文本时向他们提供反馈。

  1. public partial class Index
  2. {
  3. [Required(ErrorMessage = "Enter name")]
  4. public string Name { get; set; }
  5. [Required(ErrorMessage = "Enter a message")]
  6. public string Text { get; set; }
  7. }

Initial mark-up and validation

我们将替换Index.razor的内容,并用一个简单的EditForm替换它,该EditForm由一个DataAnnotationsValidator组件和一些Bootstrap CSS装饰的HTML组成,用于输入用户名和文本。

  1. @page "/"
  2. <h1>Blazor web chat</h1>
  3. <EditForm Model=@this>
  4. <DataAnnotationsValidator/>
  5. <div class="row mt-1">
  6. <div class="col-3">
  7. <InputText class="form-control" placeholder="Name" @bind-Value=Name maxlength=20/>
  8. <ValidationMessage For=@( () => Name )/>
  9. </div>
  10. <div class="col-9">
  11. <div class="input-group">
  12. <InputText class="form-control" placeholder="..." @bind-Value=Text maxlength=100 />
  13. <div class="input-group-append">
  14. <button class="btn btn-primary" type=submit>Send</button>
  15. </div>
  16. </div>
  17. <ValidationMessage For=@( () => Text )/>
  18. </div>
  19. </div>
  20. </EditForm>
  • Line 4

创建与this绑定的EditForm

  • Line 5

启用基于数据批注(如RequiredAttribute)的验证。

  • Line 8

将Blazor InputText组件绑定到Name属性。

  • Line 9

显示Name属性的所有验证错误。

  • Line 13

将Blazor InputText组件绑定到Text属性。

  • Line 18

显示Text属性的所有验证错误。

Consuming IChatService

接下来,我们将注入IChatService并将其完全连接到我们的组件。要实现这一点,我们需要执行以下操作。

  1. public partial class Index : IDisposable
  2. {
  3. [Required(ErrorMessage = "Enter name")]
  4. public string Name { get; set; }
  5. [Required(ErrorMessage = "Enter a message")]
  6. public string Text { get; set; }
  7. [Inject]
  8. private IChatService ChatService { get; set; }
  9. private string ChatWindowText => ChatService.ChatWindowText;
  10. protected override void OnInitialized()
  11. {
  12. base.OnInitialized();
  13. ChatService.TextAdded += TextAdded;
  14. }
  15. private void SendMessage()
  16. {
  17. if (ChatService.SendMessage(Name, Text))
  18. Text = "";
  19. }
  20. private void TextAdded(object sender, EventArgs e)
  21. {
  22. InvokeAsync(StateHasChanged);
  23. }
  24. void IDisposable.Dispose()
  25. {
  26. ChatService.TextAdded -= TextAdded;
  27. }
  28. }
  • Lines 8-9

声明应自动注入的对IChatService的依赖项。

  • Line 11

声明一个属性,该属性使访问IChatService.ChatWindowText变得简单。

  • Line 16

订阅IChatService.TextAdded事件。

  • Line 21

将当前用户的输入发送到聊天服务。

  • Line 27

每次调用IChatService.TextAdded时刷新用户界面。

  • Line 32

释放组件后,取消订阅IChatService.TextAdded以避免内存泄漏。

注意:我们必须将StateHasChanged调用包装在对InvokeAsync的调用中。这是因为IChatService.TextAdded事件将由添加文本的任何用户触发,因此将由各种线程触发。我们需要Blazor使用InvokeAsync封送这些调用,以确保组件上的所有线程调用都按顺序执行。

Adding the chat window to our user interface

我们现在只需要将HTML<textarea>控件添加到标记中,并将其绑定到ChatWindowText属性,并确保在没有验证错误的情况下提交EditForm时,它会调用我们的SendMessage方法。
最终的用户界面标记如下所示。

  1. @page "/"
  2. <h1>Blazor web chat</h1>
  3. <EditForm Model=@this OnValidSubmit=@SendMessage>
  4. <DataAnnotationsValidator/>
  5. <div class="row">
  6. <textarea class="form-control" rows=20 readonly>@ChatWindowText</textarea>
  7. </div>
  8. <div class="row mt-1">
  9. <div class="col-3">
  10. <InputText class="form-control" placeholder="Name" @bind-Value=Name maxlength=20/>
  11. <ValidationMessage For=@( () => Name )/>
  12. </div>
  13. <div class="col-9">
  14. <div class="input-group">
  15. <InputText class="form-control" placeholder="..." @bind-Value=Text maxlength=100 />
  16. <div class="input-group-append">
  17. <button class="btn btn-primary" type=submit>Send</button>
  18. </div>
  19. </div>
  20. <ValidationMessage For=@( () => Text )/>
  21. </div>
  22. </div>
  23. </EditForm>
  • Line 5

当用户在InputText上按enter并且输入验证通过时调用SendMessage

  • Lines 7-9

用于输出HTML<textarea>并将其绑定到WindowChatText的HTML。

Singleton dependencies in WebAssembly applications

仅当Blazor应用程序是Blazor服务器端应用程序时,前面的应用程序才允许用户彼此聊天。
这是因为每个应用程序进程都共享单例依赖项。Blazor服务器端应用程序实际上在服务器上运行,因此在同一服务器应用程序进程中运行的多个用户共享Singleton实例。
当在WebAssembly应用程序中运行时,每个浏览器选项卡都是它自己的独立应用程序进程,如果用户在他们的浏览器(WebAssembly托管应用程序)中各自运行单独的进程,那么他们将无法相互聊天,因为他们没有共享任何公共状态。
这与使用多台服务器时的情况相同。一旦我们的聊天服务流行到足以保证增加一台或多台服务器,就不再是所有用户的全局共享状态,而是每个服务器只有一个共享状态。
一旦我们需要扩展我们的服务器,或者我们希望将聊天客户端实现为WebAssembly应用程序以减轻服务器上的一些工作负载,我们就需要设置一种更健壮的共享状态的方法。这不在本节的范围内,因为本节的目的只是演示如何在单个应用程序进程之间共享注册为单例的依赖项。

Task for the reader

浏览器不太可能有足够的垂直空间来同时显示50条聊天消息,因此用户必须手动滚动聊天区域才能查看最新消息。
为了改善用户体验,我们的组件应该在每次添加新文本时真正将<textarea>滚动条滚动到底部。如果你不想自己发撞击,那就看一下这一节附带的项目,这项工作已经为你完成了。如果你真的想解决这个问题,这里有一些线索。

  1. 您将使用一些将控件作为参数的JavaScript,并设置control.scllTop=control.scrollHeight
  2. 在我们的组件每次呈现之后,您都需要调用此JavaScript。
  3. 您需要一个对<textarea>ElementReference来传递给JavaScript。

    Scoped dependencies

    作用域依赖项类似于Singleton依赖项,因为Blazor会将相同的实例注入到依赖它的每个对象中,但是,不同的是作用域实例并不是所有用户都共享的。
    在典型的ASP.NET MVC应用程序中,每个请求都会创建一个新的依赖项注入容器。依赖于范围注册依赖项的第一个对象将接收该依赖项的新实例,并且该新实例将被缓存在注入容器中。
    image.png
    从那时起,任何请求相同依赖项类型的对象(如上例中的ILogger)都将收到相同的缓存实例。然后,在请求结束时,不再需要容器,并且容器可能与它创建的所有作用域和临时注册实例一起被垃圾收集。
    作用域实例使我们能够将依赖项注册为每个用户的单个实例,而不是每个应用程序的单个实例。
    image.png

    Blazor server-side Scoped dependencies

    与ASP.NET MVC应用程序不同,Blazor中没有每个请求的作用域。作为单页面应用程序(SPA),Blazor应用程序只创建一次,然后在用户的整个会话期间保持在用户屏幕上。

    注意:与ASP.NET站点不同,Blazor服务器端应用程序不会在页面刷新期间保持不变。Blazor服务器端应用的范围是客户端和服务器之间的SignalR连接。

在用户会话期间,URL可能会更改,但浏览器实际上不会导航到任何地方。相反,它只是根据当前URL重新构建显示。如果您需要熟悉如何完成此操作,请阅读路由部分。
在Blazor中没有“每页”作用域的概念,在Blazor中将依赖项注册为作用域将导致依赖项在用户会话期间存在。作用域依赖项的实例将在单个用户的页面和组件之间共享,但不会在不同用户之间共享,也不会在同一浏览器中的不同选项卡之间共享。
image.png

WebAssembly Scoped services

WebAssembly应用程序中的作用域略有不同。在服务器端应用程序中,只有一个进程使我们能够在同一服务器的所有用户之间共享单例范围的依赖项。在WebAssembly中,每个选项卡都是唯一的进程。这意味着单例依赖项不会在选项卡之间共享,甚至在同一浏览器中也不会共享,更不用说跨计算机共享了。
image.png

Comparing dependency scopes

在本节中,我们将创建一个Blazor应用程序来演示各种依赖项注入作用域的不同生存期。
为此,我们将创建三个不同的服务(每个范围一个)。每个服务都将跟踪它的创建时间,以及一个递增的InstanceNumber,这样我们就可以跟踪创建了多少个该类型的实例。
首先,创建一个新的Blazor服务器端应用程序,并添加一个静态类来跟踪应用程序启动的日期时间,并计算自应用程序启动以来已经过了多少时间。

  1. public static class AppLifetime
  2. {
  3. public static DateTime StartTimeUtc { get; } = DateTime.UtcNow;
  4. public static TimeSpan ElapsedTime => DateTime.UtcNow - StartTimeUtc;
  5. }

Service interfaces

接下来,创建三个接口:IMyTemperentServiceIMyScopedServiceIMySingletonService。每个接口都将是相同的。

注意:有几种方法可以编写不需要重复的代码,但为了简单起见,此示例将复制代码。

  1. public interface IMyTransientService
  2. {
  3. public TimeSpan DeltaCreationTime { get; }
  4. public int InstanceNumber { get; }
  5. }
  6. public interface IMyScopedService
  7. {
  8. // As above
  9. }
  10. public interface IMySingletonService
  11. {
  12. // As above
  13. }

Service implementations

同样,这些服务的代码将重复,以避免复杂性。创建后,我们的服务将从AppLifetime类获取ElapsedTime,因此我们可以判断实例是否最近创建过。它还将从静态字段为自己分配一个InstanceId

  1. public class MyTransientService : IMyTransientService
  2. {
  3. public TimeSpan DeltaCreationTime { get; }
  4. public int InstanceNumber { get; }
  5. private static volatile int PreviousInstanceNumber;
  6. public MyTransientService()
  7. {
  8. DeltaCreationTime = DateTime.UtcNow - AppLifetime.StartTimeUtc;
  9. InstanceNumber = System.Threading.Interlocked.Increment(ref PreviousInstanceNumber);
  10. }
  11. }
  12. public class MyScopedService : IMyScopedService
  13. {
  14. //As above
  15. }
  16. public class MySingletonService : IMySingletonService
  17. {
  18. // As above
  19. }

Registering our services

编辑Startup.cs文件,并在ConfigureServices方法中注册我们的服务,如下所示。

  1. services.AddSingleton<IMySingletonService, MySingletonService>();
  2. services.AddScoped<IMyScopedService, MyScopedService>();
  3. services.AddTransient<IMyTransientService, MyTransientService>();

User interface

在我们的页面中,我们将展示多个组件如何共享实例。我们将通过创建一个使用所有三个服务的组件,然后让我们的组件的两个实例同时显示在我们的页面中,来简化这一过程。
image.png

Creating our service consuming component

首先,我们需要注入我们的服务,然后我们将显示每个实例的InstanceNumber,以及注入的服务是否是最近创建的。为此,如果服务是在最后500毫秒内创建的,我们将在UI中包含一个CSS类。

  1. @inject IMySingletonService MySingletonService
  2. @inject IMyScopedService MyScopedService
  3. @inject IMyTransientService MyTransientService
  4. <dl>
  5. <dt>@Caption</dt>
  6. <dd>
  7. <ul>
  8. <li>
  9. <span class="scope-name">Singleton</span>
  10. <span class="@GetNewIndicatorCss(MySingletonService.DeltaCreationTime)">Instance #@MySingletonService.InstanceNumber</span>
  11. </li>
  12. <li>
  13. <span class="scope-name">Scoped</span>
  14. <span class="@GetNewIndicatorCss(MyScopedService.DeltaCreationTime)">Instance #@MyScopedService.InstanceNumber</span>
  15. </li>
  16. <li>
  17. <span class="scope-name">Transient</span>
  18. <span class="@GetNewIndicatorCss(MyTransientService.DeltaCreationTime)">Instance #@MyTransientService.InstanceNumber</span>
  19. </li>
  20. </ul>
  21. </dd>
  22. </dl>
  23. @code
  24. {
  25. [Parameter]
  26. public string Caption { get; set; }
  27. private string GetNewIndicatorCss(TimeSpan time)
  28. {
  29. if (AppLifetime.ElapsedTime - time < TimeSpan.FromMilliseconds(500))
  30. {
  31. return "instance-info new-instance";
  32. }
  33. return "instance-info";
  34. }
  35. }
  • Line 1-3

指示Blazor将我们的服务实例注入此组件。

  • Lines 10, 14 and 18

显示每个注入依赖项的InstanceNumber,并通过调用GetNewIndicatorCss设置HTML元素的class属性。

  • Lines 31

如果服务的DeltaCreationTime(应用程序启动和实例创建之间的时间)在AppLifetime.ElapsedTime(应用程序启动和当前时间之间的时间)的500毫秒以内,则组件将使用CSS类instance-info new-instance呈现,否则它将只有CSS类instance-info。这将允许我们以不同的方式显示新实例的UI。

Displaying our component on a page

编辑Pages/Index.razor并按如下方式更改标记

  1. @page "/"
  2. <MyStandardComponent Caption="Component 1" />
  3. <MyStandardComponent Caption="Component 2" />

运行应用程序,您将看到以下输出。
image.png
当我们的作用域实例应该是第一个实例时,它却是#2,这可能会让人感到惊讶。这是因为在浏览器和服务器之间建立SignalR连接以实际启动用户会话之前,服务器端Blazor应用程序会预先呈现我们的页面,以便发回完整的HTML响应。我们可以通过执行以下操作暂时禁用此功能。

  • 编辑 /Pages/_Host.cshtml
  • 找到文本render-mode=“ServerPrerended”
  • ServerPreerended更改为Server

现在重新运行该应用程序将给我们带来我们预期的结果
image.png
Singleton实例将始终是#1,因为它由所有用户共享。应用程序的第一个用户的Scoped实例为#1,第二个用户的Scoped实例为#2,依此类推。Transient实例将是第一个组件的#1,第二个组件的#2,因为它们是为每个组件创建的。如果用户离开页面然后返回,唯一会更改的实例编号是Transient实例,它们将递增到#3#4,在下一次访问该页面时将递增到#5#6

Interactive sample 互动

为了使生命周期更加明显,我们将修改Index.razor页面,以便它有条件地全部呈现我们的组件。我们还将展示实际的页面刷新如何影响我们的作用域服务。
我们将通过以下步骤创建一个简单的向导样式UI

  • 网站已启动
  • 更新的UI-重新创建的组件
  • 在浏览器中重新加载页面
  • 更新的UI-重新创建的组件
    Ensuring components are recreated at each step
    为了确保在每个导航上创建组件,我们将有一个CurrentStep字段,并根据CurrentStep是奇数还是偶数显示一组或另一组组件。
    1. @if (CurrentStep % 2 == 1)
    2. {
    3. <MyStandardComponent Caption="Component 1" />
    4. <MyStandardComponent Caption="Component 2" />
    5. }
    6. else
    7. {
    8. <MyStandardComponent Caption="Component 1" />
    9. <MyStandardComponent Caption="Component 2" />
    10. }
    Forcing a page refresh in the browser
    要强制页面刷新,我们将使用NavigationManager进行导航,并将true传递给forceLoad参数。这样我们就知道在页面刷新后我们将继续,我们将导航到/Continue。此请求将由同一页面提供服务,但我们知道应该从步骤3开始,而不是从步骤1开始。 ```csharp @page “/“ @page “/{Continue}” @inject NavigationManager NavigationManager

@code { [Parameter] public string Continue { get; set; }

private void GoToNextStep() { CurrentStep++; if (CurrentStep == 3) NavigationManager.NavigateTo(“/continue”, forceLoad: true); }

protected override void OnInitialized() { base.OnInitialized(); if (!string.IsNullOrWhiteSpace(Continue)) CurrentStep = 3; } }

  1. <a name="b2PEH"></a>
  2. ##### Completing the sample
  3. 以下(完成的)代码基本上就是到目前为止所概述的内容,并添加了以下内容。
  4. 1. 添加了显示当前步骤名称的文本。
  5. 1. 添加了要单击的按钮,以便继续执行下一步。
  6. 1. 添加了在没有下一步时禁用按钮的代码。
  7. 1. 添加了一些CSS样式,以使新实例通过脉冲来吸引我们的注意力。
  8. ```csharp
  9. @page "/"
  10. @page "/{Continue}"
  11. @inject NavigationManager NavigationManager
  12. <h1>Step @CurrentStep: @CurrentStepName</h1>
  13. @if (CurrentStep % 2 == 1)
  14. {
  15. <MyStandardComponent Caption="Component 1" />
  16. <MyStandardComponent Caption="Component 2" />
  17. }
  18. else
  19. {
  20. <MyStandardComponent Caption="Component 1" />
  21. <MyStandardComponent Caption="Component 2" />
  22. }
  23. <button @onclick=GoToNextStep disabled=@IsButtonDisabled>Next step</button>
  24. <style>
  25. .scope-name {
  26. width: 5rem;
  27. display: inline-block;
  28. font-weight: bold;
  29. }
  30. .instance-info {
  31. color: white;
  32. background-color: #888;
  33. padding: 0 4px;
  34. margin: 2px;
  35. display: inline-block;
  36. }
  37. .instance-info.new-instance {
  38. background-color: #3f8f42;
  39. animation: flash-green 2s;
  40. }
  41. @@keyframes flash-green {
  42. from {
  43. background-color: #4cff00;
  44. }
  45. to {
  46. background-color: #3f8f42;
  47. }
  48. }
  49. </style>
  50. @code
  51. {
  52. [Parameter]
  53. public string Continue { get; set; }
  54. private int CurrentStep = 1;
  55. private string CurrentStepName => StepNames[CurrentStep - 1];
  56. private bool IsButtonDisabled => CurrentStep >= StepNames.Length;
  57. private string[] StepNames = new string[]
  58. {
  59. "Website started",
  60. "Updated UI - Components recreated",
  61. "Reloaded page in browser",
  62. "Updated UI - Components recreated"
  63. };
  64. protected override void OnInitialized()
  65. {
  66. base.OnInitialized();
  67. if (!string.IsNullOrWhiteSpace(Continue))
  68. CurrentStep = 3;
  69. }
  70. private void GoToNextStep()
  71. {
  72. CurrentStep++;
  73. if (CurrentStep == 3)
  74. NavigationManager.NavigateTo("/continue", forceLoad: true);
  75. }
  76. }
  • LIne 6

渲染一组或另一组组件实例,具体取决于CurrentStep是奇数还是偶数。这确保为向导的每个步骤重新创建生成UI的组件。

  • Line 16

转到向导的下一步(如果有)。

  • Line 69

如果Continue路由参数不为空,则从步骤3继续。

  • Line 76

如果单击“Next step”按钮并在步骤3结束,则强制重新加载页面。

Running the application

当我们的网站第一次运行时,我们会获得所有注入的依赖项的第一个实例。除了Transient依赖项之外,因为这些依赖项是按需创建的,而不是缓存以供重用,所以我们会得到实例#1#2
image.png
当用户单击Next Step按钮时,CurrentStep递增到2,我们的前两个组件将被丢弃,而第二个组件将被创建用于呈现。因为这些是在同一用户会话中运行的新实例,所以它们将接收相同的Scoped依赖项以及两个新的Transient依赖项。
image.png
当用户再次单击Next Step按钮时,应用程序将强制在新路径/Continue重新加载应用程序。因为在重新加载页面时忘记了SignalR连接的ID,所以将为用户设置一个新的连接,从而设置一个新的Scoped。所以现在,当呈现前两个组件时,它们的作用域是实例#2
image.png
最后,当用户最后一次单击Next Step按钮时,我们的第二对组件将被创建以进行呈现,并将注入由共享Singleton容器(实例#1)中的实例缓存的IMySingletonService、由当前用户的注入容器(实例#2)缓存的IMyScopedService,以及两个新的IMyTransportentService实例。这两个组件将被注入到由共享Singleton容器(实例#1)中的实例缓存的IMySingletonService、由当前用户的注入容器(实例#2)缓存的IMyScopedService和两个新实例。
image.png

WebAssembly dependency scopes

因为WebAssembly在用户的浏览器中运行,并且每个选项卡都是一个完全独立的进程,所以我们的输出将与服务器端Blazor应用程序生成的输出略有不同。
首先,应用程序在浏览器选项卡中启动,我们得到的输出与我们期望在服务器端Blazor应用程序上看到的输出相同。
image.png
下一步的第一次单击还向我们显示了一个与我们的服务器端Blazor应用程序中相同的屏幕,其中Singleton和Scope实例保持不变,因为它们都是缓存实例,并且按需创建两个Transient实例。
image.png
当我们的应用程序执行强制重新加载时,情况就不同了。在服务器端应用程序中,用户获得一个新的SignalR连接ID,从而在服务器上获得一个与该ID绑定的新依赖项注入容器。在WebAssembly中,页面没有重新连接到的应用程序状态。页面重新加载后,整个应用程序状态将被销毁,然后重新创建。因此,我们的实例编号重新从头开始。
image.png
然后最后。
image.png

Conclusion

由于Blazor应用程序中的用户界面和UI逻辑捆绑在一起,因此没有每个请求的依赖项注入作用域。
在服务器端应用程序中,单个注册的依赖项在用户之间共享,但在WebAssembly应用程序中,每个浏览器选项卡都是唯一的。
Scoped依赖项与Singleton注册依赖项的作用非常相似,不同之处在于它们与其他用户/其他浏览器选项卡隔离。
Transient依赖项在服务器端和WebAssembly上的工作方式与ASP.NET MVC中的工作方式相同,只是ASP.NET MVC中的依赖项注入容器是在页面请求之后处理的。请参阅Transient依赖项的避免内存泄漏部分。
有几种方法可以为每个用户引入额外的作用域。这项技术将在后面的小节中介绍。