Effettuare il refactoring usando SignalR ASP.NET Core

Completato

In questa unità si apprenderà come è stato effettuato il refactoring del polling lato client e come è stato aggiunto SignalR ASP.NET Core. Sia il server che le applicazioni client richiedevano il refactoring. Si è dovuto creare, mappare e configurare l'hub del server. Il client ha dovuto quindi stabilire una connessione all'hub e gestire le notifiche di modifica dello stato dell'ordine.

Pacchetti NuGet

Il file BlazingPizza.Server.csproj ha aggiunto il riferimento al pacchetto NuGet seguente:

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

Il file BlazingPizza.Client.csproj ha aggiunto il riferimento al pacchetto NuGet seguente:

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

Il pacchetto NuGet MessagePack è stato aggiunto per ridurre le dimensioni dei messaggi inviati tra il client e il server.

App server con refactoring

Il server è responsabile dell'esposizione di un endpoint di SignalR. L'endpoint è mappato a una sottoclasse Hub o Hub<T>. Il server espone i metodi dell'hub che possono essere chiamati dai client, e gli eventi che i client possono sottoscrivere.

Aggiunta di OrderStatusHub

SignalR ASP.NET Core supporta implementazioni hub “fortemente tipizzate”, ad esempio Hub<T>, dove T è il tipo di client. Si consideri il file IOrderStatusHubs.cs seguente:

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

L'interfaccia precedente definisce un metodo singolo, che funge da evento che i client possono sottoscrivere. Si consideri il file OrderStatusHub.cs seguente:

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

L'implementazione dell'hub precedente espone due metodi richiamabili dai client. Un client chiama StartTrackingOrder e riceve un'istanza order e la connessione univoca del client viene aggiunta a un gruppo da cui verranno inviate le notifiche. Analogamente, a seguito di una chiamata a StopTrackingOrder la connessione lascia il gruppo e non riceve più notifiche.

Configurazione del server SignalR

La classe Startup ha dovuto essere aggiornata per aggiungere SignalR ASP.NET Core e il protocollo MessagePack. Viene eseguito anche il mapping dell'endpoint "/orderstatus" all'implementazione OrderStatusHub. Si consideri il file Startup.cs seguente:

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

Il codice precedente evidenzia queste modifiche:

  • Aggiungere SingalR e il protocollo MessagePack.
  • Eseguire il mapping di OrderStatusHub all'endpoint "/orderstatus".

App client con refactoring

L'applicazione client necessaria per effettuare il refactoring del file 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>

Si noterà che il processo rimuove diverse direttive:

  • La direttiva @using System.Threading non è più necessaria nel markup.
  • La direttiva @inject OrdersClient OrdersClient fa ora parte del code-behind.
  • @implements IDisopsable è sostituito nel code-behind con un'implementazione di IAsyncDisposable.
  • La direttiva @code { ... } viene rimossa perché tutta la logica ora si trova nel code-behind.

Inoltre, il componente Blazor è ora più puro perché rappresenta principalmente l'associazione e la creazione di modelli. La logica viene invece spostata in un file code-behind.

Aumentare il componente con code-behind

Man mano che vengono introdotte più logiche nei componenti Blazor, si evolvono generalmente in due file distinti:

  • OrderDetails.razor: il markup Razor.
  • OrderDetails.razor.cs: il code-behind C# corrispondente. Si tratta dello stesso componente, ma di un unico oggetto partial class.

Si consideri il file 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;
        }
    }
}

Il componente C# precedente rappresenta il code-behind per il componente OrderDetails, che è possibile perché la classe viene definita come partial. Si tratta di un'implementazione di IAsyncDisposable, che pulisce l'istanza HubConnection. Questo componente definisce diversi campi con ambito classe, ognuno con la propria finalità:

  • _hubConnection: Una connessione usata per richiamare i metodi nel server SignalR.
  • _orderWithStatus: ordine contestuale e relativo stato corrente.
  • _invalidOrder: un valore che indica se l'ordine non è valido.

Con questo refactoring tutto il codice necessario presente nella direttiva @code { ... } originale è ora incluso nel code-behind:

  • [Parameter] public int OrderId { get; set; } è esattamente uguale.
  • La proprietà decorata con attributi, [Inject] public OrdersClient OrdersClient { get; set; }, sostituisce la direttiva @inject OrdersClient OrdersClient.

La configurazione della connessione SignalR richiede alcune proprietà aggiuntive inserite:

  • La proprietà [Inject] public NavigationManager Nav { get; set; } viene usata per risolvere l'endpoint dell'hub.
  • La proprietà [Inject] public IAccessTokenProvider AccessTokenProvider { get; set; } viene usata per assegnare AccessTokenProvider all'oggetto opzioni della connessione dell'hub. Questa proprietà garantisce che tutte le comunicazioni forniscano correttamente il token di accesso dell'utente autenticato.

Il metodo di override OnInitializedAsync usa l'oggetto HubConnectionBuilder per compilare l'istanza _hubConnection. L’istanza _hubConnection è configurata per la riconnessione automatica e specifica il protocollo MessagePack. Con l'istanza _hubConnection, la pagina sottoscrive l'evento dell'hub "OrderStatusChanged" e imposta OnOrderStatusChangedAsync come gestore dell'evento corrispondente. La connessione dell'hub viene quindi avviata usando HubConnection.StartAsync.

Il metodo del gestore dell'evento OnOrderStatusChangedAsync accetta l'istanza OrderWithStatus come parametro e quindi restituisce Task. Riassegna la variabile di classe _orderWithStatus e chiama StateHasChanged().

Il metodo di override OnParametersSetAsync viene chiamato quando viene assegnato OrderId. Lo stato iniziale dell'oggetto _orderWithStatus viene assegnato dalla chiamata OrdersClient.GetOrder. Se l'ordine è stato recapitato, non viene più monitorato e la connessione dell'hub viene arrestata tramite HubConnection.StopAsync. Se l'ordine non è ancora stato recapitato, continua a essere monitorato.