ASP.NET Core SignalR, ou juste "SignalR" pour les intimes, est une bibliothèque pour ASP.NET Core qui va permettre d'ajouter des fonctionnalités temps-réel à vos applications.
Vous pourrez par exemple créer envoyer des notifications en temps réel à vos utilisateurs, ou créer des jeux multijoueurs.

Le but de cet article n'est pas de parler de la technologie elle-même, mais de la façon de gérer sa mise à l'échelle.
Si vous ne connaissez pas déjà SignalR, je vous invite à aller la découvrir dans sa documentation officielle.

Mise à l'échelle

Lorsqu'on utilise SignalR sur un seul serveur, tout fonctionne très bien puisque notre serveur garde en mémoire la liste des clients connectés, et va pouvoir envoyer des messages à chacun d'entre eux.
Mais si on veut gérer beaucoup de clients connectés, on risque d'arriver aux limites de notre serveur (en mémoire utilisée, ou en nombre de connexions ouvertes simultanées).

Si on peut bien sûr passer sur un serveur plus puissant, on envisagera aussi la possibilité de lancer plusieurs serveurs derrière un load-balancer. C'est bien sûr possible, mais il va nous falloir un peu de configuration pour que ça fonctionne.

Connexion du client au serveur

Si on déploie notre serveur SignalR sur un 2ème serveur avec un load-balancer, on va directement avoir des soucis, puisque les clients auront des erreurs à l'ouverture de la connexion.

Ceci est du au fait que SignalR va pouvoir gérer plusieurs transports : un premier appel est faite pour négocier le meilleur transport (WebSocket si le client le supporte, sinon Server-Sent Events ou Long-Polling), puis un second qui ouvre la vraie connexion.
Entre ces deux appels, les informations restent en mémoire sur le serveur, et donc si le client ne se connecte pas à la même instance, la connexion échoue.

Pour remédier à ce problème, nous avons 3 possibilités :

Affinité de session
Ceci est à configurer au niveau du load-balacer. Chaque client sera lié à l'un de vos serveurs, et ses appels arriveront toujours sur le même.

Forcer l'usage de websocket
Si vous êtes sûr que les clients supportent WebSocket, alors vous pouvez oublier l'étape de négociation, et forcer la connexion en SignalR.
Ainsi, plus besoin d'affinité de session, vu qu'il n'y a qu'une requête - elle fonctionnera quel que soit le serveur qui la traitera.

En Javascript, on modifiera la connexion de la manière suivante :

let connection = new signalR.HubConnectionBuilder()
    .withUrl("/appHub", {
        skipNegotiation: true,
        transport: signalR.HttpTransportType.WebSockets
    })
    .build();

Utiliser Azure SignalR Service
Si vous hébergez votre application dans le Cloud Azure, vous pouvez utiliser le service dédié Azure SignalR Service. Ce service sert de proxy entre les clients et les serveurs, et va gérer ces problématiques de connexion pour vous.

graph BT

    subgraph Serveurs
        web1[Web]
        web2[Web]
        azsignalr(Azure SignalR)
        azsignalr<-->web1
        azsignalr<-->web2
    end

    clientA(Client A)
    clientB(Client B)
    clientC(Client C)

    clientA<--ws-->azsignalr
    clientB<--ws-->azsignalr
    clientC<--ws-->azsignalr

Pour configurer SignalR avec Redis, il faut référencer le package Nuget Microsoft.Azure.SignalR, et appeler la méthode suivante lors de la configuation de SignalR :

services.AddSignalR().AddAzureSignalR(azureSignalrConnectionString);

Communication entre clients sur instances différentes

Maintenant qu'on a résolu notre problème de connexion, il reste un problème à régler.
Les clients sont bien connectés, mais chacun de nos serveurs n'a connaissance que d'une partie des clients, il n'est pas possible d'envoyer des messages à un client connecté sur un autre serveur.

Si on veut envoyer un message à tous les clients connectés, il va nous falloir trouver une solution. La documentation d'ASP.NET nous en propose deux.

Avec Azure SignalR Service

Si vous avez déjà choisi cette option dans la première partie, rien de plus à faire :
tous les clients sont bien connectés au même service Azure, tout est géré de manière transparente pour vous.

Avec Redis

L'autre solution est d'utiliser un backplane Redis. Cette fois, on n'a pas un proxy qui gère les connexions, mais Redis et ses fonctionnalités PUB/SUB permet aux serveurs de communiquer entre eux.

graph BT 

    subgraph Serveurs
        web1[Web]
        web2[Web]
        redis[Redis]
        web1<-->redis        
        web2<-->redis
    end

    clientA(Client A)
    clientB(Client B)
    clientC(Client C)

    clientA<--ws-->web1
    clientB<--ws-->web2
    clientC<--ws-->web2

Si l'un de vos serveurs veut envoyer un message, ça se passe en 3 temps :

  • Il envoie le message au serveur Redis, avec le message et les clients visés
  • Tous les serveurs (dont l'émetteur du message) reçoivent une notification du serveur Redis
  • Chacun des serveurs envoie le message aux clients qu'il connait

Pour configurer SignalR avec Redis, il faut référencer le package Nuget Microsoft.AspNetCore.SignalR.StackExchangeRedis, et appeler la méthode suivante lors de la configuation de SignalR :

services.AddSignalR().AddStackExchangeRedis(redisConnectionString);

Multi application

Si vous avez plusieurs applications SignalR, il est possible de les configurer pour qu'elles utilisent le même serveur Redis, mais avec des canaux différents pour éviter qu'elles ne se marchent dessus :

services.AddSignalR()
    .AddStackExchangeRedis(connectionString, options => {
        options.Configuration.ChannelPrefix = "App1";
    });
graph BT
    
    redis(Redis)

    subgraph Application web
        web1[Web]
        web2[Web]
    end
    subgraph API mobile
        api1[API]
        api2[API]
    end

    web1<--channel_web-->redis
    web2<--channel_web-->redis
    api1<--channel_api-->redis
    api2<--channel_api-->redis
    navigateur1<--ws-->web1
    navigateur2<--ws-->web2
    navigateur3<--ws-->web2
    mobile1<--ws-->api1
    mobile2<--ws-->api2
    mobile3<--ws-->api2

Mais ce à quoi on ne pense pas forcément, c'est qu'on peut vouloir que ces deux applications utilisent les mêmes canaux.
Imaginons que dans notre exemple, on veuille qu'une action d'un utilisateur mobile envoie des notifications vers des utilisateurs du site web, en mettant le même ChannelPrefix (ou en laissant la valeur vide), les deux applications pourront communiquer sans problème

Le cas particulier de Blazor Server

Blazor permet de développer des applications web dynamiques, en utilisant uniquement C# et razor.
Il existe deux modes de déploiement : Webassembly, où le code est exécuté dans le navigateur, et Blazor Server, où le code est exécuté sur le serveur, et la communication avec le client se fait via une connexion SignalR.

Et donc si on utilise Blazor Server, on risque de se retrouver avec les mêmes problèmes pour gérer la mise à l'échelle de la connexion SignalR. Mais Blazor arrive avec ses spécificités dont nous allons devoir tenir compte :

Concernant le premier problème de connexion, Blazor va lui aussi nécessiter un premier appel, avant d'initialiser la connexion SignalR. Ce premier appel initialise l'état du composant, et on ne pourra pas y couper, cette fois l'affinité de session est obligatoire dans tous les cas.

Si vous utilisez Azure SignalR Service, il faudra aussi configurer cette affinité auprès entre le serveur et le service :

builder.Services.AddServerSideBlazor().AddAzureSignalR(options =>
{
    options.ServerStickyMode = Microsoft.Azure.SignalR.ServerStickyMode.Required;
});

Le deuxième problème ici n'en est finalement pas un : Blazor utilise la connexion SignalR pour avoir échanger entre le client et le serveur, mais les clients sur différentes instances n'ont pas forcément besoin d'échanger des messages. Dans ce cas, le backplane Redis n'est pas nécessaire.

Si vous souhaitez plus d'informations sur le déploiement d'une application Blazor Serveur, je vous renvoie vers la documentation dédiée.