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