使用 ASP.NET Core SignalR 重构

已完成

在本单元中,你将了解如何重构客户端轮询并添加 ASP.NET Core SignalR。 服务器和客户端应用程序都需要重构。 需要创建、映射并配置服务器中心。 然后,客户端需要建立与中心的连接,并处理订单状态更改通知。

NuGet 包

BlazingPizza.Server.csproj 文件添加了以下 NuGet 包引用:

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    <ImplicitUsings>true</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="$(BlazorVersion)" />
    <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="$(AspNetCoreVersion)" />
    <PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="$(AspNetCoreVersion)" />
    <PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="$(AspNetCoreVersion)" />
    <PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="$(AspNetCoreVersion)" />
    <PackageReference Include="Microsoft.AspNetCore.ApiAuthorization.IdentityServer" Version="$(AspNetCoreVersion)" />
    <PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="$(SignalRVersion)" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="$(EntityFrameworkVersion)" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="$(EntityFrameworkVersion)" />
    <PackageReference Include="WebPush" Version="1.0.11" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\BlazingPizza.Client\BlazingPizza.Client.csproj" />
    <ProjectReference Include="..\BlazingPizza.Shared\BlazingPizza.Shared.csproj" />
  </ItemGroup>

</Project>

BlazingPizza.Client.csproj 文件添加了以下 NuGet 包引用:

<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">

  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    <ImplicitUsings>true</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="$(BlazorVersion)" />
    <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="$(BlazorVersion)" PrivateAssets="all" />
    <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="$(BlazorVersion)" />
    <PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="$(SignalRVersion)" />
    <PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="$(SignalRVersion)" />
    <PackageReference Include="Microsoft.Extensions.Http" Version="$(SystemNetHttpJsonVersion)" />
    <PackageReference Include="System.Net.Http.Json" Version="$(SystemNetHttpJsonVersion)" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\BlazingComponents\BlazingComponents.csproj" />
    <ProjectReference Include="..\BlazingPizza.ComponentsLibrary\BlazingPizza.ComponentsLibrary.csproj" />
    <ProjectReference Include="..\BlazingPizza.Shared\BlazingPizza.Shared.csproj" />
  </ItemGroup>

</Project>

添加 MessagePack NuGet 包是为了让客户端与服务器之间发送的消息变小。

重构的服务器应用

服务器负责公开 SignalR 终结点。 终结点映射到 HubHub<T> 子类。 服务器公开可以从客户端调用的中心方法,并公开客户端可以订阅的事件。

添加 OrderStatusHub

ASP.NET Core SignalR 支持强类型中心实现 Hub<T>,其中 T 是指客户端类型。 请参考以下 IOrderStatusHubs.cs 文件:

namespace BlazingPizza.Server.Hubs;

public interface IOrderStatusHub
{
    /// <summary>
    /// This event name should match <see cref="OrderStatusHubConsts.EventNames.OrderStatusChanged"/>,
    /// which is shared with clients for discoverability.
    /// </summary>
    Task OrderStatusChanged(OrderWithStatus order);
}

前面的接口定义了一种方法,该方法将充当客户端可以订阅的事件。 请参考以下 OrderStatusHub.cs 文件:

namespace BlazingPizza.Server.Hubs;

[Authorize]
public class OrderStatusHub : Hub<IOrderStatusHub>
{
    /// <summary>
    /// Adds the current connection to the order's unique group identifier, where 
    /// order status changes will be notified in real-time.
    /// This method name should match <see cref="OrderStatusHubConsts.MethodNames.StartTrackingOrder"/>,
    /// which is shared with clients for discoverability.
    /// </summary>
    public Task StartTrackingOrder(Order order) =>
        Groups.AddToGroupAsync(
            Context.ConnectionId, order.ToOrderTrackingGroupId());

    /// <summary>
    /// Removes the current connection from the order's unique group identifier, 
    /// ending real-time change updates for this order.
    /// This method name should match <see cref="OrderStatusHubConsts.MethodNames.StopTrackingOrder"/>,
    /// which is shared with clients for discoverability.
    /// </summary>
    public Task StopTrackingOrder(Order order) =>
        Groups.RemoveFromGroupAsync(
            Context.ConnectionId, order.ToOrderTrackingGroupId());
}

前面的中心实现公开了两个可从客户端调用的方法。 客户端调用 StartTrackingOrder,并收到一个 order 实例,然后客户端的唯一连接将添加到将要发送通知的组中。 同样,如果调用 StopTrackingOrder,该组的连接将断开,并且不再接收通知。

SignalR 服务器配置

需要更新 Startup 类,以便添加 ASP.NET Core SignalR 和 MessagePack 协议。 "/orderstatus" 终结点也将映射到 OrderStatusHub 实现。 请参考以下 Startup.cs 文件:

namespace BlazingPizza.Server;

public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public IConfiguration Configuration { get; }

    public void ConfigureServices(IServiceCollection services)
    {
        services.AddControllersWithViews();
        services.AddRazorPages();

        services.AddDbContext<PizzaStoreContext>(options =>
            options.UseSqlite("Data Source=pizza.db"));

        services.AddDefaultIdentity<PizzaStoreUser>(options => options.SignIn.RequireConfirmedAccount = true)
            .AddEntityFrameworkStores<PizzaStoreContext>();

        services.AddIdentityServer()
            .AddApiAuthorization<PizzaStoreUser, PizzaStoreContext>();

        services.AddAuthentication()
            .AddIdentityServerJwt();

        services.AddSignalR(options => options.EnableDetailedErrors = true)
            .AddMessagePackProtocol();

        services.AddHostedService<FakeOrderStatusService>();
        services.AddSingleton<IBackgroundOrderQueue, DefaultBackgroundOrderQueue>();
    }

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
            app.UseWebAssemblyDebugging();
        }
        else
        {
            app.UseExceptionHandler("/Error");
            // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
            app.UseHsts();
        }

        app.UseHttpsRedirection();
        app.UseBlazorFrameworkFiles();
        app.UseStaticFiles();

        app.UseRouting();

        app.UseAuthentication();
        app.UseIdentityServer();
        app.UseAuthorization();

        app.UseEndpoints(endpoints =>
        {
            endpoints.MapHub<OrderStatusHub>("/orderstatus");
            endpoints.MapRazorPages();
            endpoints.MapControllers();
            endpoints.MapFallbackToFile("index.html");
        });
    }
}

前面的代码突出显示了这些更改:

  • 添加 SingalR 和 MessagePack 协议。
  • OrderStatusHub 映射到 "/orderstatus" 终结点。

重构的客户端应用

客户端应用程序需要重构 OrderDetails.razor 文件:

@page "/myorders/{orderId:int}"
@attribute [Authorize]

<div class="main">
    @if (_invalidOrder)
    {
        <h2>Nope</h2>
        <p>Sorry, this order could not be loaded.</p>
    }
    else if (_orderWithStatus is null)
    {
        <text>Loading...</text>
    }
    else
    {
        <div class="track-order">
            <div class="track-order-title">
                <h2>
                    Order placed @_orderWithStatus.Order.CreatedTime.ToLongDateString()
                </h2>
                <p class="ml-auto mb-0">
                    Status: <strong>@_orderWithStatus.StatusText</strong>
                </p>
            </div>
            <div class="track-order-body">
                <div class="track-order-details">
                    <OrderReview Order="_orderWithStatus.Order" />
                </div>
                <div class="track-order-map">
                    <Map Zoom="13" Markers="_orderWithStatus.MapMarkers" />
                </div>
            </div>
        </div>
    }
</div>

你会注意到,该过程移除了多个指令:

  • 标记中不再需要 @using System.Threading 指令。
  • @inject OrdersClient OrdersClient 指令现属于代码隐藏文件的一部分。
  • 在代码隐藏文件中,@implements IDisopsable 已替换为 IAsyncDisposable 的实现。
  • @code { ... } 指令已被移除,因为所有的逻辑目前都位于代码隐藏文件中。

此外,Blazor 组件现在更加纯净,因为它主要表示绑定和模板化。 而逻辑已移到代码隐藏文件中。

使用代码隐藏文件扩展组件

随着 Blazor 组件中引入的逻辑越来越多,它们通常会演变成两个单独的文件:

  • OrderDetails.razor:Razor 标记。
  • OrderDetails.razor.cs:相应的 C# 代码隐藏文件。 它是同一组件,但只有一个 partial class

请参考 OrderDetails.razor.cs 文件:

namespace BlazingPizza.Client.Pages;

public sealed partial class OrderDetails : IAsyncDisposable
{
    private HubConnection _hubConnection;
    private OrderWithStatus _orderWithStatus;
    private bool _invalidOrder;

    [Parameter] public int OrderId { get; set; }

    [Inject] public NavigationManager Nav { get; set; } = default!;
    [Inject] public OrdersClient OrdersClient { get; set; } = default!;
    [Inject] public IAccessTokenProvider AccessTokenProvider { get; set; } = default!;

    protected override async Task OnInitializedAsync()
    {
        _hubConnection = new HubConnectionBuilder()
            .WithUrl(
                Nav.ToAbsoluteUri("/orderstatus"),
                options => options.AccessTokenProvider = GetAccessTokenValueAsync)
            .WithAutomaticReconnect()
            .AddMessagePackProtocol()
            .Build();

        _hubConnection.On<OrderWithStatus>(
            OrderStatusHubConsts.EventNames.OrderStatusChanged, OnOrderStatusChangedAsync);

        await _hubConnection.StartAsync();
    }

    private async Task<string> GetAccessTokenValueAsync()
    {
        var result = await AccessTokenProvider.RequestAccessToken();
        return result.TryGetToken(out var accessToken)
            ? accessToken.Value
            : null;
    }

    protected override async Task OnParametersSetAsync()
    {
        try
        {
            _orderWithStatus = await OrdersClient.GetOrder(OrderId);
            if (_orderWithStatus is null || _hubConnection is null)
            {
                return;
            }

            if (_orderWithStatus.IsDelivered)
            {
                await _hubConnection.InvokeAsync(
                    OrderStatusHubConsts.MethodNames.StopTrackingOrder, _orderWithStatus.Order);
                await _hubConnection.StopAsync();
            }
            else
            {
                await _hubConnection.InvokeAsync(
                    OrderStatusHubConsts.MethodNames.StartTrackingOrder, _orderWithStatus.Order);
            }
        }
        catch (AccessTokenNotAvailableException ex)
        {
            ex.Redirect();
        }
        catch (Exception ex)
        {
            _invalidOrder = true;
            Console.Error.WriteLine(ex);
        }
        finally
        {
            StateHasChanged();
        }
    }

    private Task OnOrderStatusChangedAsync(OrderWithStatus orderWithStatus) =>
        InvokeAsync(() =>
        {
            _orderWithStatus = orderWithStatus;
            StateHasChanged();
        });

    public async ValueTask DisposeAsync()
    {
        if (_hubConnection is not null)
        {
            if (_orderWithStatus is not null)
            {
                await _hubConnection.InvokeAsync(
                    OrderStatusHubConsts.MethodNames.StopTrackingOrder, _orderWithStatus.Order);
            }

            await _hubConnection.DisposeAsync();
            _hubConnection = null;
        }
    }
}

前面的 C# 组件表示 OrderDetails 组件的代码隐藏文件,这可能是因为类被定义为 partial。 它是 IAsyncDisposable 的实现,用于清理 HubConnection 实例。 该组件定义了多个类范围的字段,每个字段都有各自的意图:

  • _hubConnection:用于调用 SignalR 服务器上的方法的连接。
  • _orderWithStatus:上下文订单及其当前状态。
  • _invalidOrder:指示订单是否无效的值。

在此次重构中,原来 @code { ... } 指令中所有需要的代码如今都在代码隐藏文件中:

  • [Parameter] public int OrderId { get; set; } 完全相同。
  • 由特性修饰的属性 [Inject] public OrdersClient OrdersClient { get; set; } 将取代 @inject OrdersClient OrdersClient 指令。

SignalR 连接的配置需要一些额外的注入属性:

  • [Inject] public NavigationManager Nav { get; set; } 属性用于解析中心的终结点。
  • [Inject] public IAccessTokenProvider AccessTokenProvider { get; set; } 属性用于将 AccessTokenProvider 分配给中心连接的 options 对象。 这样可确保所有通信均正确提供已通过身份验证的用户的访问令牌。

OnInitializedAsync 替代方法使用 HubConnectionBuilder 对象来生成 _hubConnection 实例。 它配置为自动重新连接并指定 MessagePack 协议。 对于 _hubConnection 实例,该页面将订阅 "OrderStatusChanged" 中心事件,并将 OnOrderStatusChangedAsync 设置为其相应的事件处理程序。 然后,使用 HubConnection.StartAsync 启动中心连接。

OnOrderStatusChangedAsync 事件处理程序方法接受 OrderWithStatus 实例作为其参数,然后返回 Task。 它重新分配 _orderWithStatus 类变量并调用 StateHasChanged()

分配 OrderId 后,将调用 OnParametersSetAsync 替代方法。 _orderWithStatus 对象的初始状态是通过 OrdersClient.GetOrder 调用分配的。 如果订单已送达,系统将不再跟踪订单,并使用 HubConnection.StopAsync 停止中心连接。 如果订单尚未送达,将继续跟踪订单。