Mise à l'échelle d'une application ASP.NET Core SignalR

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.

Calculer un itinéraire grâce à Itinero et aux données OpenStreetMap

Maintenant que nous savons afficher des cartes, on va essayer d'aller un peu plus loin ; aujourd'hui, nous allons calculer et afficher un itinéraire entre 2 points.

Le serveur de carte que nous avons installé précédemment ne va pas pouvoir nous aider, nous allons devoir utiliser un autre service pour ça. Il en existe plusieurs, j'ai décidé d'utiliser Itinero, qui est une librairie .NET, et qui propose aussi une api déployable directement.

Vous pouvez lire la documentation complète d'Itinero pour entrer plus en détail dans le sujet.

Création du fichier RouteDb

Nous avons déjà du effectuer une conversion des données OpenStreetMap vers un format vectoriel, le principe ici est le même, puisqu'Itinero ne va pas travailler non plus sur les données brutes, mais sur des données ne contenant que le nécessaires pour calculer un itinéraire (uniquement les routes, inutiles de s'encombrer des batiments ou des cours d'eau).

Les données OpenStreetMap peuvent toujours être récupérées par région depuis le site Geofabrik, et ensuite Itinero Data Processor permets d'effectuer la conversion en ligne de commande.

Tout ce que permets IDP est faisable par code grâce à un packge Nuget, et c'est ce que j'ai choisi de faire, afin d'automatiser complètement la récupération des données OSM, la conversion, et l'envoi du résultat vers un stockage cloud.

Pour cela, j'ai créé une application console, dans laquelle j'ai référencé les packages Nuget Itinero et Itinero.IO.Osm.
Puis quelques lignes de code pour récupérer les données de Midi-Pyrénées, les convertir, et sauvegarder le résultat.

var routerDb = new RouterDb();
var client = new HttpClient();

var url = "http://download.geofabrik.de/europe/france/midi-pyrenees-latest.osm.pbf";
using (var response = await client.GetAsync(url))
{
    routerDb.LoadOsmData(response.Content.ReadAsStream(), Vehicle.Car, Vehicle.Bicycle);
}

using (var stream = new FileInfo(@"midi-pyrenees.routerdb").Open(FileMode.Create))
{
    routerDb.Serialize(stream);
}

Deux choses importantes à voir dans ce code :

  • routerDb.LoadOsmData : cette méthode lis le fichier OpenStreetMap, et récupère les informations nécessaires au calcul d'itinéraire.
    Une liste de Vehicle est passé en paramètre, car selon le type d'itinéraire qu'on veut calculer, les données à utiliser ne seront pas les mêmes.
  • routerDb.Serialize : cette méthode permets d'enregistrer nos données dans un fichier RouterDb, ensuite nous n'utiliserons que ce fichier pour tous nos calculs d'itinéraires.

On a une liste de profils de véhicules prédéfinis (voiture, vélo, piéton ou camion…), et on peut définir d'autres profils personnalisés.

Vous pouvez lancer cette application, et la laisser tourner quelques minutes, ça sera plus ou moins long selon la taille de la zone à convertir, et le nombre de profils de véhicules que vous souhaitez gérer.

Calcul d'un itinéraire

Maintenant qu'on a un RouterDb, on va pouvoir l'utiliser pour calculer nos itinéraires.

On commence par charger les données depuis le fichier, puis on crée un Router utilisant ces données

RouterDb routerDb;
await using (Stream routerDbFile = await container.GetFile("midi-pyrenees.routerdb"))
{
    routerDb = RouterDb.Deserialize(routerDbFile);
}

var router = new Router(routerDb);

Il nous faut ensuite créer un Profile : avec quel type de véhicule on souhaite calculer l'itinéraire, et si on veut le plus rapide ou le plus court.

Là aussi, je reste sur les profils prédéfinis :

var profile = Vehicle.Car.Fastest();            // Trajet le plus rapide en voiture
var profile2 = Vehicle.SmallTruck.Shortest();   // Trajet le plus court en camionnette

Puis on choisit le point de départ et d'arrivée avec leurs coordonnées GPS, et on peut calculer l'itinéraire

var start = router.Resolve(profile, 43.60442f, 1.44403f);
var end = router.Resolve(profile, 43.23370f, 1.57595f);

var route = router.Calculate(profile, start, end);

L'objet Route contient toutes les informations qui nous intéressent, notamment :

  • Shape et ShapeMeta : les coordonnées de tous les points représentant la route, et les métadonnées associées
  • TotalDistance et TotalTime : la distance totale et la durée estimée du trajet

Ces informations suffisent maintenant à connaitre toutes les informations du trajet. Pour le dessiner sur une carte, on peut par exemple dessiner un trait entre chaque point du tableau Shape.

Cependant, plutôt que de faire ça manuellement, on préfèrera certainement utiliser le format GeoJson pour envoyer les données à une librairie compatible. Il suffit d'appeler la méthode ToGeoJson()

// Envoi du résultat depuis une API ASP.NET
return Ok(route.ToGeoJson());

Il suffit ensuite de récupérer les données en JavaScript et de les passer à la librairie MapLibre pour pouvoir l'afficher :

// On fait un appel à l'API pour recevoir les informations de l'itinéraire
const response = await fetch('/Itineraire');
const data = await response.json();

const map = initMap(); // voir les articles précédents pour l'initialisation de la carte

// On crée une source de données avec les informations geojson reçues
map.addSource('route', {
    type: 'geojson',
    data: data
});

// On affiche les données de la source avec l'id "route"
map.addLayer({
    'id': 'route',
    'type': 'line',
    'source': 'route',
    'layout': {
        'line-join': 'round',
        'line-cap': 'round'
    },
    'paint': {
        'line-color': '#888',
        'line-width': 8
    }
});

Démonstration

Voilà donc le résultat : j'ai repris l'affichage de la carte existant, et on peut afficher par dessus le trajet entre Toulouse et Saverdun.

Partager ses clés OpenPGP et trouver celles de vos correspondants

OpenPGP est un format de cryptographie utilisé pour le chiffrement des e-mails de bout en bout.
Le chiffrement avec OpenPGP est géré nativement par Thunderbird, et fonctionne avec n'importe quelle adresse e-mail. Des services comme Proton vous proposent un usage simplifié en embarquant directement son usage dans leur webmail.
Vous pouvez trouver une liste de logiciels compatibles sur le site OpenPGP.org

Le chiffrement repose sur une paire de clés (publique et privée) associée à chaque adresse e-mail : lorsque vous écrivez à quelqu'un, vous utilisez sa clé publique pour chiffrer le message, le destinataire utilisera sa clé privée pour le déchiffrer et lire son contenu.

S'il est bien sûr possible de partager sa clé publique en la donnant en main propre à ses correspondants, ce n'est pas forcément le plus pratique ; la question se pose ainsi de savoir comment partager sa clé publique par internet de manière sécurisée.

Partager sur un serveur de clés

Historiquement, on partageait ses clés sur un réseau de serveurs de clés décentralisés. Cette approche a trouvé ses limites, et aujourd'hui plusieurs projets OpenPGP se sont rassemblés pour créer et utiliser un serveur de clés principal de confiance : https://keys.openpgp.org/.

Ceci ne vous empêche en rien d'utiliser d'autres serveurs, mais en envoyant vos clés sur celui-là, votre adresse e-mail sera vérifiée (empêchant quiconque de fournir une fausse clé pour votre adresse), et les utilisateurs qui auront ce serveur configuré trouveront votre clé plus facilement.

Pour envoyer votre clé, allez sur la page "gérer votre clé" et entrez votre adresse e-mail. Vous allez recevoir un message avec un lien vous permettant d'envoyer vos clés ou de les supprimer du serveur.

Lorsque la clé est sur un serveur, elle est trouvable par l'API :
https://keys.openpgp.org/pks/lookup?op=get&options=mr&search=blog@lacasa.fr

Il est bien sûr toujours possible d'héberger son propre serveur de clés, et ça peut être pertinent si vous êtes en position d'autorité de confiance pour un grand nombre de clés. C'est par exemple ce qu'a fait Protonmail pour distribuer les clés de tous ses utilisateurs.

Cependant, il faut que les utilisateurs enregistrent manuellement votre serveur de clés dans leurs logiciels pour pouvoir en profiter, vous aurez probablement du mal à convaincre beaucoup de monde de le faire. C'est pour ça que nous avons un autre système de partage de clés : WKD

Partager directement avec Web Key Directory

Plutôt que de passer par un serveur externe en qui il faut avoir confiance, il est possible d'héberger ses propres clés.
L'idée est simple : si une adresse e-mail est en '@lacasa.fr', on doit pouvoir faire confiance au site web https://lacasa.fr pour fournir sa clé publique.

N'importe quel hébergement statique sera capable de fournir les clés, puisqu'il suffit de créer une arborescence et de donner le bon nom aux fichiers

https://[domaine]/.well-known/openpgpkey/hu/[Hashed-UserId]?l=[UserId]

Le nom du fichier est un hash du userId. Ainsi, pour l'adresse e-mail blog@lacasa.fr, le hash de blog est n4jfjtn9wusz7eehybkb96azsdg68ftf

La clé de cette adresse e-mail peut donc être publiée à cette adresse :
https://lacasa.fr/.well-known/openpgpkey/hu/n4jfjtn9wusz7eehybkb96azsdg68ftf?l=blog

Certains serveurs WKD ont besoin d'avoir le userId passé en clair, on l'ajoute donc le paramètre si besoin : l=blog

Il existe un mode avancé pour proposer ses clés, quand pour des raisons pratiques on ne peut pas ou on ne souhaite pas héberger ses clés directement sous le domaine racine.
Ce mode avancé permets aussi de centraliser l'hébergement des clés de plusieurs domaines sur un seul serveur.

Pour ça, il faut créer un sous-domaine openpgpkey au domaine de l'adresse e-mail, et ajouter aussi un dossier avec le nom du domaine dans le dossier .well-known/openpgpkey

https://openpgpkey.[domaine]/.well-known/openpgpkey/[domaine]/hu/[Hashed-Userid]?l=[UserId]

La clé de mon adresse e-mail est donc aussi accessible à cette adresse :
https://openpgpkey.lacasa.fr/.well-known/openpgpkey/lacasa.fr/hu/n4jfjtn9wusz7eehybkb96azsdg68ftf?l=blog

Ainsi on peut héberger les clés en dehors de son site web principal, et héberger plusieurs domaines sur le même serveur en faisant pointer les différents sous-domaine sur ce site web avec un CNAME.

Le serveur de clés keys.openpgp.org propose ainsi un service WKD utilisable avec votre propre domaine :
Créez le CNAME qui pointe vers wkd.keys.openpgp.org, et vos clés déjà hébergées sur leur serveur deviennent accessible en WKD sur votre propre domaine.

.NET et les clés OpenPGP

Si je me suis plongé dans le partage de clés OpenPGP, c'était aussi pour pouvoir chiffrer des e-mails envoyés depuis mes applications métier. J'ai donc fait une librairie .NET standard Pericia.OpenPgp pour retrouver facilement des clés sur un serveur de clés ou par WKD, que vous pouvez ensuite utiliser avec BoucyCastle.

var key1 = openPgpSearch.SearchWebKeyDirectory(mailAddress);
var key2 = openPgpSearch.SearchHttpKeyServer(mailAddress);

Cette librairie propose aussi une extension à ASP.NET pour distribuer vos clés via WKD (aussi bien en mode direct qu'en mode avancé). Il vous suffit de mettre vos clés publiques dans un dossier, et elles seront distribuées automatiquement.

// Configure services
var keyDirectory = builder.Environment.ContentRootFileProvider.GetDirectoryContents("Keys");
builder.Services.AddWebKeyDirectory(keyDirectory);

// Configure app
app.UseWebKeyDirectory();