Saltar al contenido principal

Capítulo 5: WebSockets y Comunicación en Tiempo Real

Objetivos del Capítulo​

  • Comprender el protocolo WebSocket
  • Configurar Spring WebSocket con STOMP
  • Implementar un sistema de mensajería en tiempo real
  • Gestionar la autenticación WebSocket
  • Crear aplicaciones de chat y notificaciones

1. Introducción a 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 Cuándo Usar WebSocket​

Caso de UsoSolución
Chat en tiempo realWebSocket
Notificaciones pushWebSocket
Juegos multijugadorWebSocket
Trading en tiempo realWebSocket
Tableros en vivoWebSocket
Datos frecuentes (polling)WebSocket
API REST estándarHTTP

1.3 Protocolo STOMP​

STOMP (Simple Text Oriented Messaging Protocol) proporciona una capa de abstracción sobre WebSocket:

STOMP Frame:
COMMAND
header1:value1
header2:value2

Body^@

Comandos principales:

  • CONNECT - Establecer conexión
  • SUBSCRIBE - Suscribirse a un destino
  • SEND - Enviar un mensaje
  • MESSAGE - Recibir un mensaje
  • DISCONNECT - Cerrar conexión

2. Configuración de Spring WebSocket​

2.1 Dependencias 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 Configuración 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) {
// Prefijo para los destinos que el servidor envía a los clientes
// /topic = broadcast a todos los suscriptores
// /queue = mensaje privado a un usuario
registry.enableSimpleBroker("/topic", "/queue");

// Prefijo para los destinos que los clientes envían al servidor
registry.setApplicationDestinationPrefixes("/app");

// Prefijo para los mensajes privados a un usuario específico
registry.setUserDestinationPrefix("/user");
}

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

3. Controller WebSocket​

3.1 DTOs de Mensaje​

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);

// Cliente envía a /app/chat.sendMessage
// Servidor hace broadcast a /topic/public
@MessageMapping("/chat.sendMessage")
@SendTo("/topic/public")
public ChatMessage sendMessage(@Payload ChatMessage message) {
log.info("Mensaje recibido: {} de {}", message.getContent(), message.getSender());
return message;
}

// Gestión de la llegada de un usuario
@MessageMapping("/chat.addUser")
@SendTo("/topic/public")
public ChatMessage addUser(@Payload ChatMessage message,
SimpMessageHeaderAccessor headerAccessor) {

// Almacenar el nombre de usuario en la sesión WebSocket
headerAccessor.getSessionAttributes().put("username", message.getSender());

log.info("Usuario se unió: {}", message.getSender());

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

3.3 Envío de Mensajes desde un Servicio​

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 a todos los suscriptores de /topic/notifications
public void sendBroadcastNotification(String message) {
ChatMessage notification = new ChatMessage(
"System",
message,
ChatMessage.MessageType.CHAT
);

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

// Mensaje privado a un usuario específico
public void sendPrivateMessage(String username, String message) {
ChatMessage privateMessage = new ChatMessage(
"System",
message,
ChatMessage.MessageType.CHAT
);

// Envía a /user/{username}/queue/private
messagingTemplate.convertAndSendToUser(
username,
"/queue/private",
privateMessage
);
}

// Enviar a un destino específico
public void sendToRoom(String roomId, ChatMessage message) {
messagingTemplate.convertAndSend("/topic/room/" + roomId, message);
}
}

4. Event Listeners​

4.1 Gestión de Conexiones/Desconexiones​

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("Nueva conexión WebSocket establecida");
}

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

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

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

ChatMessage leaveMessage = new ChatMessage(
username,
username + " ha salido del chat",
ChatMessage.MessageType.LEAVE
);

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

5. Cliente JavaScript​

5.1 Plantilla HTML​

<!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="Ingrese su nombre">
<button onclick="connect()">Unirse al Chat</button>
</div>

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

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

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

// Conexión 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';

// Suscribirse al topic público
stompClient.subscribe('/topic/public', onMessageReceived);

// Suscribirse a mensajes privados
stompClient.subscribe('/user/queue/private', onPrivateMessage);

// Anunciar la llegada
stompClient.send('/app/chat.addUser', {}, JSON.stringify({
sender: username,
type: 'JOIN'
}));
}

function onError(error) {
console.error('WebSocket error:', error);
alert('No se pudo conectar al servidor WebSocket');
}

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 = '[Privado] ' + 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 + ' se unió al chat';
} else if (message.type === 'LEAVE') {
messageElement.className += ' leave-message';
messageElement.textContent = message.sender + ' salió del chat';
} else {
messageElement.textContent = message.sender + ': ' + message.content;
}

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

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

6. Autenticación 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())) {
// Extraer el token del 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 Registrar el 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 y Canales​

7.1 Controller con Rooms​

@Controller
public class RoomController {

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

log.info("Mensaje a la sala {}: {}", roomId, message.getContent());
return message;
}

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

// Almacenar la sala en la sesión
headerAccessor.getSessionAttributes().put("currentRoom", roomId);

message.setType(ChatMessage.MessageType.JOIN);
message.setContent(message.getSender() + " se unió a la sala " + roomId);

return message;
}
}

7.2 Cliente JavaScript - Rooms​

function joinRoom(roomId) {
// Cancelar suscripción de la sala anterior
if (currentRoomSubscription) {
currentRoomSubscription.unsubscribe();
}

// Suscribirse a la nueva sala
currentRoomSubscription = stompClient.subscribe(
'/topic/room/' + roomId,
onRoomMessage
);

// Anunciar la llegada
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. Resumen​

Arquitectura​

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

Prefijos de Destino​

PrefijoUso
/appCliente a servidor
/topicBroadcast (1-a-muchos)
/queuePunto a punto (1-a-1)
/userMensajes privados

Buenas Prácticas​

  1. Utilizar SockJS para compatibilidad
  2. Implementar autenticación
  3. Gestionar las desconexiones correctamente
  4. Limitar el tamaño de los mensajes
  5. Monitorear las conexiones activas
  6. Usar salas (rooms) para escalar

Ir a los Trabajos Prácticos

Volver al Módulo 14