Le développement de solutions en temps réel a explosé au fil des années et à juste titre, les applications sont multiples : jeux vidéos, cartographie, alertes, messagerie, réseaux sociaux , etc.

Avec l’arrivée des WebSocket, plusieurs solutions ont été crées afin de répondre au besoin d’applications dîtes temps réel.

Parmi elles on peut citer : WebRTC, Pusher, RabbitMQ ou encore FireBase.

Aujourd’hui nous allons nous intéresser à la solution proposée par Microsoft : SignalR.

Ce tutoriel va vous accompagner pas à pas dans la découverte de cette solution, via la mise en place d’un Chat basique.

1. Prérequis

En prérequis pour cette découverte de SignalR, il est nécessaire d’installer les outils suivants :

En termes de connaissances, il est nécéssaire d’avoir des notions sur le protocole WebSocket. Des bases sur le développement d’API ASP.Net Core et de client avec React permettront de prendre plus facilement en main le code mis en place

2. Fonctionnalités du Chat

Le chat que nous allons implémenter aura les fonctionnalités suivantes :

  • Créer / Rejoindre un salon ;
  • Envoyer des messages dans un salon ;
  • Afficher la liste des utilisateurs connectés au salon ;
  • Quitter le salon.

À la fin de ce tutoriel, votre chat devrait avoir une apparence similaire à celle-ci :

lobby-view tchat-view

3. Création du Hub SignalR

3.1 Création de l’API

Commençons par créer notre solution avec notre API :

  • Ouvrez Visual Studio ;
  • Créez un nouveau projet avec le modèle API web ASP.NET Core ;
  • Décocher Placer la solution et le projet dans le même répertoire ;
  • Comme nom de solution, renseignez blog.c2s.signalrChat ;
  • Comme nom de projet, renseignez blog.c2s.signalrChat.API ;
  • Sélectionner l’emplacement que vous souhaitez puis cliquez sur suivant ;
  • Vérifier la version du framework (.NET 6.0) ;
  • Décocher simplement Use Controllers et Activer la prise en charge d'OpenAPI.

Vous pourrez alors lancer votre API qui devrait afficher une liste de températures.

Avant de continuer, nous allons faire un peu de nettoyage sur ce template de base :

  • Dans le fichier Program.cs, supprimez le code suivant :
/// ...
var summaries = new[]
{
    "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};

app.MapGet("/weatherforecast", () =>
{
    var forecast = Enumerable.Range(1, 5).Select(index =>
       new WeatherForecast
       (
           DateTime.Now.AddDays(index),
           Random.Shared.Next(-20, 55),
           summaries[Random.Shared.Next(summaries.Length)]
       ))
        .ToArray();
    return forecast;
});

/// ...

internal record WeatherForecast(DateTime Date, int TemperatureC, string? Summary)
{
    public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}
  • Dans votre répertoire blog.c2s.signalrChat, supprimez le sous-dossier du même nom généré par défaut.

Votre API est à présent prête à être implémentée.

3.2 Implémentation

Rentrons directement dans le vif du sujet en commençant par l’implémentation de notre ChatHub.

Les Hubs dans signalR sont des canaux de communications permettant de mettre en relation des clients qui s’y connectent.

Dans notre cas nous n’en n’aurons qu’un seul, permettant d’envoyer des messages.

Celui-ci sera subdivisé en plusieurs Groups pour autant de salon qui seront ouverts.

Explications faîtes, commençons par créer notre modèle de données.

Créez un fichier UserConnection.cs dans un dossier ~/Entities à la racine du projet et copiez-y le code suivant :

namespace blog.c2s.signalrChat.Entities
{
    public class UserConnection
    {
        private string _user;
        private string _room;

        public string User { get { return _user; } set { _user = value.TrimStart().TrimEnd(); } }
        public string Room { get { return _room; } set { _room = value.TrimStart().TrimEnd(); } }
    }
}

Cette classe nous permettra, pour chaque client, de stocker :

  • le salon auquel il est connecté ;
  • son pseudo pour ce salon.

Passons au hub, créez un fichier ChatHub.cs dans un dossier ~/Hubs à la racine du projet et copiez-y le code suivant :

using blog.c2s.signalrChat.Entities;
using Microsoft.AspNetCore.SignalR;

namespace blog.c2s.signalrChat.Hubs
{
    /// <summary>
    /// Service de gestion du chat
    /// </summary>
    public class ChatHub : Hub
    {
        /// <summary>
        /// Nom du serveur de chat
        /// </summary>
        private readonly string _botUser;
        /// <summary>
        /// Stockage des utilisateurs connectés : {Identifiant, {UserName, Room}}
        /// </summary>
        private readonly IDictionary<string, UserConnection> _connections;

        public ChatHub(IDictionary<string, UserConnection> connections)
        {
            _botUser = "MyChat Bot";
            _connections = connections;
        }

        /// <summary>
        /// Méthode permettant de gérer la déconnexion d'un utilisateur d'une salle
        /// </summary>
        /// <param name="exception">Exception généré par SignalR lors de la déconnexion</param>
        /// <returns></returns>
        public override Task OnDisconnectedAsync(Exception exception)
        {
            if (_connections.TryGetValue(Context.ConnectionId, out UserConnection userConnection))
            {
                _connections.Remove(Context.ConnectionId);
                Clients.Group(userConnection.Room).SendAsync("ReceiveMessage", _botUser, $"{userConnection.User} a quitté le salon.");
                SendUsersConnected(userConnection.Room);
            }

            return base.OnDisconnectedAsync(exception);
        }

        /// <summary>
        /// Méthode permettant de rejoindre une salle de chat
        /// </summary>        
        /// <returns></returns>
        public async Task JoinRoom(UserConnection userConnection)
        {
            await Groups.AddToGroupAsync(Context.ConnectionId, userConnection.Room);

            _connections[Context.ConnectionId] = userConnection;

            await SendBotMessage(userConnection, $"{userConnection.User} vient de se connecter dans \"{userConnection.Room}\".");

            await SendUsersConnected(userConnection.Room);
        }

        /// <summary>
        /// Message permettant au bot d'envoyer un message
        /// </summary>
        /// <param name="userConnection"></param>
        /// <returns></returns>
        private async Task SendBotMessage(UserConnection userConnection, string message)
        {
            await Clients.Group(userConnection.Room).SendAsync("ReceiveMessage", null, _botUser, message, DateTime.Now.ToString("G"));
        }

        /// <summary>
        /// Message permettant d'envoyer un message dans une salle de chat
        /// </summary>
        /// <param name="message">Message à envoyer</param>
        /// <returns></returns>
        public async Task SendMessage(string message)
        {
            if (_connections.TryGetValue(Context.ConnectionId, out UserConnection userConnection))
            {
                await Clients.Group(userConnection.Room).SendAsync("ReceiveMessage", Context.ConnectionId, userConnection.User, message, DateTime.Now.ToString("G"));
            }
        }

        /// <summary>
        /// Méthode permettant de renvoyer la liste des utilisateurs connectés dans une salle de chat
        /// </summary>
        /// <param name="room"></param>
        /// <returns></returns>
        public Task SendUsersConnected(string room)
        {
            var users = _connections.Values
                .Where(c => c.Room == room)
                .Select(c => c.User);

            return Clients.Group(room).SendAsync("UsersInRoom", users);
        }
    }
}

Analysons rapidement ce code :

  • private readonly IDictionary<string, UserConnection> _connections; : il s’agit d’un dictionnaire associant un identifiant de connexion d’un client à notre objet créé à l’instant. Le stockage de ce dictionnaire sera vu ulterieurement.
  • .SendAsync("{eventName}", ...) : signalr se base sur l’envoi de websockets pour notifier les clients à l’aide de cette méthode.
  • la récéption de WebSockets par le Hub se fait via les méthodes publiques qui y sont implémentées : ce sont des endpoints en quelques sortes.
  • le traitement de chaque endpoints est décrit en commentaire de chacune d’entres elles.

Enfin, pour pouvoir accéder à ce ChatHub, il nous faut modifier le fichier Program.cs comme suit :

using blog.c2s.signalrChat.Entities;
using blog.c2s.signalrChat.Hubs;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSignalR();
builder.Services.AddCors(options =>
{
    options.AddDefaultPolicy(builder =>
    {
        builder.WithOrigins("http://localhost:3000")
            .AllowAnyHeader()
            .AllowAnyMethod()
            .AllowCredentials();
    });

});
builder.Services.AddSingleton<IDictionary<string, UserConnection>>(opts => new Dictionary<string, UserConnection>());

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
}
else
{
    app.UseExceptionHandler("/Error");
    // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
    app.UseHsts();
}

app.UseHttpsRedirection();

app.UseRouting();
app.UseCors();
app.UseEndpoints(endpoints =>
{
    endpoints.MapHub<ChatHub>("/hub");
});

app.Run();

Quelques commentaires sur ce code :

  • builder.Services.AddSignalR(); : cette instruction permet d’ajouter les services de SignalR au serviceProvider.
  • builder.Services.AddCors(...); : notre client ne sera pas intégré à notre API, il est donc nécéssaire de spécifier le CORS afin de pouvoir intéragir avec notre Hub.
  • builder.Services.AddSingleton<IDictionary<string, UserConnection>>(opts => new Dictionary<string, UserConnection>()); : pour simplifier ce code exemple, notre dictionnaire sera géré comme un simple singleton de données. Sur une application en production, cette donnée pourrait être stockée dans un cache Redis par exemple.
  • app.UseCors(); : afin de spécifier à la pipeline de Kestrel d’utiliser le CORS.

Note : Le CORS doit absolument être spécifié après la redirection HTTPS et le Rooting dans la pipeline de notre API.

  • app.UseEndpoints(endpoints => { endpoints.MapHub<ChatHub>("/hub"); }); : permet de créer un point d’entrée vers notre ChatHub.

Nous en avons à présent terminé avec notre API, passons au client.

4. Création du Front React

4.1 Création du projet Client

Commençons par créer notre projet React :

  • Ouvrez Visual Studio Code dans votre dossier blog.c2s.signalrChat
  • Créez un dossier blog.c2s.signalrChat.Client
  • Ouvrez-y un terminal et lancez la commande suivante :
yarn create react-app blog.c2s.signalr.chat.client --template typescript

Passons à présent aux librairies dont nous aurons besoin :

Une fois installées, nous pouvons à présent passer à l’implémentation de notre client.

4.2 Architecture du client

Nous aurons 4 composants :

  • App : Composant principal qui affichera soit le lobby soit le salon.
  • Lobby : Composant se chargeant de la gestion du lobby.
  • Clients : Composant se chargeant de la gestion du listing des clients connectés au sein d’un salon.
  • Chat : Composant se chargeant de la gestion du chat au sein d’un salon.

Ces composants seront représentés par leur dossier portant le même nom.

La hiérarchie des dossiers reflètera la hiérarchie des composants.

Pour chacun de ses composants, on pourra retrouver différents fichiers :

  • hooks.ts : logique métier du composant via l’utilisation des hooks de React.
  • index.tsx : description du DOM renvoyé par le composant.
  • styles.ts : customisation CSS de nos visuels.
  • types.ts : types d’objets transitants entre nos composants.

Nous aurons donc, à la fin, une structure analogue à celle-ci :

src
│   index.tsx    
└───App
    └───Chat
        | hooks.ts
        | index.tsx
        | styles.tsx
        | types.ts
    └───Clients
        │ hooks.ts
        | index.tsx
        | types.ts
    └───Lobby    
        │ hooks.ts
        | index.tsx
        | types.ts    
    | hooks.ts
    | index.tsx
    | styles.tsx
    | types.ts
...

Le fichier index.tsx à la racine du dossier ~/src contiendra seulement un appel vers notre composant App.

4.3 Implémentation

Le but de ce tutoriel n’étant pas de s’initier à React la partie visuel du code ne sera pas détaillée mais est entièrement disponible ici.

À vous de voir si vous souhaitez créer votre propre visuel, ou simplement copier ce dernier.

Attardons nous plutôt à comprendre comment intéragir avec notre Hub.

La magie opère dans le fichier ~/src/App/hooks.ts.

L’idée est qu’au moment où, dans le Lobby, on soumet le formulaire, on se connecte au salon renseigné avec notre pseudo.

On commence donc par créer toutes les données dont nous aurons besoin dans les autres composants :

// La connection avec le Hub
const [hubConnection, setHubConnection] = useState<signalR.HubConnection | undefined>(undefined);
// La liste des messages du salon sur lequel on est connecté
const [messages, setMessages] = useState<Message[]>([]);
// La liste des clients connectés à ce même salon
const [users, setUsers] = useState<User[]>([]);
// Le nom de ce même salon
const [currentRoom, setCurrentRoom] = useState<string>('');

Une fois fait, on définit donc une méthode joinRoom qui sera exécutée à la soumission du formulaire du Lobby :

  • On définit une connection avec notre Hub :
const connection = new HubConnectionBuilder()
        .withUrl("https://localhost:7145/hub")
        .configureLogging(LogLevel.Information)
        .build();
  • On s’abonne ensuite aux évènements envoyés par ce même Hub :
// ...
connection.on("ReceiveMessage", (connectionId, user, msg, srvTime) => { 
  const newMsg: Message = {
          content: msg,
          userName: user,
          connectionId: connectionId,
          time: srvTime
        };

  setMessages((prevMessages) => [...prevMessages, newMsg]);
});

// ...
connection.on("UsersInRoom", (users) => { 
  setUsers(users);
});
  • On s’abonne, si besoin, aux évènements prédéfinis par SignalR :
// Gérer le cas de la déconnexion : on reset simplement les données stockées
connection.onclose(() => {
  setHubConnection(undefined);
  setMessages([]);
  setUsers([]);
});
  • On lance la connexion :
await connection.start();
  • On invoque l’endpoint JoinRoom de notre Hub réalisant l’enregistrement du client dans le salon renseigné.
await connection.invoke("JoinRoom", { user, room });
  • On s’assure de bien enregistrer les informations dont on aura besoin dans les autres composants :
setHubConnection(connection);
setCurrentRoom(room);

À ce stade nous sommes déjà capable de nous connecter, de réceptionner les messages envoyés par le Hub et la liste des clients connectés.

Afin de pouvoir nous même réaliser des actions sur le Hub, il nous faut créer des méthodes à part :

// Envoyer un Message
const sendMessage = async (message: string) => {
    try {
      await hubConnection?.invoke("SendMessage", message);
    } catch (e) {
      console.log(e);
    }
}

// Fermer la connexion avec le Hub
const closeConnection = async () => {
  try {
    setCurrentRoom('');
    await hubConnection?.stop();
  } catch (e) {
    console.log(e);
  }
}

Pour terminer, il ne nous reste plus qu’à exposer les données nécéssaires au fonctionnement de l’application :

const useData = (): AppDataProps => {
  // ...
  
  return {
      connected: (hubConnection !== undefined),
      currentConnectionId: hubConnection?.connectionId,
      currentRoom,
      messages,
      users,
      joinRoom,
      sendMessage,
      closeConnection
  }
};

export default useData;

Les différents composants n’ont alors plus qu’à les utiliser afin d’intéragir avec notre Hub :

Note : Dans le cas d’une application en production, l’utilisation d’un store de données avec Mobx par exemple serait plus judicieux. On aurait alors toutes ces données disponibles via des Observables sans avoir à les faire passer de composants en composants.

5. Conclusion

Nous avons vu comment créer un chat temps réel au moyen d’une API SignalR et d’un client React.

Le cas présenté se veut être simple pour une première découverte, de nombreuses fonctionnalitées n’ont pas été abordées : l’authentification, les autorisations, les filtres de hub, la communication entre Hubs ou encore la diffusion en continu pour n’en citer que quelques unes.

Nous n’avons également pas parler de la mise en production de SignalR : Azure propose une ressource nommée Azure SignalR Service dédiée spécifiquement à cette technologie.

Il est également possible d’utiliser Azure App Service de manière plus classique et de configurer l’utilisation des WebSockets.

Pour finir voici une liste de liens externes pour aller plus loin :

  • L’intégralité du code associé à cet article sur notre Github ;
  • La formation de base à SignalR sur Microsoft Learn ;
  • Le point d’entrée vers la documentation complète de SignalR ;
  • La documentation sur l’utilisation des Hooks ;
  • La documentation sur l’utilisation des stores avec Mobx ;
  • Le déploiement d’une API SignalR sur Azure SignalR Service ;
  • Le déploiement d’une API SignalR sur Azure App Service.