saverioriotto.it

Creare una web app in tempo reale con Blazor e SignalR

In questo tutorial, testeremo Blazor WebAssembly creando una piccola applicazione che sfrutta SignalR per la sua funzionalità in tempo reale sia sul front-end che sul back-end. Useremo anche le librerie ASP.NET più recenti e verificheremo alcune delle nuove funzionalità del linguaggio.

Creare una web app in tempo reale con Blazor e SignalR

Nell’articolo precedente, dedicato a Blazor, abbiamo dedotto che è visto come un’importante libreria per sviluppare applicazioni full stack usando .Net. Con l’avvento delle Single Page Application, dove l’interfaccia viene creata dinamicamente all’interno della pagina HTML senza la necessità di ri-caricare la pagina stessa.

Con Blazor, Microsoft ha fornito allo sviluppatore un unico mezzo con il quale sviluppare a 360° sfruttando l’esperienza accumulata nell’ambiente .NET. Utilizzando codice .NET è infatti possibile usufruire delle caratteristiche dei linguaggi pensati per le SPA come, ad esempio, ri-caricare solo parti della pagina che devono essere aggiornate, riducendo il carico sul server e rendendolo più immediato all’utente finale.

Prima di procedere al tutorial e mettere in atto quando detto sopra vorrei parlarti di SignalIR.

Cos’è SignalR

E’ una libreria per sviluppatori che aggiunge agli applicativi la funzionalità Real-Time. Permette al server sia di spedire i dati al client non appena disponibili e senza aspettare la sua richiesta, sia di rimanere in attesa di una richiesta di nuove informazioni da parte dello stesso client.

Può essere usato quindi per sviluppare applicativi come chat, dashboard, App di monitoraggio.

SignalR usa gli hub per comunicare tra client e server.

Un hub è una pipeline di alto livello che consente a un client e a un server di chiamare metodi l’uno sull’altro. Le comunicazioni con SignalR possono avvenire con l’ausilio di due protocolli: Json (testo) e MessagePack (binario).

La comunicazione avviene inviando messaggi, serializzati secondo il protocollo scelto, che contengono nome e parametri del metodo lato client. Chi è in ascolto cerca di trovare una corrispondenza e una volta trovata, chiama il metodo e gli passa i parametri deserializzati.

Fatta questa premessa procediamo all'implementazione della nostra applicazione web Real-Time.

Ambiente di sviluppo utilizzato

 - Visual Studio 2022
 - .Net 6.0 LTS
 - C# 10

Creazione di un’applicazione Blazor WebAssembly con Visual Studio 2022

Il primo passo da fare ovviamente è creare un'applicazione Web con Visual Studio 2022 utilizzando il modello Blazor WebAssembly.

config_blazor_project.webp (13 KB)

Inserire i dati richiesti e successivamente selezione anche l'opzione "ASP.NET Core hosted".

New_blazor_project.webp (8 KB)

Se osservi la struttura della soluzione, puoi notare che per impostazione predefinita vengono creati 3 diversi progetti.

project_structuresr.webp (26 KB)

Implementazione del codice della nostra web app real-time

Per questo tutorial creiamo un’applicazione che permette di effettuare operazioni CRUD da interfaccia web e aggiornare tutti i client in tempo reale sui dati di libri. Quindi la prima cosa da fare è creare una classe “Book” nel progetto Shared.

namespace BlazorRealTimeApp.Shared
{
    public class Book
    {
        public string Id { get; set; } = string.Empty;
        public string Isbn { get; set; } = string.Empty;
        public string Name { get; set; } = string.Empty;
        public string Author { get; set; } = string.Empty;
        public double Price { get; set; } = 0.0;
    }
}

Adesso procediamo ad installare la libreria "Microsoft.AspNetCore.SignalR.Client" utilizzando il gestore di pacchetti NuGet nel progetto "Client". Successivamente dobbiamo registrare il componente SignalR all'interno del metodo “ConfigureServices” della classe Startup (progetto Server).

builder.Services.AddSignalR();
builder.Services.AddResponseCompression(opts =>
{
    opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(
        new[] { "application/octet-stream" });
});

var app = builder.Build();

Adesso per poter far funzionare SignalIR con la nostra applicazione dobbiamo implementare un Hub che permette di inviare e ricevere notifiche. Quindi Creiamo una classe "BroadcastHub" all'interno di una nuova cartella "Hub" nel progetto Server che eredita la classe "Hub" dalla libreria SignalR.

namespace BlazorRealTimeApp.Server.Hubs
{
    public class BroadcastHub : Hub
    {
        public async Task SendMessage()
        {
            await Clients.All.SendAsync("ReceiveMessage");
        }
    }
}

Adesso aggiungiamo gli endpoint per la classe BroadcastHub nel metodo Configure della classe Startup. L'abbiamo chiamato "broadcastHub". Questo verrà utilizzato nei nostri componenti Razor più avanti nel progetto Client.

app.MapRazorPages();
app.MapControllers();
app.MapHub<BroadcastHub>("/broadcastHub");
app.MapFallbackToFile("index.html");

app.Run();


Adesso dobbiamo impostare la nostra classe controller lato server che ci fornirà i metodi per le operazioni CRUD. Implementiamo, quindi, il nostro controller standard, chiamato BooksController, con l’attributo [ApiController] ereditato dalla classe Controller e gli innestiamo, grazie alla Dependency Injection il nostro BooksControllerContext che ci servirà a scatenare gli eventi in real-time che comunicheranno i dati al client.

Possiamo anche creare il controller utilizzando il modello di Scaffolding presente su Visual Strudio.

scaffolding.webp (5 KB)

Verrà generata la classe BooksController.cs con tutti i metodi CRUD:

namespace BlazorRealTimeApp.Server.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class BooksController : ControllerBase
    {
        private readonly BooksControllerContext _context;

        public BooksController(BooksControllerContext context)
        {
            _context = context;
        }

        // GET: api/Books  
        [HttpGet]
        public async Task<ActionResult<IEnumerable<Book>>> GetBook()
        {
            return await _context.Book.ToListAsync();
        }

        // GET: api/Books/5  
        [HttpGet("{id}")]
        public async Task<ActionResult<Book>> GetBook(string id)
        {
            var book = await _context.Book.FindAsync(id);

            if (book == null)
            {
                return NotFound();
            }

            return book;
        }

        // PUT: api/Books/5  
        // To protect from overposting attacks, enable the specific properties you want to bind to, for  
        // more details, see https://go.microsoft.com/fwlink/?linkid=2123754.  
        [HttpPut("{id}")]
        public async Task<IActionResult> PutBook(string id, Book book)
        {
            if (id != book.Id)
            {
                return BadRequest();
            }

            _context.Entry(book).State = EntityState.Modified;

            try
            {
                await _context.SaveChangesAsync();
            }
            catch (DbUpdateConcurrencyException)
            {
                if (!BookExists(id))
                {
                    return NotFound();
                }
                else
                {
                    throw;
                }
            }

            return NoContent();
        }

        // POST: api/Books  
        // To protect from overposting attacks, enable the specific properties you want to bind to, for  
        // more details, see https://go.microsoft.com/fwlink/?linkid=2123754.  
        [HttpPost]
        public async Task<ActionResult> PostBook(Book book)
        { 
            _context.Book.Add(book);
            try
            {
                await _context.SaveChangesAsync();
            }
            catch (DbUpdateException)
            {
                if (BookExists(book.Id))
                {
                    return Conflict();
                }
                else
                {
                    throw;
                }
            }

            return Ok();
        }

        // DELETE: api/Books/5  
        [HttpDelete("{id}")]
        public async Task<ActionResult<Book>> DeleteBook(string id)
        {
            var book = await _context.Book.FindAsync(id);
            if (book == null)
            {
                return NotFound();
            }

            _context.Book.Remove(book);
            await _context.SaveChangesAsync();

            return book;
        }

        private bool BookExists(string id)
        {
            return _context.Book.Any(e => e.Id == id);
        }
    }

Apri la console di Gestione pacchetti da "Strumenti" -> "Gestione pacchetti NuGet" e utilizza il comando NuGet di seguito per creare uno script di migrazione. Stiamo utilizzando il primo approccio al codice del Entity framework.

Add-migration Init

Il comando creerà una nuova classe con il timestamp corrente (suffisso come "_Init") e verrà utilizzato per la nostra migrazione dei dati.

Utilizzare comando NuGet successivo per aggiornare il database.

Update-database

Se guardi l'Esploratore oggetti di SQL Server, puoi vedere che viene creato un nuovo database con una tabella "Book".

db_ispect.webp (6 KB)

Creazione dei componenti Razor nel progetto Client

Terminata l'implementazione della logica della nostra applicazione procediamo alla creazione della UI quindi del Client.

Ricordo che i file di UI sono dei files di markup con estensione .razor che contengono gli elementi HTML insieme al codice C# responsabile della gestione degli eventi e del rendering HTML.

La prima pagina che andremo a creare è quella di visualizzare l’elenco dei libri presenti sul database. Quindi aggiungiamo il componente “ListBooks.razor” all'interno della cartella "Pages" per visualizzare tutti i dettagli utilizzando il metodo API del controller.

page "/listbooks"  
  
@using BlazorRealTimeApp.Shared;
@using Microsoft.AspNetCore.SignalR.Client  
  
@inject NavigationManager NavigationManager  
@inject HttpClient Http  
  
<h2>Gestione Libri</h2>  
<p>  
    <a href="/addbook">Aggiungi libro</a>  
</p>  
@if (books == null)  
{  
    <p>Loading...</p>  
}  
else  
{  
    <table class='table'>  
        <thead>  
            <tr>  
                <th>Nome</th>  
                <th>ISBN</th>  
                <th>Autore</th>  
                <th>Prezzo</th>  
            </tr>  
        </thead>  
        <tbody>  
            @foreach (var book in books)  
            {  
                <tr>  
                    <td>@book.Name</td>  
                    <td>@book.Isbn</td>  
                    <td>@book.Author</td>  
                    <td>@book.Price</td>  
                    <td>  
                        <a href='/editbook/@book.Id'>Modifica</a>  
                        <a href='/deletebook/@book.Id'>Rimuovi</a>  
                    </td>  
                </tr>  
            }  
        </tbody>  
    </table>  
}  
  
@code {  
    Book[] books;  
    private HubConnection hubConnection;  
  
    protected override async Task OnInitializedAsync()  
    {  
  
        hubConnection = new HubConnectionBuilder()  
            .WithUrl(NavigationManager.ToAbsoluteUri("/broadcastHub"))  
            .Build();  
  
        hubConnection.On("ReceiveMessage", () =>  
        {  
            CallLoadData();  
            StateHasChanged();  
        });  
  
        await hubConnection.StartAsync();  
  
        await LoadData();  
    }  
  
    private void CallLoadData()   
    {  
        Task.Run(async () =>  
        {  
            await LoadData();  
        });  
    }  
  
    protected async Task LoadData()  
    {  
        books = await Http.GetFromJsonAsync<Book[]>("api/books");  
        StateHasChanged();  
    }  
  
    public bool IsConnected =>  
        hubConnection.State == HubConnectionState.Connected;  
  
    public void Dispose()  
    {  
        _ = hubConnection.DisposeAsync();  
    }  
}  

Come puoi notare vi è presente del codice HTML ed codice C#. Puoi notare anche che ho inizializzato una connessione hub SignalR all'interno del metodo "OnInitializedAsync" e anche passato il riferimento alll'endpoint "broadcastHub", che abbiamo già registrato nella classe Startup.

protected override async Task OnInitializedAsync()  
    {  
  
        hubConnection = new HubConnectionBuilder()  
            .WithUrl(NavigationManager.ToAbsoluteUri("/broadcastHub"))  
            .Build();  
  
        hubConnection.On("ReceiveMessage", () =>  
        {  
            CallLoadData();  
            StateHasChanged();  
        });  
  
        await hubConnection.StartAsync();  
  
        await LoadData();  
    }  

La connessione Hub è in attesa di una nuova notifica push dal server Hub che chiamerà il metodo "CallLoadData". Questo metodo chiamerà nuovamente il metodo LoadData e otterrà i dati nuovi o modificati dei libri dal database utilizzando il metodo API. Ogni volta che aggiungiamo o modifichiamo un record un client web, si rifletterà automaticamente su tutti i client. Quindi i dati saranno aggiornati in tempo reale.

Creiamo un nuovo componente, AddBook.razor, che permette di aggiungere un Libro nuovo con il seguente codice.

@page "/addbook"

@using BlazorRealTimeApp.Shared;
@using Microsoft.AspNetCore.SignalR.Client;

@inject HttpClient Http
@inject NavigationManager NavigationManager

<h2>Create Book</h2>
<hr />
<form>
    <div class="row">
        <div class="col-md-8">
            <div class="form-group">
                <label for="Name" class="control-label">Nome</label>
                <input for="Name" class="form-control" @bind="@book.Name" />
            </div>
            <div class="form-group">
                <label for="Department" class="control-label">ISBN</label>
                <input for="Department" class="form-control" @bind="@book.Isbn" />
            </div>
            <div class="form-group">
                <label for="Designation" class="control-label">Autore</label>
                <input for="Designation" class="form-control" @bind="@book.Author" />
            </div>
            <div class="form-group">
                <label for="Company" class="control-label">Prezzo</label>
                <input for="Company" class="form-control" @bind="@book.Price" />
            </div>
        </div>
    </div>
    <div class="row">
        <div class="col-md-4">
            <div class="form-group">
                <input type="button" class="btn btn-primary" @onclick="@CreateBook" value="Salva" />
                <input type="button" class="btn" @onclick="@Cancel" value="Annulla" />
            </div>
        </div>
    </div>
</form>

@code {

    private HubConnection hubConnection;
    Book book = new Book();

    protected override async Task OnInitializedAsync()
    {
        hubConnection = new HubConnectionBuilder()
            .WithUrl(NavigationManager.ToAbsoluteUri("/broadcastHub"))
            .Build();

        await hubConnection.StartAsync();
    }

    protected async Task CreateBook()
    {        
        book.Id = Guid.NewGuid().ToString();       
        await Http.PostAsJsonAsync("api/books", book);
        if (IsConnected) await SendMessage();
        NavigationManager.NavigateTo("listbooks");
    }

    Task SendMessage() => hubConnection.SendAsync("SendMessage");

    public bool IsConnected =>
        hubConnection.State == HubConnectionState.Connected;

    public void Dispose()
    {
        _ = hubConnection.DisposeAsync();
    }

    void Cancel()
    {
        NavigationManager.NavigateTo("listbooks");
    }
}  

Anche qui abbiamo inizializzato la connessione hub all'interno del metodo “OnInitializedAsync”. Se osservi il metodo "CreateBook", puoi notare che abbiamo inviato una notifica al server hub dopo aver salvato i dati nel database utilizzando il metodo API post.

  protected async Task CreateBook()
    {        
        book.Id = Guid.NewGuid().ToString();       
        await Http.PostAsJsonAsync("api/books", book);
        if (IsConnected) await SendMessage();
        NavigationManager.NavigateTo("listbooks");
    }

    Task SendMessage() => hubConnection.SendAsync("SendMessage");

Ogni volta che inviamo una notifica push da qui, tutti gli altri client aperti riceveranno la notifica push e all'interno del componente ListBooks che a sua volta, tramite l’implementazione precedente, aggiornerà tutti i client connessi.

Allo stesso modo sono stati creati i componenti EditBook.razor e DeleteBook.razor completando l'intera parte di codifica. Adesso possiamo eseguire l'applicazione per verificare il funzionamento.

balzor_app_demo1.gif

Conclusione

In questo tutorial abbiamo visto come creare un'applicazione Web in tempo reale con Blazor WebAssembly e SignalR. Abbiamo creato una semplice applicazione con tutte le operazioni CRUD e visto come i dati vengono aggiornati automaticamente in diversi browser in tempo reale. E’ importante capire, tuttavia, se tale tecnologia sarà veramente in grado di sostituire il mondo JavaScript e/o TypeScript.

Il codice sorgente completo lo trovi su Github.




Commenti
* Obbligatorio