Aller au contenu principal

Chapitre 5: WebSockets et Communication Temps Réel

Objectifs du Chapitre​

  • Comprendre le protocole WebSocket
  • Configurer Spring WebSocket avec STOMP
  • Implémenter un système de messagerie temps réel
  • Gérer l'authentification WebSocket
  • Créer des applications de chat et notifications

1. Introduction aux WebSockets​

1.1 HTTP vs WebSocket​

HTTP (Request-Response):
Client ──GET──> Server
Client <──200── Server
Client ──GET──> Server
Client <──200── Server
(Connexion fermée après chaque échange)

WebSocket (Bidirectionnel):
Client ──Handshake──> Server
<──Upgrade────
←───────────→ (Connexion persistante)
←───────────→ (Messages bidirectionnels)

1.2 Quand Utiliser WebSocket​

Cas d'UsageSolution
Chat en temps réelWebSocket
Notifications pushWebSocket
Jeux multijoueursWebSocket
Trading temps réelWebSocket
Tableaux de bord liveWebSocket
Données fréquentes (polling)WebSocket
API REST standardHTTP

1.3 STOMP Protocol​

STOMP (Simple Text Oriented Messaging Protocol) fournit une couche d'abstraction sur WebSocket:

STOMP Frame:
COMMAND
header1:value1
header2:value2

Body^@

Commandes principales:

  • CONNECT - Établir connexion
  • SUBSCRIBE - S'abonner à une destination
  • SEND - Envoyer un message
  • MESSAGE - Recevoir un message
  • DISCONNECT - Fermer connexion

2. Configuration Spring WebSocket​

2.1 Dépendances Maven​

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

<!-- Pour SockJS fallback -->
<dependency>
<groupId>org.webjars</groupId>
<artifactId>sockjs-client</artifactId>
<version>1.5.1</version>
</dependency>

<!-- Pour STOMP -->
<dependency>
<groupId>org.webjars</groupId>
<artifactId>stomp-websocket</artifactId>
<version>2.3.4</version>
</dependency>
</dependencies>

2.2 Configuration WebSocket​

package com.example.websocket.config;

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
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// Préfixe pour les destinations que le serveur envoie aux clients
// /topic = broadcast à tous les abonnés
// /queue = message privé à un utilisateur
registry.enableSimpleBroker("/topic", "/queue");

// Préfixe pour les destinations que les clients envoient au serveur
registry.setApplicationDestinationPrefixes("/app");

// Préfixe pour les messages privés à un utilisateur spécifique
registry.setUserDestinationPrefix("/user");
}

@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// Endpoint WebSocket avec SockJS fallback
registry.addEndpoint("/ws")
.setAllowedOrigins("http://localhost:3000", "http://localhost:8080")
.withSockJS();
}
}

3. Controller WebSocket​

3.1 Message DTOs​

package com.example.websocket.dto;

public class ChatMessage {

private String sender;
private String content;
private String timestamp;
private MessageType type;

public enum MessageType {
CHAT, JOIN, LEAVE
}

public ChatMessage() {}

public ChatMessage(String sender, String content, MessageType type) {
this.sender = sender;
this.content = content;
this.type = type;
this.timestamp = java.time.LocalDateTime.now().toString();
}

// Getters et Setters
public String getSender() { return sender; }
public void setSender(String sender) { this.sender = sender; }

public String getContent() { return content; }
public void setContent(String content) { this.content = content; }

public String getTimestamp() { return timestamp; }
public void setTimestamp(String timestamp) { this.timestamp = timestamp; }

public MessageType getType() { return type; }
public void setType(MessageType type) { this.type = type; }
}

3.2 Controller​

package com.example.websocket.controller;

import com.example.websocket.dto.ChatMessage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.stereotype.Controller;

@Controller
public class ChatController {

private static final Logger log = LoggerFactory.getLogger(ChatController.class);

// Client envoie à /app/chat.sendMessage
// Serveur broadcast à /topic/public
@MessageMapping("/chat.sendMessage")
@SendTo("/topic/public")
public ChatMessage sendMessage(@Payload ChatMessage message) {
log.info("Message received: {} from {}", message.getContent(), message.getSender());
return message;
}

// Gestion de l'arrivée d'un utilisateur
@MessageMapping("/chat.addUser")
@SendTo("/topic/public")
public ChatMessage addUser(@Payload ChatMessage message,
SimpMessageHeaderAccessor headerAccessor) {

// Stocker le username dans la session WebSocket
headerAccessor.getSessionAttributes().put("username", message.getSender());

log.info("User joined: {}", message.getSender());

message.setType(ChatMessage.MessageType.JOIN);
return message;
}
}

3.3 Envoi de Messages depuis un Service​

package com.example.websocket.service;

import com.example.websocket.dto.ChatMessage;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;

@Service
public class NotificationService {

private final SimpMessagingTemplate messagingTemplate;

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

// Broadcast à tous les abonnés de /topic/notifications
public void sendBroadcastNotification(String message) {
ChatMessage notification = new ChatMessage(
"System",
message,
ChatMessage.MessageType.CHAT
);

messagingTemplate.convertAndSend("/topic/notifications", notification);
}

// Message privé à un utilisateur spécifique
public void sendPrivateMessage(String username, String message) {
ChatMessage privateMessage = new ChatMessage(
"System",
message,
ChatMessage.MessageType.CHAT
);

// Envoie à /user/{username}/queue/private
messagingTemplate.convertAndSendToUser(
username,
"/queue/private",
privateMessage
);
}

// Envoyer à une destination spécifique
public void sendToRoom(String roomId, ChatMessage message) {
messagingTemplate.convertAndSend("/topic/room/" + roomId, message);
}
}

4. Event Listeners​

4.1 Gestion des Connexions/Déconnexions​

package com.example.websocket.listener;

import com.example.websocket.dto.ChatMessage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.event.EventListener;
import org.springframework.messaging.simp.SimpMessageSendingOperations;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.messaging.SessionConnectedEvent;
import org.springframework.web.socket.messaging.SessionDisconnectEvent;

@Component
public class WebSocketEventListener {

private static final Logger log = LoggerFactory.getLogger(WebSocketEventListener.class);

private final SimpMessageSendingOperations messagingTemplate;

public WebSocketEventListener(SimpMessageSendingOperations messagingTemplate) {
this.messagingTemplate = messagingTemplate;
}

@EventListener
public void handleWebSocketConnectListener(SessionConnectedEvent event) {
log.info("New WebSocket connection established");
}

@EventListener
public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());

String username = (String) headerAccessor.getSessionAttributes().get("username");

if (username != null) {
log.info("User disconnected: {}", username);

ChatMessage leaveMessage = new ChatMessage(
username,
username + " has left the chat",
ChatMessage.MessageType.LEAVE
);

messagingTemplate.convertAndSend("/topic/public", leaveMessage);
}
}
}

5. Client JavaScript​

5.1 HTML Template​

<!DOCTYPE html>
<html>
<head>
<title>WebSocket Chat</title>
<script src="/webjars/sockjs-client/sockjs.min.js"></script>
<script src="/webjars/stomp-websocket/stomp.min.js"></script>
<style>
.chat-container { max-width: 600px; margin: 0 auto; }
.messages { height: 400px; overflow-y: auto; border: 1px solid #ccc; padding: 10px; }
.message { padding: 5px; margin: 5px 0; }
.join-message { color: green; }
.leave-message { color: red; }
input[type="text"] { width: 70%; padding: 10px; }
button { padding: 10px 20px; }
</style>
</head>
<body>
<div class="chat-container">
<div id="login-page">
<input type="text" id="username" placeholder="Enter your name">
<button onclick="connect()">Join Chat</button>
</div>

<div id="chat-page" style="display: none;">
<div class="messages" id="messages"></div>
<input type="text" id="message" placeholder="Type a message...">
<button onclick="sendMessage()">Send</button>
</div>
</div>

<script>
let stompClient = null;
let username = null;

function connect() {
username = document.getElementById('username').value.trim();
if (!username) return;

// Connexion SockJS
const socket = new SockJS('/ws');
stompClient = Stomp.over(socket);

stompClient.connect({}, onConnected, onError);
}

function onConnected() {
document.getElementById('login-page').style.display = 'none';
document.getElementById('chat-page').style.display = 'block';

// S'abonner au topic public
stompClient.subscribe('/topic/public', onMessageReceived);

// S'abonner aux messages privés
stompClient.subscribe('/user/queue/private', onPrivateMessage);

// Annoncer l'arrivée
stompClient.send('/app/chat.addUser', {}, JSON.stringify({
sender: username,
type: 'JOIN'
}));
}

function onError(error) {
console.error('WebSocket error:', error);
alert('Could not connect to WebSocket server');
}

function sendMessage() {
const messageInput = document.getElementById('message');
const content = messageInput.value.trim();

if (content && stompClient) {
stompClient.send('/app/chat.sendMessage', {}, JSON.stringify({
sender: username,
content: content,
type: 'CHAT'
}));

messageInput.value = '';
}
}

function onMessageReceived(payload) {
const message = JSON.parse(payload.body);
displayMessage(message);
}

function onPrivateMessage(payload) {
const message = JSON.parse(payload.body);
message.content = '[Private] ' + message.content;
displayMessage(message);
}

function displayMessage(message) {
const messagesDiv = document.getElementById('messages');
const messageElement = document.createElement('div');
messageElement.className = 'message';

if (message.type === 'JOIN') {
messageElement.className += ' join-message';
messageElement.textContent = message.sender + ' joined the chat';
} else if (message.type === 'LEAVE') {
messageElement.className += ' leave-message';
messageElement.textContent = message.sender + ' left the chat';
} else {
messageElement.textContent = message.sender + ': ' + message.content;
}

messagesDiv.appendChild(messageElement);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}

// Envoyer avec Enter
document.getElementById('message').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
sendMessage();
}
});
</script>
</body>
</html>

6. Authentification WebSocket​

6.1 Channel Interceptor​

package com.example.websocket.config;

import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageHeaderAccessor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;

@Component
public class AuthChannelInterceptor implements ChannelInterceptor {

private final JwtService jwtService;

public AuthChannelInterceptor(JwtService jwtService) {
this.jwtService = jwtService;
}

@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor
.getAccessor(message, StompHeaderAccessor.class);

if (StompCommand.CONNECT.equals(accessor.getCommand())) {
// Extraire le token du header
String token = accessor.getFirstNativeHeader("Authorization");

if (token != null && token.startsWith("Bearer ")) {
token = token.substring(7);

if (jwtService.validateToken(token)) {
String username = jwtService.extractUsername(token);

UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(
username, null, Collections.emptyList()
);

accessor.setUser(auth);
}
}
}

return message;
}
}

6.2 Enregistrer l'Interceptor​

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

private final AuthChannelInterceptor authInterceptor;

@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(authInterceptor);
}

// ... autres configurations
}

7. Rooms et Canaux​

7.1 Controller avec Rooms​

@Controller
public class RoomController {

@MessageMapping("/room/{roomId}/send")
@SendTo("/topic/room/{roomId}")
public ChatMessage sendToRoom(
@DestinationVariable String roomId,
@Payload ChatMessage message) {

log.info("Message to room {}: {}", roomId, message.getContent());
return message;
}

@MessageMapping("/room/{roomId}/join")
@SendTo("/topic/room/{roomId}")
public ChatMessage joinRoom(
@DestinationVariable String roomId,
@Payload ChatMessage message,
SimpMessageHeaderAccessor headerAccessor) {

// Stocker le room dans la session
headerAccessor.getSessionAttributes().put("currentRoom", roomId);

message.setType(ChatMessage.MessageType.JOIN);
message.setContent(message.getSender() + " joined room " + roomId);

return message;
}
}

7.2 Client JavaScript - Rooms​

function joinRoom(roomId) {
// Se désabonner de l'ancien room
if (currentRoomSubscription) {
currentRoomSubscription.unsubscribe();
}

// S'abonner au nouveau room
currentRoomSubscription = stompClient.subscribe(
'/topic/room/' + roomId,
onRoomMessage
);

// Annoncer l'arrivée
stompClient.send('/app/room/' + roomId + '/join', {}, JSON.stringify({
sender: username,
type: 'JOIN'
}));

currentRoom = roomId;
}

function sendToRoom(content) {
stompClient.send('/app/room/' + currentRoom + '/send', {}, JSON.stringify({
sender: username,
content: content,
type: 'CHAT'
}));
}

8. Résumé​

Architecture​

Client (Browser)
|
| SockJS / WebSocket
|
Spring WebSocket
|
├── STOMP Protocol
|
├── Message Broker (/topic, /queue)
|
└── @MessageMapping Controllers

Préfixes de Destination​

PréfixeUsage
/appClient vers serveur
/topicBroadcast (1-to-many)
/queuePoint-to-point (1-to-1)
/userMessages privés

Bonnes Pratiques​

  1. Utiliser SockJS pour la compatibilité
  2. Implémenter l'authentification
  3. Gérer les déconnexions proprement
  4. Limiter la taille des messages
  5. Monitorer les connexions actives
  6. Utiliser des rooms pour le scaling

Passer aux Travaux Pratiques

Retour au Module 14