Applicazioni in tempo reale utilizzando Blazor WebAssemby, SignalR e C#9

Cos’è Blazor

Blazor è 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,  ha cominciato a diffondersi l’utilizzo da parte degli sviluppatori di librerie o, addirittura framework, come React, Angular e Vue.

Con Blazor, Microsoft è intenzionata a fornire 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 .

Blazor utilizza le Razor Pages con le quali rappresentare l’interfaccia utente e che possono essere annidate e anche ri-condivise in progetti differenti.

Differenza tra Blazor Server e Blazor WebAssembly

Con Blazor Server (rilasciato con .NET Core 3)  il codice viene elaborato su server e lo scambio tra client e server viene effettuato attraverso SignalR.

Con Blazor WebAssembly (rilasciata con .NET Core 3.1)   l’applicazione viene compilata e eseguita direttamente nel browser e nello stesso processo. Tale applicazione, visto che il codice e le relative dipendenze vengono scaricate nel browser, può essere utilizzata anche off-line. E’ chiamato WebAssembly poiché rispetta il target di compilazione standard per la programmazione, lo  sviluppo di applicazioni client e server e la loro distribuzione.

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, App per la modifica simultanea dei documenti, moduli per vedere l’avanzamento di progetti in real-time o attività e applicazioni che richiedono aggiornamenti ad alta frequenza come i giochi .

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.

Cenni di C#9

Nel progetto verranno utilizzate le ultime peculiarità di C# senza entrare nel dettaglio ma, semplicemente, mostrandone il loro utilizzo.

Definiamo come sarà l’applicazione

L’applicazione che andremo a costruire sarà un’applicazione web dove gli utenti potranno connettersi e partecipare ad una chat comune.

Il nostro scopo sarà creare un’applicazione dove l’utente può :

  • Connettersi ad una chat room
  • Vedere chi è connesso in quel momento 
  • Essere notificato dell’avvenuta connessione di un nuovo utente o di chi ha deciso di lasciare
  • Vedere tutti messaggi scambiati nella chat

Le caratteristiche appena elencate saranno tutte informazioni in real-time.

Creiamo la nostra solution e installiamo i pacchetti necessari.

Al fine di sviluppare il nostro esercizio è necessario prima di tutto creare il progetto e installarvi gli opportuni pacchetti.

Per iniziare, quindi, posizionarsi nella cartella dove si vuole lavorare (per me che sto lavorando su W10 è  C:\Sviluppo)  e eseguire il comando relativo (da linea di comando o da terminale di Visual Studio):

cd C:\Sviluppo
dotnet new blazorwasm --hosted --output BlazorChatTest

Questo comando creerà una soluzione composta da tre progetti:

  • BlazorChatTest.Client: che conterrà tutte  le classi utili alla rendering dell’interfaccia utente.
  • BlazorChatTest.Server: che conterrà tutte le classi della nostra REST API.
  • BlazorChatTest.Shared: che conterrà classi e moduli utilizzati sia lato client che lato server.

Ovviamente, se si vuole effettuare le modifiche in sicurezza, consiglio di inizializzare un repository in git con il seguente codice

C:\Sviluppo> cd BlazorChatTest
dotnet new gitignore
git init

Nell’implementazione dell’esercizio, il lato client avrà la necessità di chiamare i metodi esposti dalla nostra REST API utilizzando il servizio HttpClient. Sarà, quindi, necessario installare il pacchetto NuGet Microsoft.Extention.Http eseguendo le seguenti istruzioni:

Posizionarsi nella cartella del progetto client (C:\Sviluppo\BlazorChatTest\Client)

C:\Sviluppo\BlazorChatTest> cd Client 

E eseguire il seguente comando

dotnet add package Microsoft.Extensions.Http --version 5.0.0

Essendo un’applicazione real-time che si appoggia a SignalR sarà necessario, sempre nella cartella client, installare anche il relativo pacchetto che ci permetterà di comunicare con il lato server e viceversa

Digitare quindi il seguente comando e premere invio

dotnet add package Microsoft.AspNetCore.SignalR.Client --version 5.0.0

Adesso che abbiamo installato tutti i pacchetti necessari, possiamo procedere all’implementazione del nostro esercizio.

Mettiamo le basi per il nostro real-time

Registriamo il nostro hub!!!!!.

Lato Server

Se tutto il procedimento precedente è andato a buon fine vi troverete nel progetto BlazorChatTest.Server il file chiamato Startup.cs dove vengono registrati tutti i servizi necessari all’App (metodo ConfigureServices) e il suo ordine di elaborazione richieste (metodo Configure)

L’hub, che non è altro che un servizio, viene registrato come segue:

 public void ConfigureServices(IServiceCollection services)
 {
      services.AddSignalR();
      // ... omissis ...
      services.AddResponseCompression(opts =>
      {
         opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(
                    new[] { "application/octet-stream" });
      });
 }

E poi, successivamente mappato con il nome di /chathub

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            app.UseResponseCompression();
            //... omissis ...
            app.UseEndpoints(endpoints =>
            {
                endpoints.MapRazorPages();
                endpoints.MapControllers();
                endpoints.MapHub<ChatHub>("/chathub");
                endpoints.MapFallbackToFile("index.html");
            });
        }

Il metodo  endpoints.MapHub è quello con il quale la nostra classe ChatHub (di cui parleremo fra poco) fornirà i metodi per lo scambio di informazioni.

Dato che a larghezza di banda potrebbe essere una risorsa limitata, è importante comprimere i dati scambiati tra client e server. La registrazione del servizio ResponseCompression viene in nostro aiuto.

In generale è corretto affermare che la riduzione delle dimensioni dei dati  aumenta, in genere, la velocità di risposta di un’applicazione e riduce le dimensioni del payload.

SignalR

A questo punto , creiamo una cartella chiamata Hub e aggiungiamo un file chiamato ChatHub.cs.

Questo file conterrà la nostra classe ChatHub ed un’ interfaccia IChatHub che tipizzerà i gestori di eventi da utilizzare.

    public interface IChatHub
    {
        Task MessageAdded(Message message);
        Task UserAdded(List<User> users);
        Task UserDeleted(List<User> users);
        Task PrivateMessageAdded(Message message);
    }
    public class ChatHub : Hub<IChatHub>
    {
      //... omissis
     }

MessageAdded verrà utilizzato per avvisare che un messaggio è stato aggiunto

UserAdded avviserà che un utente si è connesso

UserDeleted avviserà che un utente ha lasciato la chat.

Lato Client

Per stabilire una connessione real-time sul client è necessario registrare un’istanza HubConnection nel metodo Main della classe Program , utilizzando HubConnecionBuilder

 public class Program
    {
        public static async Task Main(string[] args)
        {
            var builder = WebAssemblyHostBuilder.CreateDefault(args);
           //... omissis
            builder.Services.AddSingleton<HubConnection>(sp => {
                var navigationManager = sp.GetRequiredService<NavigationManager>();
                return new HubConnectionBuilder() .WithUrl(navigationManager.ToAbsoluteUri("/chathub"))
                  .WithAutomaticReconnect()
                  .Build();
            });
            //... omissis
        }
    }

Una volta registrata la connessione, dovrà essere fatta partire con il metodo StartAsync() nella fase di inizializzazione dal componente principale App.razor 

@code {
    private CancellationTokenSource cts = new CancellationTokenSource();
    protected override void OnInitialized()
    {
        ConnectWithRetryAsync(cts.Token);
		HubConnection.Closed += error =>
        {
            return ConnectWithRetryAsync(cts.Token);
        };
    }
    private async Task<bool> ConnectWithRetryAsync(CancellationToken token)
    {
        while (true)
        {
            try
            {
                await HubConnection.StartAsync(token);
                return true;
            }
            catch when (token.IsCancellationRequested)
            {
                return false;
            }
            catch
            {
				await Task.Delay(5000);
            }
        }
    }

E’ importante notare che:

  • Il tentativo di connessione avviene in modo asincrono con il metodo await HubConnection.StartAsync(token) e quindi in modo indipendentemente dalla inizializzazione dell’applicazione. Se non fosse cosi, infatti , un eventuale errore di connessione, provocherebbe il blocco di tutto il rendering dell’applicazione stessa.
  • E che il sistema prova a connettersi ripetutamente e, anche quando i tentativi di connessione vengono esauriti, viene ristabilito il tentativo di connessione in modo manuale.
 catch when (token.IsCancellationRequested)
 {
   return false;
 }

Le classi condivise

Una volta fatte tutte le registrazioni del caso, analizziamo i file da implementare nel progetto.

Blazor mette a disposizione le basi per la tipizzazione forte, e tutti i sui benefici, creando un progetto di nome BlazorChattest.Shared dove inserire tutti i file utilizzati sia dal client, che dal server.

Queste classi verranno utilizzate sia come input/output dalla REST API , sia dalle Razor Pages nel progetto client e saranno record immutabili.

C# 9 e i record immutabili.

Una nuova peculiarità di C# 9 sono le classi immutabili le cui proprietà non possono essere modificate nel corso della vita dell’oggetto stesso. Il compilatore, infatti, non permetterà che una classe di questo tipo venga modificata una volta istanziata se non dichiarandone una nuova istanza.

I record (cosi si chiamano)  sono utilizzati per restituire liste di dati o dettagli in sola lettura e non possono essere utilizzati per il binding dei dati lato client (verrebbe restituita un’eccezione dal compilatore tutte le volte che l’utente cerca di modificare il dato).E’ necessario quindi appoggiarsi all’utilizzo di classi standard per modificare le proprietà della pagina.

Nell’immagine seguente ho illustrato il comportamento del compilatore nel caso di dichiarazione di una classe ExampleRecord e di successiva istanza e tentativo di modifica della sua proprietà dichiarata init invece che set.

Il principale vantaggio di questi record e che i loro dati non posso essere modificati in modo erroneo, nel corso dello sviluppo dell’applicazione, sia lato server che lato client. Per poter però garantire il binding è necessario mantenere due modelli anziché uno. (il back side dei record)

Tipizziamo ancora un po’

Una cosa molto importante è incapsulare tutte le chiamate alla nostra REST API in un’unica classe per essere facilitati nelle operazioni di sincronizzazione delle REST API stesse.

Nel progetto Shared aggiungiamo un file chiamato ChatHttpClient.cs che avrà il compito di interrogare tutti i metodi REST API forniti dal nostro lato server.

   public class ChatHttpClient
    {
        private readonly HttpClient http;
        public ChatHttpClient(HttpClient http)
        {}
        public async Task<Chat[]> GetChats()
        {}
        public async Task<Chat> GetChat(Guid chatId)
        {}
        public async Task<User> GetUser(Guid chatId, string username)
        {}
        public async Task<HttpResponseMessage> AddUser(AddingUserModel aum)
        {}
       
        public async Task<HttpResponseMessage> AddChat(Chat cht)
        {}
        public async Task<HttpResponseMessage> AddMessage(AddingMessageModel amm)
        {}
        public async Task<HttpResponseMessage> DeleteUser(Guid chatId, string username)
        {}
    }
}

N.B. I metodi sono vuoti poiché è necessario implementare prima la classe lato server che esporrà le chiamate REST API. Non ti preoccupare, lo faremo a breve!!!

Un volta scritta la classe sarà necessario registrarla nel nostro progetto Client e più precisamente nel metodo Main della classe Program

public static async Task Main(string[] args)
{
	var builder = WebAssemblyHostBuilder.CreateDefault(args);
	//... omissis
	var baseAddress = new Uri(builder.HostEnvironment.BaseAddress);
	//... omissis
	builder.Services.AddHttpClient<ChatHttpClient>(client => client.BaseAddress = baseAddress);
	//... omissis
}

Ho proprio voglia di cominciare a chattare!!

Dopo aver registrato il nostro hub e chiarito alcune nozioni base, è arrivata l’ora implementare il nostro esercizio.

La base

Per poter iniziare a chattare dobbiamo, prima di tutto, impostare la nostra classe controller lato server che ci fornirà i nostri dati. Implementiamo, quindi , il nostro controller standard, chiamato ChatController, con l’attributo [ApiController] ereditato dalla classe Controller e gli innestiamo, grazie alla Dependency Injection il nostro hubContext che ci servirà a scatenare gli eventi in real-time che comunicheranno i dati al client.

    [ApiController]
    [Route("api/[controller]")]
    public class ChatController : Controller
    {
        private IHubContext<ChatHub, IChatHub> hubContext;
        public ChatController(IHubContext<ChatHub, IChatHub> chatHub)
        {
            this.hubContext = chatHub;
        }
        private static ConcurrentBag<Chat> chats = new ConcurrentBag<Chat> {
          new Chat ("Chat Room")
        };
        //... omissis

In questo esercizio opteremo per una lista in memoria che simulerà il nostro database e verrà rappresentata dal record Chat (sentitevi liberi di replicare il tutto con un database persistente).

    public record Chat
    {
        public Guid Id { get; init; } = Guid.NewGuid();
        public string Name { get; init; }
        public List< Message> messages=new List< Message>();
        public List<User> users = new List<User>();
        
        public Chat(string name)
        {
            Id = Guid.NewGuid();
            Name = name;
        }
    }

Chat conterrà i dati relativi agli utenti connessi ed ai messaggi mandati tra di loro.

Il record User conterrà il nome dell’utente e un id di tipo Guid e rappresenterà gli utenti che scelgono di entrare in chat.

namespace BlazorChatTest.Shared
{
    public record User
    {
        public Guid Id { get; init; }
        public string Name { get; init; }
        public User(string name)
        {
            Name = name;
            Id = Guid.NewGuid();
        }
    }
    public record ExampleRecord
    {
        public string? exampleField { get; init; }
    }
}

Mentre il record Message rappresenterà i messaggi scambiati tra gli utenti connessi e conterrà quello che è stato scritto dall’utente (Body), l’utente stesso (User) e la data di inserimento del messaggio (InsertDate).

namespace BlazorChatTest.Shared
{
   public  record Message
    {
        public Message(string body,User user)
        {
            Body = body;
            InsertDate = DateTime.Now;
            User = user;
        }
        public User User { get; init; }
        public Guid Id { get; init; } = Guid.NewGuid();
        public string Body { get; init; }
        public DateTime InsertDate { get; init; }
    }
}

A questo punto abbiamo identificato la classe lato server che ci fornirà i dati e la classe lato client che la interrogherà per recuperarli. Possiamo quindi iniziare a implementare la nostra App per raggiungere il nostro scopo.

Connettersi ad una chat room

Prima di tutto cominciamo a caricare nel nostro menu laterale le chat disponibili (una sola nel nostro esercizio).

Per farlo è necessario

Aggiungere un metodo nel nostro controller chiamato GetChats(),

[HttpGet()]
public IEnumerable<Chat> GetChats()
{
    return chats;
}

Implementare il metodo GetChats() della classe ChatHttpClient con la chiamata al metodo REST API definito nel controller,

public async Task<Chat> GetChat(Guid chatId)
{
     return await this.http.GetFromJsonAsync<Chat>($"api/chat/{chatId}");
}

E implementare il caricamento della lista nella nostra Razor Page chiamata NavMenu.razor

		@*... omissis *@
		@foreach (var chat in chats.OrderBy(s => s.Name))
		{
			var link = $"chatroom/{chat.Id}";
			<li class="nav-item px-3">
				<NavLink class="nav-link" href="@link">
					<span class="oi oi-list-rich" aria-hidden="true"></span> @chat.Name
				</NavLink>
			</li>
		}
	</ul>
</div>
}
@code {
	private Chat[] chats;
	protected override async Task OnInitializedAsync()
	{
		chats = await ChatHttpClient.GetChats();
	}
	//... omissis
}

Da notare che chatroom/{chat.Id} è il link al nostro componente principale ChatRoom.razor che permetterà all’utente di registrarsi alla chat, di visualizzare i messaggi mandati da altri e vedere chi è connesso in quel momento.

Vedere chi è connesso in quel momento

Prima di tutto si devono implementare i metodi  lato server che gestiscano l’aggiunta, in caso di nuova connessione, e la cancellazione, in caso di abbandono, degli utenti.

Nella nostra classe ChatController aggiungiamo due metodi :

AddUser per aggiungere l’utente appena connesso e DeleteUser per eliminare l’utente che ha lasciato la chat

        [HttpPut()]
        public  ActionResult AddUser([FromBody] AddingUserModel aum)
        {
            var chat = chats.SingleOrDefault(t => t.Id == aum.ChatId);
            if (chat != null)
            {
                var user = chat.users.SingleOrDefault(t => t.Name == aum.Name);
                if (user == null)
                {
                    user  = new User(aum.Name);
                    chat.users.Add(user);
                    this.hubContext.Clients.All.UserAdded(chat.users);
                }
                return new JsonResult(user);
            }
            else
            {
                return NotFound();
            }
        }
        [HttpDelete()]
        public ActionResult DeleteUser(Guid chatId, string username)
        {
            var chat = chats.SingleOrDefault(t => t.Id == chatId);
            if (chat != null)
            {
                var user = chat.users.SingleOrDefault(t => t.Name == username);
                if (user != null)
                {
                    chat.users.Remove(user);
                    this.hubContext.Clients.All.UserDeleted(chat.users);
                }
                return new JsonResult("OK");
            }
            else
            {
                return NotFound();
            }
        }

N.B. Attraverso l’utilizzo di hubContext.Client.All potremo accedere agli eventi a noi utili come UserDeleted e UserAdded, che trasmetteranno al client la lista di utenti attualmente connessi.

Lato client, invece, nella pagina chiamata UserList.razor , iniettiamo HubConnection ad inizio pagina,

@inject HubConnection HubConnection

e registriamo gli eventi SignalR nel metodo OnInitializedAsync (cioè quando il componente viene inizializzato)

    protected override async Task OnInitializedAsync()
    {
        HubConnection.On<List<User>>("UserAdded", users =>
        {
            chat.users = users;
            StateHasChanged();
        });
        HubConnection.On<List<User>>("UserDeleted", users =>
        {
            chat.users = users;
            StateHasChanged();
        });
    }

Il nostro hub, grazie al metodo On, si mette ‘in ascolto’  di due gestori eventi :

 UserAdded e UserDeleted ai quali il server comunicherà la nuova lista di utenti attualmente collegati.

Quando un gestore degli eventi viene ‘aggiornato’, il metodo StateHasChanged() permette al componente di visualizzare i nuovi dati attraverso la loro ri-renderizzazione.

Essere notificato dell’avvenuta connessione di un nuovo utente o di chi ha deciso di lasciare

Quando un utente si inserisce o abbandona la chat, oltre che aggiornare l’elenco degli utenti connessi, l’applicazione dovrà notificare a tutti i presenti l’evento appena accaduto mandando un messaggio.

Per far questo è necessario

aggiungere al controller il metodo AddMessage, con il quale il nuovo messaggio verrà associato alla chat e notificato al client con this.hubContext.Clients.All.MessageAdded(msg),

        [HttpPost()]
        public ActionResult AddMessage([FromBody]  AddingMessageModel msgModel)
        {
            Guid chatId = msgModel.ChatId;
           string userName= msgModel.UserName;
            string message = msgModel.TextMessage;
            var chat = chats.SingleOrDefault(t => t.Id == chatId);
            if (chat != null)
            {
                var u= chat.users.SingleOrDefault(u => u.Name == userName);
                if (u==null)
                {
                    u = new User(userName);
                }
                var msg = new Message(message,u);
                chat.messages.Add(msg);
                this.hubContext.Clients.All.MessageAdded(msg);
                return new JsonResult(msg);
            }
            else
            {
                return NotFound();
            }
        }

implementare il metodo AddMessage della classe ChatHttpClient con la quale si effettueranno le chiamata al server ,

public async Task<HttpResponseMessage> AddMessage(AddingMessageModel amm)
{
            return await this.http.PostAsJsonAsync<AddingMessageModel>("api/chat", amm);
}

e implementare la parte di codice nella Razor Page ChatRoom.razor che permetta di svolgere le seguenti operazioni:

Fornire all’utente una mini interfaccia per connettersi,

@if (!this.isChatting)
{
    <p>
        Inserisci il tuo nome per iniziare a chattare
    </p>
    <input type="text" maxlength="32" @bind="@username" />
    <button type="button" @onclick="@Chat"><span class="oi oi-chat" aria-hidden="true"></span> Chat!</button>
    // Error messages
    @if (message != null)
    {
        <div class="invalid-feedback">@message</div>
        <small id="emailHelp" class="form-text text-muted">@message</small>
    }
}

e notificare che l’utente si è appena connesso implementando il metodo Chat che comunicherà al server sia di aggiungere un utente (ChatHttpClient.AddUser), sia di aggiungere un messaggio (SendAsync).

    public async Task Chat()
    {
        // check username is valid
        if (string.IsNullOrWhiteSpace(username))
        {
            message = "E' necessario inserire il nome";
            return;
        };
        try
        {
            this.isChatting = true;
            await Task.Delay(1);
           
            chat.messages.Clear();
             string baseUrl = navigationManager.BaseUri;
            var um = new AddingUserModel(chat.Id, username);
           await ChatHttpClient.AddUser(um) ;
          
            await SendAsync($"[Notice] {username} è entrato in chat.");
        }
        catch (Exception e)
        {
            message = $"ERROR: Failed to start chat client: {e.Message}";
            isChatting = false;
        }
    }
    private void BroadcastMessage(string name, string message)
    {
        var response = ChatHttpClient.AddMessage(new AddingMessageModel(this.chatid, name, message));
    }
    private async Task SendAsync(string message)
    {
        if (isChatting && !string.IsNullOrWhiteSpace(message))
        {
            BroadcastMessage(username, message);
            newMessage = string.Empty;
        }
    }

Una volta fatto questo possiamo eseguire il nostro progetto ed ecco qui!

Conclusioni

Blazor e SignalR ci hanno permesso di sviluppare un’applicazione web con la quale chattare con altri utenti, vedere chi di loro è connesso, ricevere la notifica degli utenti appena connessi e capire quali di questi ha abbandonato.

E intuibile che permettere agli sviluppatori di implementare un’applicazione web utilizzando solo .NET e C# potrebbe essere una grande vittoria. Ti permetterebbe di sviluppare applicazioni web in real-time senza dover imparare altre tecnologie, ma utilizzando tutti i vantaggi del framework .NET  e le peculiarità di linguaggi come C# 9.

E’ importante capire, tuttavia, se tale tecnologia sarà veramente in grado di sostituire il mondo JavaScript e/o TypeScript. Certo, anche se non esiste ancora il concetto di stateful hot reloading  (compilazione in real-time delle nuove modifiche effettuate senza dover stoppare e ripartire il progetto), Microsoft  è molto vicina a farlo.

Link utili

C# 9.0 on the record di Mads Torgensen

Using Blazor WebAssembly, SignalR and C# 9 to create Full-stack Real time Applications