Refactor by using ASP.NET Core SignalR

Completed

In this unit, you'll learn how client-side polling was refactored out and ASP.NET Core SignalR was added. Both the server and the client applications required refactoring. The server hub had to be created, mapped, and configured. The client then had to establish a connection to the hub and handle order status change notifications.

NuGet packages

The BlazingPizza.Server.csproj file added the following NuGet package reference:

<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>

The BlazingPizza.Client.csproj file added the following NuGet package references:

<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>

The MessagePack NuGet package was added to make messages sent between the client and server smaller.

Refactored server app

The server is responsible for exposing a SignalR endpoint. The endpoint maps to a Hub or Hub<T> subclass. The server exposes hub methods that can be called from clients, and it exposes events that clients can subscribe to.

The addition of the OrderStatusHub

ASP.NET Core SignalR supports strongly typed hub implementations, Hub<T>, where T is the client type. Consider the following IOrderStatusHubs.cs file:

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);
}

The preceding interface defines a single method, which acts as an event that clients can subscribe to. Consider the following OrderStatusHub.cs file:

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());
}

The preceding hub implementation exposes two methods that are invokable from clients. A client calls StartTrackingOrder and is given an order instance, and the client's unique connection is added to a group where notifications will be sent. Likewise, a call to StopTrackingOrder has the connection leave the group and no longer receive notifications.

SignalR server configuration

The Startup class needed to be updated to add ASP.NET Core SignalR and the MessagePack protocol. The "/orderstatus" endpoint is also mapped to the OrderStatusHub implementation. Consider the following Startup.cs file:

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");
        });
    }
}

The preceding code highlights these changes:

  • Add SingalR and the MessagePack protocol.
  • Map OrderStatusHub to the "/orderstatus" endpoint.

Refactored client app

The client application needed to refactor the OrderDetails.razor file:

@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>

You'll notice that the process has removed several directives:

  • The @using System.Threading directive is no longer needed in the markup.
  • The @inject OrdersClient OrdersClient directive is now part of the code-behind.
  • The @implements IDisopsable has been replaced in the code-behind with an implementation of IAsyncDisposable.
  • The @code { ... } directive has been removed, because all logic is now in the code-behind.

Additionally, the Blazor component is now more pure because it primarily represents binding and templating. The logic has been moved to a code-behind file instead.

Grow component with code-behind

As more logic is introduced into Blazor components, it's common for them to evolve into two separate files:

  • OrderDetails.razor: The Razor markup.
  • OrderDetails.razor.cs: The corresponding C# code-behind. It's the same component but only a partial class.

Consider the OrderDetails.razor.cs file:

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;
        }
    }
}

The preceding C# component represents the code-behind for the OrderDetails component, which is possible because the class is being defined as partial. It's an implementation of IAsyncDisposable, which cleans up the HubConnection instance. This component defines several class-scoped fields, each with its own intent:

  • _hubConnection: A connection that's used to invoke methods on the SignalR Server.
  • _orderWithStatus: The contextual order and its current status.
  • _invalidOrder: A value that indicates whether the order is invalid.

With this refactoring, all required code that was in the original @code { ... } directive is now in the code-behind:

  • The [Parameter] public int OrderId { get; set; } is exactly the same.
  • The attribute-decorated property, [Inject] public OrdersClient OrdersClient { get; set; }, replaces the @inject OrdersClient OrdersClient directive.

The configuration of the SignalR connection requires a few additional injected properties:

  • The [Inject] public NavigationManager Nav { get; set; } property is used to resolve the hub's endpoint.
  • The [Inject] public IAccessTokenProvider AccessTokenProvider { get; set; } property is used to assign the AccessTokenProvider to the hub connection's options object. This ensures that all communications correctly provide the authenticated user's access token.

The OnInitializedAsync override method uses the HubConnectionBuilder object to build the _hubConnection instance. It's configured to automatically reconnect, and it specifies the MessagePack protocol. With the _hubConnection instance, the page subscribes to the "OrderStatusChanged" hub event and sets the OnOrderStatusChangedAsync as its corresponding event handler. The hub connection is then started using HubConnection.StartAsync.

The OnOrderStatusChangedAsync event handler method accepts the OrderWithStatus instance as its parameter and then returns Task. It reassigns the _orderWithStatus class variable and calls StateHasChanged().

The OnParametersSetAsync override method is called when the OrderId is assigned. The initial state of the _orderWithStatus object is assigned from the OrdersClient.GetOrder call. If the order has been delivered, it's no longer tracked, and the hub connection is stopped using HubConnection.StopAsync. If the order has not yet been delivered, it continues to be tracked.