Blazor: creare Rich Web UI secondo Microsoft

Che cosa è Blazor?

Blazor è un framework che permette di costruire Rich Web UI con l’ausilio di Microsoft .NET Framework.

Blazor nasce dalla necessità di riuscire a creare applicazioni web complesse e ricche di interazioni utente senza dover necessariamente utilizzare Javascript, ma avvalendosi di un linguaggio moderno ed evoluto come C#.

Come primo aspetto da dover analizzare prima di affrontare nel dettaglio il funzionamento di Blazor, sono i due modelli di hosting supportati.


Blazor Server vs Blazor WebAssembly

Blazor Server (Server-side) Questa modalità è stata rilasciata con .NET Core 3 e prevede che il componente Blazor giri su un server e che tutta la parte di comunicazione ed interazione con il client sia gestita con una real-time connection tramite WebSocket con SignalR.

Blazor WebAssembly (Client-side) Questa modalità è stata rilasciata con .NET Core 3.1 ed in questo caso il componente Blazor viene impacchettato in un WebAssembly (WA) ed è eseguito direttamente nel browser dell’utente.
Affinché questo tipo di approccio possa funzionare, è necessario che siano soddisfatti due prerequisiti fondamentali:

  1. Il browser utilizzato deve supportare l’uso dei WA. Questo vincolo dal 2017 non risulta essere un problema in quanto tutti i moderni browser ora supportano l’utilizzo dei WA.
  2. E’ necessario avere un .NET Runtime compilato in un WA. Anche questo punto però è stato risolto con lo sviluppo di .NET Core 3.0.

E’ importante sottolineare che la scelta di quale delle due modalità di hosting usare non è vincolante, questo vuol dire che una Blazor App di tipo Serever-Side può essere portata ad una versione Client-Side e viceversa senza dover sconvolgere il progetto.

Blazor Server

Blazor è un Component Oriented UI Framework, questo vuol dire che l’applicazione stessa, le pagine e gli elementi contenuti, sono tutti Component relazionati in gerarchia fra loro.
La sintassi utilizzata per programmare questi Component è Razor ed ogni file .razor è un Component.

Vediamo come creare una nuova App Blazor Server con Visual Studio 2019:

In allegato a questo articolo è possibile scaricare un progetto di esempio creato da Steven Sanderson e da lui trattato durante uno speech del 2019, in cui vengono illustrate alcune potenzialità di Blazor.
In particolare, nella solution MyCompanyApp, viene creato un semplice Component bonus.razor il cui scopo è quello di mostrare degli slider di ripartizione di un bonus economico tra diversi reparti di un’azienda.
Di seguito il codice:

@page "/bonus"

<h3>Bonus splitter</h3>

<div class="budget">
    @foreach (var item in budgetItems)
    {
        <span>@item.Name:</span>
        <span>@item.Amount.ToString("c0")</span>
        <input type="range" max="@(item.Amount + Remaining)" step="1000"
               @bind="item.Amount" @bind:event="oninput"
               style="width: @( Math.Round(100 * (item.Amount + Remaining) / totalBudget,0))%" />
    }
    <span>Remaining:</span>
    <span>@Remaining.ToString("c0")</span>
</div>

<button disabled="@(Remaining > 0)">Save</button>

@code {
    decimal totalBudget = 1000000;

    decimal Remaining => totalBudget - budgetItems.Sum(x => x.Amount);

    List<BudgetItem> budgetItems = new List<BudgetItem>
    {
        new BudgetItem { Name = "Developers" },
        new BudgetItem { Name = "Managers" },
        new BudgetItem { Name = "Sales" },
    };
}

Il primo aspetto e quello più evidente è l’utilizzo della sintassi di markup Razor tramite cui è possibile includere codice server-based in una pagina web. Come si può notare un file .razor è un mix di codice C# e HTML connessi dagli appositi markup Razor.
Razor è uno strumento che già troviamo nel mondo Microsoft da diversi anni, quindi in questo articolo andremo ad analizzare quegli aspetti innovativi introdotti con Blazor.
All’interno di questo Component, seppur molto semplice nella sua logica, ci sono vari elementi di novità che ci possono far capire le potenzialità fornite da questo Framework senza dover scrivere una sola riga in Javascript:

  • @page "/bonus" definisce che il componente in oggetto viene caricato seguendo la regola di route definita “/bonus
  • @bind a riga 11 viene definito che il valore dell’elemento di input definito sul client deve essere in binding con la variabile item.Amount. Questo binding è bidirezionale, quindi al variale del valore dell’input, la property item.Amount verrà anch’essa aggiornata.
  • @bind:event="oninput" a riga 11 viene definito un’ulteriore opzione; di default l’aggiornamento del value dell’input (e quindi dell’item.Amount) avviene nel momento in cui rilascio il click del mouse e di fatto termino lo slide del range input control. Tramite il parametro @bind:event è possibile ridefinire l’evento di default che governa il binding. Per questo esempio specifico si vuole imporre che l’aggiornamento del valore avvenga non appena viene modificata la posizione del range element.
  • @Remaining.ToString("c0") è possibile sfruttare .NET per impostare le formattazioni dei valori
  • style="width: @( Math.Round(100 * (item.Amount + Remaining) / totalBudget,0))%"anche questo aspetto è interessante, ovvero pensare di controllare dinamicamente gli stili grafici degli elementi client direttamente tramite delle espressioni C#.

Blazor Client

Già in fase di creazione di un nuovo progetto in Visual Studio 2019 si ha la possibilità di scegliere se si desidera impostare il progetto per un hoting mode di tipo WebAssembly.
Scegliendo questo target, si abilita anche l’opzione Progressive Web Application, di cui analizzeremo in seguito le caratteristiche di questa ulteriore importante feature:

Similmente a quanto fatto per precedente, utilizzeremo un altro esempio sempre realizzato da Steven Sanderson, scaricabile qui, che utilizzeremo come base per analizzare alcune delle importanti caratteristiche di questa tipologia di soluzione.

La differenza principale che si nota immediatamente è la suddivisione della solution in due progetti:

  • BlazorMart.Server: applicazione back-end che si occupa di recuperare le info necessarie dalla base dati
  • BlazorMart.Client: applicazione Blazor Web Assembly

Similmente a quanto fatto prima per la soluzione di tipo Blazor Server, proviamo a mettere il focus sul main Component del progetto BlazorMart.Client, ovvero sul file App.razor

@inject Cart Cart

<header>
    <SearchBox OnItemChosen="HandleItemChosen" />
</header>

<main>
    @foreach (var row in Cart.Rows)
    {
        <CartRowDisplay @key="row.Key" Data="row" />
    }
</main>

@if (Cart.HasAnyProducts)
{
    <footer>
        <button class="remove-item" @onclick="EnterRemoveMode">✗ Remove item</button>
        <div>Total: <span class="price">@Cart.GrandTotal.ToString("c")</span></div>
        <button>💳 Checkout</button>
    </footer>
}

@if (isInRemoveMode)
{
    <Overlay>
        <p>Scan an item to remove</p>
        <button @onclick="@(() => { isInRemoveMode = false; })">Cancel</button>
    </Overlay>
}

@code {
    bool isInRemoveMode;

    void EnterRemoveMode()
    {
        isInRemoveMode = true;
    }

    async Task HandleItemChosen(string ean)
    {
        if (isInRemoveMode)
        {
            Cart.RemoveItem(ean);
            isInRemoveMode = false;
        }
        else
        {
            await Cart.AddItemAsync(ean);
        }
    }
}

Non è difficile notare immediatamente come possa essere semplice lavorare con un Framework Component Oriented. Infatti la Single Page App in questione è suddivisa sostanzialmente in tre sezioni, ovvero header, main ed un footer.
Le sezioni al loro interno richiamano altri Component definiti in altri file .razor utilizzando come tag HTML il nome del Component stesso. Questo approccio facilita la riutilizzabilità del codice e ne favorisce anche la sua modularizzazione.
Alcune delle peculiarità di Blazor sono già state affrontate nella sezione precedente, ora il focus verrà posto su altre feature chiave per la soluzione di tipo Web Assembly.

gRPC

All’interno del file App.razor è stata implementata una chiamata che aggiunge un nuovo elemento ad un oggetto di tipo carrello (Cart).
La chiamata AddItemAsync di fatto si occupa di recuperare i dettagli del prodotto dato un codice EAN in input. Questi dettagli però risiedono nel back-end del Server, quindi quello che normalmente si fa è fare una chiamata API RESTful per ottenere le informazioni che servono. Questo approccio resta corretto logicamente, ma ha quale limite; nella maggior parte dei casi il content della risposta dal Server è in formato JSON il quale, seppur semplice da usare, porta con sè alcuni limiti intrinseci:

  • Verbose: le informazioni nel JSON sono molto ridondanti. Questo genera genera traffico superfluo.
  • Javascript Object Notation (JSON): di fatto è un formato basato su Javascript ed in quanto tale è per sua natura loosely typed e questa cosa può essere spesso fonte di errori durante il processo di implementazione.

La soluzione proposta in questo esempio è di utilizzare gRPC, ovvero un sistema di chiamata di procedura remota open source sviluppato da Google. Lo schema è semplice, viene definito un file .proto lato Server in cui vengono dichiarate tutte le chiamate e le strutture che il server espone. Da questo file .proto viene generata automaticamente una classe Service che crea lo scheletro degli oggetti e delle chiamate definite, quello che bisogna fare lato server è l’ovverride dei metodi dichiarati con le rispettive implementazioni.

Vediamo nel concreto dell’esempio in esame:

  1. Come prima cosa lato server occorre installare da NuGet i pacchetti Grpc.AspNetCore e Knowit.Grpc.Web
  2. Definire un file proto in cui vengono esposte le chiamate e le rispettive strutture dati
syntax = "proto3";

option csharp_namespace = "BlazorMart.Server";

package Inventory;

service Inventory {
  rpc Autocomplete (AutocompleteRequest) returns (AutocompleteReply);
  rpc ProductDetails (ProductDetailsRequest) returns (ProductDetailsResponse);
}

message AutocompleteRequest {
  string searchQuery = 1;
}

message AutocompleteReply {
  repeated AutocompleteItem items = 1;
}

message AutocompleteItem {
    string EAN = 1;
    string name = 2;
}

message Product {
	string EAN = 1;
	string name = 2;
	int32 price = 3;
	string description = 4;
	string imageUrl = 5;
}

message ProductDetailsRequest {
	string EAN = 1;
}

message ProductDetailsResponse {
	Product Product = 1;
}
  1. Aggiungere la classe InventoryService.cs che implementa le chiamate esposte nel file proto
public class InventoryService : Inventory.InventoryBase
    {
        private static Product[] _products = JsonSerializer.Deserialize<Product[]>(
            File.ReadAllText("products.json"));

        public override Task<AutocompleteReply> Autocomplete(AutocompleteRequest request, ServerCallContext context)
        {
            var result = new AutocompleteReply();

            if (!string.IsNullOrEmpty(request.SearchQuery))
            {
                var matches = _products
                    .Where(p => p.Name.StartsWith(request.SearchQuery, StringComparison.CurrentCultureIgnoreCase))
                    .Select(p => new AutocompleteItem { EAN = p.EAN, Name = p.Name })
                    .Take(10); // Limit to this many results
                result.Items.AddRange(matches);
            }

            return Task.FromResult(result);
        }

        public override async Task<ProductDetailsResponse> ProductDetails(ProductDetailsRequest request, ServerCallContext context)
        {
            await Task.Delay(500); // Look busy
            var product = _products.FirstOrDefault(p => p.EAN == request.EAN);
            return new ProductDetailsResponse { Product = product };
        }
    }
  1. A questo punto lato Server l’implementazione gRPC è ultimata.
    Ora spostiamo l’attenzione lato Client e cerchiamo di rispondere alla domanda: come si può richiamare il Server tramite il servizio definito? Molto semplicemente andando ad aggiungere una reference all’interno del file .csproj del progetto Client
<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>netstandard2.1</TargetFramework>
    <OutputType>Exe</OutputType>
    <LangVersion>7.3</LangVersion>
    <RazorLangVersion>3.0</RazorLangVersion>
    
    <!-- Because the linker doesn't yet work with netstandard2.1 -->
    <BlazorLinkOnBuild>false</BlazorLinkOnBuild>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Blazor" Version="3.0.0-preview9.19457.4" />
    <PackageReference Include="Microsoft.AspNetCore.Blazor.Build" Version="3.0.0-preview9.19457.4" PrivateAssets="all" />
    <PackageReference Include="Microsoft.AspNetCore.Blazor.HttpClient" Version="3.0.0-preview9.19457.4" />
    <PackageReference Include="Microsoft.AspNetCore.Blazor.DevServer" Version="3.0.0-preview9.19457.4" PrivateAssets="all" />

    <PackageReference Include="Google.Protobuf" Version="3.13.0" />
    <PackageReference Include="Grpc.Net.Client" Version="2.32.0" />
    <PackageReference Include="Grpc.Tools" Version="2.32.0" PrivateAssets="all">
      <IncludeAssets>build; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>
  </ItemGroup>

  <ItemGroup>
    <Protobuf Include="..\BlazorMart.Server\Protos\inventory.proto" GrpcServices="Client" />
  </ItemGroup>

</Project>
  1. A questo punto lato Client sarà possibile richiamare il Server sfruttando l’istanza autogenerata dell’Inventory Service.

PWA

Con PWA si intende tipicamente una Single Page Application (SPA) che sfrutta le attuali API che i Browser moderni mettono a disposizione per far diventare un’applicazione da funzionante solamente via Web in un’applicazione Client a tutti gli effetti, quindi installata direttamente sul device in uso ed avviabile tramite un’icona presente sulla Home del dispositivo stesso.

Non solo, ma avvalendosi di uno strumento chiamato Service Worker, è possibile far girare l’applicazione Client anche offline, sfruttando la cache del dispositivo.

PWA – Come creare un’applicazione installabile sul Client?

Basta aggiungere al progetto Client un file manifest.json che contiene alcune informazioni di base necessarie all’installazione.

{
  "short_name": "BlazorMart",
  "name": "BlazorMart",
  "icons": [
    {
      "src": "icon-512.png",
      "type": "image/png",
      "sizes": "512x512"
    }
  ],
  "start_url": "/",
  "background_color": "#006B41",
  "display": "standalone",
  "scope": "/",
  "theme_color": "#006B41"
}

Dopo aver creato il manifest file, è necessario includere tale file all’interno dell’ <head> della index page come segue:

<link rel="manifest" href="manifest.json" />

Quanto mostrato nel manifest precedente è un esempio semplice con un subset di tutti i parametri disponibili, per avere maggiori dettagli di configurazione consultare il link ufficiale con le specifiche W3C

PWA – Service Worker

Il Service Worker di fatto è un semplice file Javascript inserito all’interno del progetto Client che si occupa di intercettare tutte le richieste di rete fatte dall’applicazione e, nel caso di connessione attiva le gestisce in modo standard, in caso contrario sfrutta la cache del dispositivo. In questo modo l’applicazione risulta almeno in parte funzionante, ovviamente per tutte le interazioni che richiedono il recupero di informazioni presenti solo sul Server, si possono introdurre dei meccanismi di retry per mettere in attesa l’utente, senza bloccarlo completamente.
Vediamo un esempio di file service.worker.js:

const cacheName = 'offline-cache';
let handleAsOfflineUntil = 0;

self.addEventListener('install', async event => {
    console.log('Installing service worker...');
    await Promise.all((await caches.keys()).map(key => caches.delete(key)));
    await (await caches.open(cacheName)).addAll(['loading.gif']);
    self.skipWaiting();
});

self.addEventListener('fetch', event => {
    // Don't interfere with API calls
    if (event.request.method !== 'GET' || event.request.url.indexOf('/_framework/debug') >= 0) {
        return;
    }

    event.respondWith(getFromNetworkOrCache(event.request));
});
  
async function getFromNetworkOrCache(request) {
    if (new Date().valueOf() > handleAsOfflineUntil) {
        try {
            const networkResponse = await fetchWithTimeout(request, 1000);
            (await caches.open(cacheName)).put(request, networkResponse.clone());
            console.info('Fetched from network: ' + request.url);
            return networkResponse;
        } catch (ex) {
            handleAsOfflineUntil = new Date().valueOf() + 3000; // Next 3 seconds
        }
    }

    // Fall back on cache
    console.info('Fetching from cache: ' + request.url);
    return caches.match(request);
}

function fetchWithTimeout(request, timeoutMs) {
    return new Promise((resolve, reject) => {
        setTimeout(() => reject('Timed out'), timeoutMs);
        fetch(request).then(resolve, reject);
    });
}

Similmente a quanto fatto per il file manifest.json, affinchè il Service Worker venga installato è necessario importarlo nella index page nel modo seguente:

<script>
    navigator.serviceWorker.register('service-worker.js');
</script>
Blazor ClientBlazor Server
– PWA
– Funzionamento offline
– Minimizzare i contenuti da scaricare
– Elaborazione maggiore demandata ai Server
– Maggiore consumo CPU device
– Consumo dati iniziale per download Web Assembly
– Maggiori interazioni di rete

Conclusioni

Le potenzialità di questo Framework sono molto chiare, non ci sono dubbi che utilizzare un linguaggio come C# per programmare delle Web UI sia un desiderio per tantissimi programmatori e fino ad oggi tutte le soluzioni proposte sul mercato non hanno mai trovato una soluzione efficiente e scalabile come quella di Blazor.
Il modello Blazor-Client con tecnologia WA sicuramente è quello che offre più prospettive e scenari, soprattutto in combinazione con la feature PWA, tuttavia per il momento non si può non considerare che il download del WA con relativo .NET Core Runtime sicuramente è un punto dolente. Anche se si parla di alcuni MB come ordine di grandezza, bisogna tener conto che la copertura Internet ad alta velocità è tutt’altro che da dare per scontata e questo potrebbe essere un limite non trascurabile in un buon numero di scenari nel mondo mobile.
Blazor è un Framework che lascia tante possibilità implementative e sarà tanto più diffuso maggiori saranno i Component pronti all’uso per gli sviluppatori unitamente al fatto che nelle prossime release verranno rilasciate delle migliorie che aumenteranno il grado di maturità del Framework stesso.

L’esempio Razor Server-side presente in questa guida è scaricabile a questo link.
L’esempio Razor Client-side presente in questa guida è scaricabile a questo link.

Link utili

Lascia un commento

Il tuo indirizzo email non sarà pubblicato. I campi obbligatori sono contrassegnati *

Questo sito usa Akismet per ridurre lo spam. Scopri come i tuoi dati vengono elaborati.