Contactez-nous

WebSockets avec Spring

Découvrez comment implémenter la communication temps réel bidirectionnelle avec les WebSockets dans Spring Boot. Apprenez à configurer les handlers, à utiliser STOMP et à sécuriser vos connexions.

Introduction aux WebSockets : Au-delà de requête/réponse

Le protocole HTTP, bien que fondamental pour le web, fonctionne sur un modèle requête/réponse initié par le client. Ce modèle atteint ses limites lorsqu'une communication bidirectionnelle et en temps réel est nécessaire entre le client et le serveur. Des techniques comme le polling, le long-polling ou les Server-Sent Events (SSE) tentent de pallier ces limitations, mais elles introduisent de la latence, une surcharge réseau ou sont unidirectionnelles (SSE).

Le protocole WebSocket (standardisé par l'IETF sous RFC 6455) a été conçu spécifiquement pour résoudre ce problème. Il établit une connexion full-duplex (bidirectionnelle) et persistante (stateful) entre un client et un serveur via une unique connexion TCP. Une fois la poignée de main initiale (qui ressemble à une requête HTTP/1.1 avec un en-tête `Upgrade`) réussie, la connexion TCP reste ouverte, permettant aux deux parties d'envoyer des messages à tout moment, indépendamment l'une de l'autre.

Les avantages clés des WebSockets incluent une faible latence, une réduction significative de la surcharge réseau (pas d'en-têtes HTTP répétés pour chaque message) et une véritable communication en temps réel. Ils sont idéaux pour des cas d'usage comme :

  • Les applications de chat.
  • Les notifications push en temps réel.
  • Les tableaux de bord et flux de données live (finance, monitoring).
  • Les jeux multijoueurs en ligne.
  • L'édition collaborative de documents.

Spring Framework fournit un support complet et flexible pour l'intégration des WebSockets dans les applications Java, offrant différents niveaux d'abstraction pour s'adapter à divers besoins.

Support Spring et l'API bas niveau : WebSocketHandler

Spring propose une API de bas niveau pour interagir directement avec le protocole WebSocket. Cette approche donne un contrôle total sur le cycle de vie de la connexion et le traitement des messages bruts (texte ou binaire).

La configuration commence par l'annotation `@EnableWebSocket` sur une classe de configuration et l'implémentation de l'interface `WebSocketConfigurer`. Cette interface requiert de définir la méthode `registerWebSocketHandlers`, qui permet d'enregistrer un ou plusieurs `WebSocketHandler` sur des chemins spécifiques.

import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
import org.springframework.beans.factory.annotation.Autowired;

@Configuration
@EnableWebSocket // Active le support WebSocket
public class WebSocketConfig implements WebSocketConfigurer {

    @Autowired
    private MyCustomWebSocketHandler myHandler;

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        // Enregistre notre handler sur le chemin "/my-websocket-endpoint"
        // withAllowedOrigins("*") est souvent nécessaire pour le développement, à restreindre en production
        registry.addHandler(myHandler, "/my-websocket-endpoint").setAllowedOrigins("*");
    }
}

Le coeur de cette approche est l'implémentation de l'interface `WebSocketHandler` (ou plus commodément, en étendant les classes abstraites `TextWebSocketHandler` ou `BinaryWebSocketHandler`). Ces handlers définissent comment réagir aux événements du cycle de vie de la connexion et aux messages reçus :

  • `afterConnectionEstablished(WebSocketSession session)` : Appelée après qu'une connexion WebSocket a été établie.
  • `handleTextMessage(WebSocketSession session, TextMessage message)` : Gère les messages texte reçus.
  • `handleBinaryMessage(WebSocketSession session, BinaryMessage message)` : Gère les messages binaires reçus.
  • `handleTransportError(WebSocketSession session, Throwable exception)` : Gère les erreurs de transport.
  • `afterConnectionClosed(WebSocketSession session, CloseStatus status)` : Appelée après la fermeture de la connexion.

L'objet `WebSocketSession` représente la connexion active et permet d'envoyer des messages au client (`session.sendMessage(...)`).

Exemple simple d'un `TextWebSocketHandler` qui renvoie les messages en écho :

import org.springframework.stereotype.Component;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import org.springframework.web.socket.CloseStatus;
import java.io.IOException;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.List;

@Component
public class MyCustomWebSocketHandler extends TextWebSocketHandler {

    // Liste thread-safe pour stocker les sessions actives (gestion basique)
    private final List sessions = new CopyOnWriteArrayList<>();

    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        System.out.println("Nouvelle connexion WebSocket: " + session.getId());
        sessions.add(session);
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        String receivedPayload = message.getPayload();
        System.out.println("Message reçu de " + session.getId() + ": " + receivedPayload);
        
        // Simple écho
        session.sendMessage(new TextMessage("Echo: " + receivedPayload));
        
        // Exemple de diffusion (broadcast) à tous les clients connectés
        // broadcast("Message de " + session.getId() + ": " + receivedPayload);
    }
    
    public void broadcast(String message) throws IOException {
         TextMessage textMessage = new TextMessage(message);
         for (WebSocketSession sess : sessions) {
             if (sess.isOpen()) {
                 sess.sendMessage(textMessage);
             }
         }
     }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        System.out.println("Connexion fermée: " + session.getId() + " - Status: " + status);
        sessions.remove(session);
    }
    
     @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
        System.err.println("Erreur de transport sur session " + session.getId() + ": " + exception.getMessage());
        if (session.isOpen()) {
             session.close(CloseStatus.SERVER_ERROR);
        }
        sessions.remove(session);
    }
}

Cette approche est puissante mais de bas niveau. Elle vous oblige à gérer manuellement le format des messages, le routage basé sur le contenu, la gestion des sessions pour la diffusion (broadcasting), etc.

STOMP sur WebSocket : Une abstraction plus riche

Pour simplifier le développement d'applications WebSocket basées sur des messages structurés, Spring offre un excellent support pour utiliser STOMP (Simple Text Oriented Messaging Protocol) comme sous-protocole au-dessus de WebSocket. STOMP définit un format de trame simple (similaire à HTTP) pour l'envoi et la réception de messages, ajoutant des concepts familiers du monde de la messagerie comme les destinations, les commandes (CONNECT, SUBSCRIBE, SEND, UNSUBSCRIBE, etc.) et les accusés de réception.

L'utilisation de STOMP sur WebSocket transforme votre interaction WebSocket en un modèle de messagerie de type publish/subscribe ou point-à-point beaucoup plus structuré. Cela permet notamment d'utiliser un 'message broker' (simple broker en mémoire fourni par Spring, ou un broker externe comme RabbitMQ, ActiveMQ) pour router les messages vers les clients abonnés à des destinations spécifiques (ex: `/topic/updates`, `/queue/user123`).

La configuration passe par l'annotation `@EnableWebSocketMessageBroker` et l'implémentation de `WebSocketMessageBrokerConfigurer` :

import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;

@Configuration
@EnableWebSocketMessageBroker // Active le broker de messages sur WebSocket
public class WebSocketStompConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        // Active un simple broker en mémoire pour les destinations préfixées par "/topic" et "/queue"
        // Les clients s'abonnent à ces destinations.
        config.enableSimpleBroker("/topic", "/queue"); 
        
        // Définit le préfixe pour les destinations gérées par l'application (ex: via @MessageMapping)
        // Les clients envoient des messages vers des destinations comme "/app/hello"
        config.setApplicationDestinationPrefixes("/app");
        
        // Optionnel: Configuration pour utiliser un broker externe (ex: RabbitMQ)
        // config.enableStompBrokerRelay("/topic", "/queue")
        //       .setRelayHost("localhost")
        //       .setRelayPort(61613)
        //       .setClientLogin("guest")
        //       .setClientPasscode("guest");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        // Enregistre l'endpoint WebSocket que les clients utiliseront pour se connecter.
        // "/gs-guide-websocket" est l'URL de connexion (ex: ws://host:port/gs-guide-websocket)
        // withSockJS() active un fallback SockJS pour les navigateurs ne supportant pas WebSocket.
        registry.addEndpoint("/gs-guide-websocket").withSockJS();
    }
}

Le traitement des messages entrants (envoyés par les clients vers des destinations préfixées par `/app` dans notre exemple) se fait via des méthodes annotées avec `@MessageMapping` dans des beans `@Controller` Spring classiques. Les valeurs retournées par ces méthodes peuvent être envoyées automatiquement vers une destination spécifique grâce à l'annotation `@SendTo` (ou `@SendToUser` pour des messages spécifiques à un utilisateur authentifié).

import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.stereotype.Controller;
import org.springframework.web.util.HtmlUtils;

@Controller
public class GreetingController {

    // Gère les messages envoyés par les clients vers "/app/hello"
    @MessageMapping("/hello") 
    // Renvoie la valeur de retour à tous les clients abonnés à "/topic/greetings"
    @SendTo("/topic/greetings") 
    public Greeting greeting(HelloMessage message) throws Exception {
        // Simule un petit délai
        Thread.sleep(1000); 
        // Retourne un nouvel objet qui sera converti (ex: en JSON) et envoyé
        return new Greeting("Bonjour, " + HtmlUtils.htmlEscape(message.getName()) + "!");
    }
    
    // Classe simple pour le message entrant
    public static class HelloMessage {
        private String name;
        // getters, setters, constructeur...
        public String getName() { return name; }
        public void setName(String name) { this.name = name; }
    }

    // Classe simple pour le message sortant
    public static class Greeting {
        private String content;
        public Greeting(String content) { this.content = content; }
        // getters, setters, constructeur...
         public String getContent() { return content; }
        public void setContent(String content) { this.content = content; }
    }
}

Cette approche basée sur STOMP et les annotations simplifie grandement la logique de messagerie, la rendant très similaire au développement de contrôleurs REST ou MVC classiques.

Envoi de messages côté serveur : SimpMessagingTemplate

Dans de nombreux cas, le serveur a besoin d'envoyer des messages aux clients de manière proactive, et non pas seulement en réponse à un message reçu d'un client. Par exemple, pour notifier une mise à jour de données, un événement système, ou le résultat d'un traitement asynchrone.

Lorsque vous utilisez la configuration STOMP (`@EnableWebSocketMessageBroker`), Spring fournit un bean `SimpMessagingTemplate` que vous pouvez injecter dans n'importe quel autre bean (Service, Component, etc.) pour envoyer des messages programm­atiquement vers des destinations du broker.

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;

@Service
public class NotificationService {

    private final SimpMessagingTemplate messagingTemplate;

    @Autowired
    public NotificationService(SimpMessagingTemplate messagingTemplate) {
        this.messagingTemplate = messagingTemplate;
    }

    // Méthode pour envoyer une notification à tous les abonnés d'un topic
    public void sendPublicNotification(String notificationContent) {
        System.out.println("Envoi de la notification publique: " + notificationContent);
        // Convertit l'objet (ici une simple String) et l'envoie vers la destination
        messagingTemplate.convertAndSend("/topic/notifications", new GreetingController.Greeting(notificationContent));
    }
    
    // Exemple d'envoi planifié toutes les 10 secondes
    @Scheduled(fixedRate = 10000)
    public void sendPeriodicMessage() {
         String time = new java.text.SimpleDateFormat("HH:mm:ss").format(new java.util.Date());
         sendPublicNotification("Heure serveur: " + time);
    }

    // Méthode pour envoyer un message privé à un utilisateur spécifique
    // Nécessite une configuration d'authentification et de gestion des utilisateurs
    public void sendPrivateMessage(String username, String messageContent) {
        System.out.println("Envoi du message privé à " + username + ": " + messageContent);
        // Le préfixe "/user" est géré par Spring pour router vers la session de l'utilisateur
        messagingTemplate.convertAndSendToUser(username, "/queue/private-messages", new GreetingController.Greeting(messageContent));
    }
}

`SimpMessagingTemplate` est donc l'outil essentiel pour intégrer la communication WebSocket sortante dans votre logique métier côté serveur.

Considérations client et sécurité

Côté client (typiquement dans un navigateur web), vous utiliserez l'API JavaScript native `WebSocket` si vous communiquez directement avec un `WebSocketHandler`. Si vous utilisez STOMP sur WebSocket, des bibliothèques comme `SockJS-client` (pour la compatibilité et le fallback) et `StompJs` (ou `stomp.js`) sont couramment utilisées pour faciliter la connexion, la souscription aux destinations et l'envoi/réception de messages STOMP.

La sécurité est une préoccupation majeure avec les WebSockets. Il est primordial d'utiliser le protocole sécurisé `wss://` (WebSocket Secure) en configurant SSL/TLS sur votre serveur. De plus, l'intégration avec Spring Security est essentielle. Vous devez protéger l'endpoint de connexion WebSocket/STOMP (celui défini dans `registerStompEndpoints`) et autoriser les messages STOMP (connexion, souscription, envoi) en fonction des rôles et permissions de l'utilisateur. Spring Security offre des mécanismes spécifiques pour cela, notamment via `AbstractSecurityWebSocketMessageBrokerConfigurer`, permettant de sécuriser les différents types de messages et d'utiliser des annotations comme `@PreAuthorize` sur les méthodes `@MessageMapping`.

Soyez également vigilant quant au contenu des messages échangés pour prévenir les attaques de type Cross-Site Scripting (XSS), en particulier si vous affichez directement les messages reçus dans l'interface utilisateur client. L'échappement HTML (comme montré avec `HtmlUtils.htmlEscape` dans l'exemple `@MessageMapping`) est une mesure de base.

En conclusion, Spring offre un support robuste et à plusieurs niveaux pour intégrer les WebSockets, permettant de choisir entre un contrôle fin avec l'API bas niveau ou une approche plus structurée et orientée messagerie avec STOMP, tout en s'intégrant aux mécanismes de sécurité éprouvés de Spring.