facebook

Blog

Resta aggiornato

Vediamo come Redis consente di scalare orizzontalmente applicazioni ASP.NET Core SignalR con Blazor
Redis come Backplane per scalare le tue applicazioni Blazor
mercoledì 24 Febbraio 2021

In un precedente articolo  abbiamo visto che è possibile utilizzare una tecnologia quale Redis non solo nel suo uso più comune, ovvero come database o cache, ma anche come messaging system sfruttando il meccanismo di publish/subscribe che mette a disposizione. 
Questa feature, unita alla sua versatilità, fanno sì che possa essere utilizzato per svariate situazioni ed in particolare, come avevamo accennato, può essere utilizzato per scalare orizzontalmente applicazioni ASP.NET Core SignalR.

ASP.NET Core SignalR è una libreria che consente ad un server di inviare informazioni ai client senza la necessità di ulteriori richieste per aggiornare le informazioni, rendendo quindi l’applicazione real-time. SignalR è sinonimo di WebSocket, protocollo di comunicazione full-duplex, di cui è un’astrazione.

Per fare ciò, SignalR implementa un meccanismo molto interessante. Al tempo della negoziazione tra client e server, si stabilisce, in base alle loro caratteristiche e alla compatibilità tra esse, il meccanismo di comunicazione da utilizzare che va dal più performante, WebSocket, al meno performante, Server Sent Events e Long Polling che vengono considerati come meccanismi di fallback.

I meccanismi di comunicazione supportati sono:

  • WebSockets – Si tratta di una connessione bidirezionale instaurata dopo una negoziazione HTTP iniziata dal client. Se il server accetta, si crea un canale bidirezionale sul quale il server può inviare dati al client (Push Model) senza che quest’ultimo debba richiederli ogni volta.
  • Server Sent Events – Si tratta di una connessione monodirezionale instaurata dopo la negoziazione HTTP iniziata dal client. Il canale monodirezionale è solo dal server verso il client ed infatti quest’ultimo non può effettuare ulteriori richieste. Quando necessario, il server invia eventi al client nei quali sono contenuti dati aggiornati.
  • Long polling – Tra le tre è la tecnica più vecchia e consiste nell’instaurazione di una connessione che resta aperta finché il server non ha la risposta da inviare al client. Una volta che ha dati da inviare la connessione viene interrotta ed il client ricontatterà il server per ricevere aggiornamenti successivi.

Il modo migliore per capire alcuni aspetti di questa libreria è farlo attraverso un esempio.
È possibile scegliere tra diversi framework e librerie per realizzare il front-end per un’applicazione ASP.NET Core. Ho scelto di utilizzare una delle ultime novità in casa Microsoft ovvero Blazor, un framework per la realizzazione di interfacce web utilizzando C#.

Essendo alle prime armi sia con ASP.NET Core SignalR che con Blazor, ho preso spunto dalla documentazione Microsoft, dove possiamo trovare un tutorial per realizzare una delle applicazioni real-time più semplici che ci può venire in mente, ovvero una chat.

Ci sono due modelli di hosting per un’applicazione Blazor:

  • Blazor Web Assembly – L’applicazione con le sue dipendenze e il runtime .NET viene eseguita all’interno del browser stesso e la gestione degli eventi e gli aggiornamenti dell’interfaccia avvengono all’interno dello stesso processo.
  • Blazor Server – l’applicazione in ASP.NET Core viene eseguita su di un server. La gestione degli eventi e gli aggiornamenti dell’interfaccia client avvengono mediante una connessione SignalR.

Per il nostro esempio sceglieremo quest’ultimo. 

Per prima cosa creiamo un nuovo progetto con il comando: 

dotnet new blazorserver -o ProjectName

Dove ProjectName è il nome che vogliamo dare al progetto.
Fatto ciò, ci ritroveremo davanti ad un’applicazione già funzionante dalla quale, però, abbiamo rimosso le pagine di esempio e la logica ad esse collegata.

Non mostreremo l’intero progetto (potete trovare il link al repository GitHub alla fine dell’articolo) ma solo le componenti essenziali e rilevanti per spiegare alcuni concetti fondamentali di SignalR.
Tra questi sicuramente c’è il concetto di Hub. Un hub è una pipeline che consente ad un client di invocare i metodi del server e al server di invocare metodi del client rendendo possibile la comunicazione real-time tra le due parti.

Installiamo il pacchetto NuGet Microsoft.AspNetCore.SignalR.Client all’interno del nostro progetto e creiamo un hub che chiameremo ChatHub:

using System; 

using System.Threading.Tasks; 

using Microsoft.AspNetCore.SignalR; 

 namespace BlazorWithRedisBackPlane.Chat 

{ 

    public class ChatHub: Hub 

    { 

        public const string HubUrl = "/chat"; 

  
        public async Task SendMessageToAll(string username, string message) 

        { 

            await Clients.All.SendAsync("ReceiveMessage", username, message); 

        } 

  
        public override Task OnConnectedAsync() 

        { 

            Console.WriteLine($"{Context.ConnectionId} connected"); 

            return base.OnConnectedAsync(); 

        } 


        public override  async Task OnDisconnectedAsync(Exception exception) 

        { 

            Console.WriteLine($"Disconnected {exception?.Message} {Context.ConnectionId}"); 

            await base.OnDisconnectedAsync(exception); 

        } 

     } 

} 

Come possiamo vedere, questa classe eredita dalla classe base Hub propria di SignalR e definisce quindi l’hub che utilizzeremo in questa applicazione.
Definiamo un campo HubUrl che rappresenterà la rotta verso il nostro hub.

Il primo dei tre metodi definiti è SendMessageToAll() che utilizzeremo per definire ciò che andrà fatto quando un utente della chat invia un messaggio.

Utilizziamo l’oggetto Clients, fornito sempre da SignalR, che ci consente di invocare metodi sui client connessi a tale hub. Con Clients.All indichiamo che il metodo sia invocato su tutti i client connessi all’hub mentre mediante il metodo SendAsync() andiamo a specificare il nome del metodo invocato per tutti i client connessi all’hub.

Gli altri due metodi invece sono override dei metodi della classe base Hub.

OnConnectedAsync() viene invocato quando viene instaurata una nuova connessione con l’hub mentre quando questa connessione termina viene invocato il metodo OnDisconnectedAsync().

In entrambi, utilizziamo l’oggetto Context presente nella libreria, che contiene le informazioni legate alle connessioni in atto come ad esempio il ConnectionId.

Definiamo adesso l’interfaccia e la logica del nostro client. Come detto in precedenza, quando creiamo un nuovo progetto ci viene fornito qualcosa di già funzionante con delle pagine di esempio.

Alcune di queste sono state rimosse ed è stato sostituito il codice all’interno della pagina Index.razor per realizzare la nostra chat.

@page "/" 

@inject NavigationManager navigationManager 

@using Microsoft.AspNetCore.SignalR.Client; 

@using BlazorWithRedisBackPlane.Chat; 

 
@if (!_isChatting) 

{ 

    <p> 

        Enter your name:  

    </p> 

  
    <input type="text" maxlength="32" @bind="@_username"/> 

    <button type="button" @onclick="@Chat"><span class="oi oi-chat" aria-hidden="true"></span> Start a chat </button> 

} 

else 

{  

    <div> 

        <span>User: <b>@_username</b></span> 

    </div> 

    <div id="scrollbox"> 

        @foreach (var item in _chatMessages) 

        { 

            <div class="@item.Style"> 

                <div class="user">@item.Username</div> 

                <div class="msg">@item.Body</div> 

            </div>             

        } 

        <hr /> 

        <textarea class="input" placeholder="enter your message" @bind="@_newMessage"></textarea> 

        <button class="btn btn-default" style="border-color:grey" @onclick="@(() => SendAsync(_newMessage))">Send</button> 

    </div> 

}
@code { 

    private bool _isChatting = false; 

    private string _username; 

    private string _message; 

    private string _newMessage; 

    private List<Message> _chatMessages = new List<Message>(); 

    private string _chatHubUrl; 

    private HubConnection _hubConnection; 

  

    public async Task Chat() 

    { 

         if (string.IsNullOrWhiteSpace(_username)) 

        { 

            _message = "Please enter a name"; 

            return; 

        }; 

          

        try 

        { 

            _isChatting = true; 

            await Task.Delay(1); 

  

            _chatMessages.Clear(); 

            _chatHubUrl = navigationManager.BaseUri.TrimEnd('/') + ChatHub.HubUrl;  

  

            _hubConnection = new HubConnectionBuilder() 

                .WithUrl(_chatHubUrl) 

                .Build(); 

  

            _hubConnection.On<string, string>("ReceiveMessage", HandleMessage); 

  

            await _hubConnection.StartAsync(); 

        } 

        catch (Exception e) 

        { 

            _message = $"ERROR: Failed to start chat client: {e.Message}"; 

            _isChatting = false; 

        } 

    } 

  
    private void HandleMessage(string name, string message) 

    { 

        bool isMine = name.Equals(_username, StringComparison.OrdinalIgnoreCase); 

  

        _chatMessages.Add(new Message(name, message, isMine)); 

  
        // Inform blazor the UI needs updating 

        StateHasChanged(); 

    } 

  
    private async Task SendAsync(string message) 

    { 

        if (_isChatting && !string.IsNullOrWhiteSpace(message)) 

        { 

            await _hubConnection.SendAsync("SendMessageToAll", _username, message); 

  
            _newMessage = string.Empty; 

        } 

  
    } 

  
    private class Message 

    { 

        public Message(string username, string body, bool mine) 

        { 

            Username = username; 

            Body = body; 

            IsMine = mine; 

        } 

  
        public string Username { get; set; } 

        public string Body { get; set; } 

        public bool IsMine { get; set; } 

        public string Style => IsMine ? "sent" : "received"; 

    } 

} 

Il file è nettamente diviso in due parti che interagiscono tra loro. Nella prima parte, viene definito il layout della pagina in HTML e la logica con cui possiamo rappresentare all’interno di essa le varie componenti mediante la sintassi Razor.

Con la direttiva @code invece indichiamo la parte di codice in cui possono essere definiti gli attributi ed i metodi della classe che verrà generata in fase di compilazione e che prenderà il nome del file.

Le parti interessanti di questa classe sono i tre metodi Chat(), HandleMessage() e SendAsync().

Il primo viene invocato quando l’utente inserisce il proprio username e inizializza la chat.
Con l’ausilio di un HubConnectionBuilder() andiamo a creare una HubConnection che ci consente di invocare i metodi degli hub in un Server SignalR.

Infatti con l’istruzione:

_hubConnection.On<string, string>("ReceiveMessage", HandleMessage); 

si registra un handler di nome HandleMessage(), che verrà invocato a seguito dell’invocazione del metodo ReceiveMessage presente nell’hub.
Infine, viene inizializzata la connessione al server con il metodo _hubConnection.StartAsync().

Quando l’utente inserisce un messaggio, invece, viene invocato il metodo SendAsync() che a sua volta andrà ad invocare il metodo dell’hub specificato, che in questo caso è “SendMessageToAll”.
Questo significa che, siccome nell’hub il metodo “SendMessageToAll” scatena a sua volta il metodo Client “ReceiveMessage” che abbiamo collegato all’handler HandleMessage(). Verrà quindi eseguito quest’ultimo che non farà altro che aggiungere alla lista dei messaggi quello inserito dall’utente.

Infine, non resta che aggiungere all’interno del file Startup.cs ed in particolare nella configurazione degli EndPoints quella relativa al ChatHub che abbiamo definito

endpoints.MapHub<ChatHub>(ChatHub.HubUrl); 

Eseguiamo l’applicazione per provare il funzionamento della nostra chat in due finestre del browser.

Una volta inseriti i due user inviamo un messaggio per parte.

Come possiamo vedere, la chat funziona correttamente.

Nell’esempio mostrato, abbiamo usato due istanze del browser per i due client ma l’applicazione server è sempre la stessa.
Nel caso di applicazioni real-time o comunque in generale quando un’applicazione deve servire un gran numero di client c’è necessità che l’applicazione venga scalata orizzontalmente. In questo caso, può risultare un problema, perché, come abbiamo visto, SignalR fa in modo che sia il server a gestire le connessioni e quindi ci ritroveremmo nella situazione descritta in figura:

in cui le istanze di SignalR sui vari server non hanno conoscenza delle connessioni presenti sugli altri.

Per fare un esempio più vicino al caso reale, creiamo un’immagine Docker per la nostra applicazione e istanziamo due container all’interno di una Docker network.
Per creare l’immagine Docker della nostra applicazione, possiamo generare molto semplicemente il dockerfile utilizzando l’estensione Docker ufficiale per Visual Studio Code oppure l’utility fornita da Visual Studio per Windows, Docker Support.

Prima di procedere però dobbiamo fare una piccola modifica per “bypassare” un problema legato al redirection dell’HTTPs che non ci consente di avviare la connessione al server (https://github.com/dotnet/dotnet-docker/issues/2129).

Nel file Startup.cs bisogna commentare l’istruzione:

app.UseHttpsRedirection(); 

Inoltre, all’interno di Index.razor è necessario specificare l’URL della connessione al ChatHub in questo modo:

string baseUrl = "http://localhost"; 

_chatHubUrl = baseUrl + ChatHub.HubUrl; 

Possiamo quindi creare l’immagine della nostra applicazione lanciando il comando: 

docker build -t blazorchat:1 . 

Creiamo adesso una rete docker:

docker network create --subnet=192.168.0.0/16 chat-network 

e creiamo due container assegnando gli indirizzi IP all’interno della rete appena creata:

Server1

docker run -h server1 --net chat-network --ip 192.168.0.5 -p 5000:80 --name server1  blazorchat:1

Server2

docker run -h server2 --net chat-network --ip 192.168.0.6 -p 5001:80 --name server2  blazorchat:1

Se, come nell’esempio fatto in precedenza, ci colleghiamo all’indirizzo http://localhost:5000/
utilizzando due finestre diverse del browser otteniamo esattamente lo stesso risultato.
Ma se per un client utilizziamo Server1 (http://localhost:5000/) e per l’altro client Server2 (http://localhost:5001/) avremo questa situazione:

I due client non comunicano: è esattamente ciò che stavamo provando a descrivere prima, ovvero che le connessioni di SignalR sono indipendenti fra i vari server.
Per ovviare a questa situazione, ci sono diverse soluzioni tra cui l’utilizzo di Redis come BackPlane. Il termine backplane viene dall’elettronica e si riferisce ad un insieme di connettori collegati in parallelo tra loro, in modo che ogni pin di uno sia collegato ai corrispondenti pin degli altri andando a formare così un bus di comunicazione (https://en.wikipedia.org/wiki/Backplane). Allo stesso modo è possibile utilizzare Redis per far comunicare i diversi nodi su cui è in esecuzione l’applicazione SignalR.

L’applicazione invia al backplane sia le informazioni legate alle connessioni dei client sia i vari messaggi ricevuti.
Attraverso un meccanismo di publish/subscribe, il backplane di Redis ha tutte le informazioni per instradare correttamente tutti i messaggi tra tutti i client e i server ai quali sono collegati.

Proviamo nuovamente a riprodurre lo scenario dell’esempio precedente introducendo stavolta Redis come backplane.
Istanziamo un container basato sull’immagine di Redis all’interno della rete Docker definita in precedenza.

docker run -h redis --net chat-network --ip 192.168.0.7 --name redisbackplane -p 6379:6379 -d redis 

Aggiungiamo alla nostra applicazione il pacchetto NuGet Microsoft.AspNetCore.SignalR.StackExchangeRedis ed una volta installato aggiungiamo all’interno del metodo ConfigureServices() l’istruzione:

services.AddSignalR().AddStackExchangeRedis("192.168.0.7"); 

Avendo modificato l’applicazione andiamo a creare una nuova immagine:

docker build -t blazorchat:2 . 

Creiamo nuovamente i due container, questa volta aggiungendo come host il server Redis: 

Server1

docker run -h server1 --net chat-network --ip 192.168.0.5 --add-host redis:192.168.0.7 -p 5000:80 --name server1 blazorchat:2

Server2

docker run -h server2 --net chat-network --ip 192.168.0.6 --add-host redis:192.168.0.7 -p 5001:80 --name server2 blazorchat:2 

In questo caso, se ci colleghiamo al Server1 (http://localhost:5000/) e al Server2(http://localhost:5001/) e proviamo la nostra chat, avremo finalmente la comunicazione tra le due istanze della nostra applicazione.

Inoltre, possiamo vedere l’attività sul backplane collegandoci al container Redis e utilizzando il comando:

redis-cli monitor

Spero che sia stato interessante vedere un altro modo per sfruttare la versatilità di Redis ed utilizzarlo per aiutarci nello scaling di una nostra applicazione.

Potete trovare il codice del progetto con il dockerfile al seguente link.

Inoltre, se siete interessati a Blazor vi invito a seguire la community Blazor Developer Italiani, fondata dal nostro CEO Michele Aponte, e la conferenza BlazorConf2021 in programma a marzo.

Al prossimo articolo!

Scritto da

Genny Paudice