Tutorial #6: Servidor WebSocket con Ratchet para Señalización en Tiempo Real
Tutorial #6: Servidor WebSocket con Ratchet para Señalización en Tiempo Real
🎯 Introducción
¡Bienvenido de nuevo! En esta lección, vamos a implementar un servidor WebSocket usando Ratchet, una biblioteca PHP para comunicación en tiempo real.
El servidor WebSocket será el corazón de nuestra señalización para WebRTC, permitiendo:
🔄 Comunicación en tiempo real entre usuarios
📨 Envío de ofertas y respuestas WebRTC
👥 Gestión de usuarios en línea
🚀 Notificaciones instantáneas
📦 ¿Qué es Ratchet?
Ratchet (socketo.me) es una biblioteca PHP que implementa el protocolo WebSocket.
Características Principales
| Característica | Descripción |
|---|---|
| 🔌 WebSocket | Comunicación bidireccional en tiempo real |
| 📡 Event-driven | Basado en eventos (onOpen, onMessage, etc.) |
| 🧩 Componentes | Fácil de extender con componentes |
| ⚡ Alto rendimiento | Maneja múltiples conexiones simultáneas |
¿Por qué usar Ratchet?
✅ Ventajas: - Escrito en PHP (mismo lenguaje que el backend) - Fácil integración con el resto del proyecto - Muy documentado - Compatible con Composer - Soporte para WebSockets nativo ❌ Alternativas (Node.js): - Necesitarías aprender JavaScript - Requiere servidor separado - Mayor complejidad en el proyecto
🏗️ Estructura del Servidor WebSocket
1. Archivos Necesarios
📁 vchat/
├── 📄 composer.json ← Dependencias
├── 📄 server.php ← Servidor WebSocket
└── 📁 core/
└── 📁 classes/
└── 📄 Chat.php ← Lógica del WebSocket2. Flujo de Comunicación
[Cliente A] ←→ [Servidor WebSocket] ←→ [Cliente B]
↑ ↑
└─── WebRTC ─────┘
1. Cliente A → Servidor: "Quiero llamar a B"
2. Servidor → Cliente B: "A te está llamando"
3. Cliente B → Servidor: "Acepto"
4. Servidor → Cliente A: "B aceptó"
5. Cliente A ↔ Cliente B: Conexión P2P establecida📦 Paso 1: Instalar Ratchet con Composer
1.1 Configurar composer.json
{ "autoload": { "psr-4": { "MyApp\\": "core/classes" } }, "require": { "cboden/ratchet": "^0.4.3" } }
1.2 Ejecutar la Instalación
# Ir a la carpeta del proyecto cd C:\xampp\htdocs\vchat # Instalar dependencias composer install
Resultado esperado:
Loading composer repositories with package information Updating dependencies (including require-dev) Package operations: 16 installs, 0 updates, 0 removals - Installing evenement/evenement (v3.0.1) - Installing react/event-loop (v1.2.0) - Installing react/stream (v1.2.0) - Installing react/promise (v2.8.0) - Installing react/promise-timer (v1.7.0) - Installing react/cache (v1.1.1) - Installing react/dns (v1.8.0) - Installing react/socket (v1.9.0) - Installing ratchet/rfc6455 (v0.3) - Installing guzzlehttp/psr7 (1.8.3) - Installing psr/http-message (1.0.1) - Installing ralouphie/getallheaders (3.0.3) - Installing symfony/polyfill-mbstring (v1.23.1) - Installing symfony/deprecation-contracts (v2.4.0) - Installing symfony/polyfill-php80 (v1.23.1) - Installing symfony/http-foundation (v5.3.7) - Installing symfony/routing (v5.3.7) - Installing cboden/ratchet (v0.4.3) Generating autoload files
📝 Paso 2: Crear el Servidor
2.1 Archivo server.php
<?php // server.php - Servidor WebSocket use Ratchet\Server\IoServer; use Ratchet\Http\HttpServer; use Ratchet\WebSocket\WsServer; use MyApp\Chat; // Cargar el autoloader de Composer require dirname(__DIR__) . '/vendor/autoload.php'; // Crear el servidor $server = IoServer::factory( new HttpServer( new WsServer( new Chat() ) ), 8080 // Puerto del servidor WebSocket ); echo "🔌 Servidor WebSocket ejecutándose en puerto 8080\n"; echo "📡 Esperando conexiones...\n"; // Ejecutar el servidor $server->run();
2.2 Explicación del Código
// 1. IoServer::factory() - Crea el servidor $server = IoServer::factory( // 2. HttpServer - Maneja peticiones HTTP (upgrade) new HttpServer( // 3. WsServer - Maneja conexiones WebSocket new WsServer( // 4. Chat - Nuestra lógica personalizada new Chat() ) ), 8080 // Puerto );
Componentes:
IoServer: Servidor de entrada/salida
HttpServer: Escucha peticiones HTTP para hacer upgrade a WebSocket
WsServer: Maneja el protocolo WebSocket
Chat: Nuestra clase con la lógica de negocio
💬 Paso 3: Crear la Lógica del Chat
3.1 Archivo Chat.php
<?php // core/classes/Chat.php namespace MyApp; use Ratchet\MessageComponentInterface; use Ratchet\ConnectionInterface; class Chat implements MessageComponentInterface { protected $clients; public function __construct() { // Almacenar todas las conexiones $this->clients = new \SplObjectStorage; echo "🟢 Servidor Chat inicializado\n"; } // ===== Cuando un cliente se conecta ===== public function onOpen(ConnectionInterface $conn) { // Guardar la conexión $this->clients->attach($conn); echo "🔗 Nueva conexión! ID: {$conn->resourceId}\n"; echo "📊 Conexiones activas: " . count($this->clients) . "\n"; // Notificar a todos los usuarios sobre la nueva conexión $this->broadcast([ 'type' => 'user_connected', 'userId' => $conn->resourceId ]); } // ===== Cuando un cliente envía un mensaje ===== public function onMessage(ConnectionInterface $from, $msg) { $numRecv = count($this->clients) - 1; echo "📨 Mensaje de conexión {$from->resourceId}: $msg\n"; echo "📤 Enviando a $numRecv cliente(s)\n"; // Decodificar el mensaje JSON $data = json_decode($msg, true); // Procesar diferentes tipos de mensajes switch ($data['type'] ?? '') { case 'auth': $this->handleAuth($from, $data); break; case 'call_request': $this->handleCallRequest($from, $data); break; case 'call_accepted': $this->handleCallAccepted($from, $data); break; case 'call_rejected': $this->handleCallRejected($from, $data); break; case 'webrtc_signal': $this->handleWebRTCSignal($from, $data); break; case 'call_ended': $this->handleCallEnded($from, $data); break; default: // Broadcast simple para mensajes normales $this->broadcastToOthers($from, $msg); } } // ===== Cuando un cliente se desconecta ===== public function onClose(ConnectionInterface $conn) { // Eliminar la conexión $this->clients->detach($conn); echo "🔌 Conexión {$conn->resourceId} desconectada\n"; echo "📊 Conexiones activas: " . count($this->clients) . "\n"; // Notificar a otros usuarios $this->broadcast([ 'type' => 'user_disconnected', 'userId' => $conn->resourceId ]); } // ===== Cuando ocurre un error ===== public function onError(ConnectionInterface $conn, \Exception $e) { echo "❌ Error en conexión {$conn->resourceId}: {$e->getMessage()}\n"; $conn->close(); } // ===== Métodos de ayuda ===== // Manejar autenticación protected function handleAuth($conn, $data) { $userId = $data['userId'] ?? null; $username = $data['username'] ?? null; if ($userId && $username) { // Guardar datos del usuario en la conexión $conn->userId = $userId; $conn->username = $username; $conn->isBusy = false; echo "✅ Usuario autenticado: $username (ID: $userId)\n"; // Enviar confirmación $conn->send(json_encode([ 'type' => 'auth_success', 'userId' => $userId, 'username' => $username ])); // Notificar a todos los usuarios en línea $this->broadcastOnlineUsers(); } } // Manejar solicitud de llamada protected function handleCallRequest($from, $data) { $receiverId = $data['receiverId'] ?? null; $callerId = $data['callerId'] ?? null; $callerName = $data['callerName'] ?? 'Usuario'; $offer = $data['offer'] ?? null; if (!$receiverId || !$callerId || !$offer) { return; } echo "📞 Solicitud de llamada de $callerName a usuario $receiverId\n"; // Buscar al receptor $receiver = $this->findConnection($receiverId); if (!$receiver) { // Receptor no está en línea $from->send(json_encode([ 'type' => 'user_offline', 'receiverId' => $receiverId ])); return; } // Verificar si el receptor está ocupado if ($receiver->isBusy) { $from->send(json_encode([ 'type' => 'call_busy', 'receiverId' => $receiverId ])); return; } // Marcar al emisor como ocupado $from->isBusy = true; // Enviar notificación al receptor $receiver->send(json_encode([ 'type' => 'incoming_call', 'callerId' => $callerId, 'callerName' => $callerName, 'offer' => $offer, 'roomId' => $data['roomId'] ?? uniqid('call_') ])); } // Manejar aceptación de llamada protected function handleCallAccepted($from, $data) { $callerId = $data['callerId'] ?? null; $answer = $data['answer'] ?? null; if (!$callerId || !$answer) { return; } echo "✅ Llamada aceptada por usuario {$from->userId}\n"; // Marcar al receptor como ocupado $from->isBusy = true; // Buscar al emisor $caller = $this->findConnection($callerId); if ($caller) { $caller->send(json_encode([ 'type' => 'call_connected', 'receiverId' => $from->userId, 'answer' => $answer, 'roomId' => $data['roomId'] ])); } } // Manejar rechazo de llamada protected function handleCallRejected($from, $data) { $callerId = $data['callerId'] ?? null; if (!$callerId) { return; } echo "❌ Llamada rechazada por usuario {$from->userId}\n"; // Liberar al emisor $caller = $this->findConnection($callerId); if ($caller) { $caller->isBusy = false; $caller->send(json_encode([ 'type' => 'call_rejected', 'receiverId' => $from->userId ])); } } // Manejar señales WebRTC (ICE candidates) protected function handleWebRTCSignal($from, $data) { $targetId = $data['targetId'] ?? null; $candidate = $data['candidate'] ?? null; if (!$targetId || !$candidate) { return; } $target = $this->findConnection($targetId); if ($target) { $target->send(json_encode([ 'type' => 'webrtc_signal', 'fromId' => $from->userId, 'candidate' => $candidate ])); } } // Manejar finalización de llamada protected function handleCallEnded($from, $data) { $otherUserId = $data['otherUserId'] ?? null; echo "🔚 Llamada finalizada por usuario {$from->userId}\n"; // Liberar al emisor $from->isBusy = false; // Buscar al otro usuario if ($otherUserId) { $other = $this->findConnection($otherUserId); if ($other) { $other->isBusy = false; $other->send(json_encode([ 'type' => 'call_ended_by_other', 'userId' => $from->userId ])); } } } // ===== Funciones de utilidad ===== // Buscar conexión por userId protected function findConnection($userId) { foreach ($this->clients as $client) { if (isset($client->userId) && $client->userId == $userId) { return $client; } } return null; } // Obtener usuarios en línea protected function getOnlineUsers() { $users = []; foreach ($this->clients as $client) { if (isset($client->userId) && isset($client->username)) { $users[] = [ 'id' => $client->userId, 'username' => $client->username, 'isBusy' => $client->isBusy ?? false ]; } } return $users; } // Transmitir usuarios en línea a todos protected function broadcastOnlineUsers() { $users = $this->getOnlineUsers(); $message = json_encode([ 'type' => 'online_users', 'users' => $users ]); $this->broadcast($message); } // Enviar a todos los clientes protected function broadcast($message) { if (is_array($message)) { $message = json_encode($message); } foreach ($this->clients as $client) { $client->send($message); } } // Enviar a todos excepto al emisor protected function broadcastToOthers($from, $message) { if (is_array($message)) { $message = json_encode($message); } foreach ($this->clients as $client) { if ($from !== $client) { $client->send($message); } } } }
🚀 Paso 4: Ejecutar el Servidor
4.1 Desde Terminal/CMD
Windows 🪟
# Ir a la carpeta del proyecto cd C:\xampp\htdocs\vchat # Ejecutar el servidor php server.php
macOS/Linux 🐧🍎
# Ir a la carpeta del proyecto cd /opt/lampp/htdocs/vchat # Ejecutar el servidor php server.php
4.2 Resultado Esperado
🔌 Servidor WebSocket ejecutándose en puerto 8080 📡 Esperando conexiones... 🟢 Servidor Chat inicializado
4.3 Mantener el Servidor Corriendo
Opción 1: Usar screen (Linux)
# Instalar screen sudo apt-get install screen # Crear una sesión screen -S websocket # Ejecutar el servidor php server.php # Desconectar: Ctrl+A luego D # Reconectar: screen -r websocket
Opción 2: Usar nohup (Linux/macOS)
nohup php server.php > websocket.log 2>&1 &
Opción 3: Usar pm2 (Node.js)
# Instalar pm2 npm install -g pm2 # Ejecutar con pm2 pm2 start "php server.php" --name websocket pm2 save pm2 startup
Opción 4: Servicio de Windows
# Usar NSSM (Non-Sucking Service Manager) nssm install WebSocketServer "C:\xampp\php\php.exe" "C:\xampp\htdocs\vchat\server.php"
🔌 Paso 5: Probar el Servidor WebSocket
5.1 Cliente de Prueba (HTML)
<!DOCTYPE html> <html> <head> <title>Test WebSocket</title> </head> <body> <h1>Cliente WebSocket</h1> <div id="messages"></div> <input type="text" id="messageInput" placeholder="Escribe un mensaje"> <button onclick="sendMessage()">Enviar</button> <script> // Conectar al servidor WebSocket const ws = new WebSocket('ws://localhost:8080'); // Cuando se abre la conexión ws.onopen = () => { console.log('Conectado al servidor'); document.getElementById('messages').innerHTML += '<p>✅ Conectado</p>'; // Autenticar ws.send(JSON.stringify({ type: 'auth', userId: 1, username: 'UsuarioPrueba' })); }; // Cuando se recibe un mensaje ws.onmessage = (event) => { const data = JSON.parse(event.data); console.log('Mensaje recibido:', data); document.getElementById('messages').innerHTML += `<p>📨 ${JSON.stringify(data)}</p>`; }; // Cuando se cierra la conexión ws.onclose = () => { console.log('Desconectado'); document.getElementById('messages').innerHTML += '<p>❌ Desconectado</p>'; }; // Enviar mensaje function sendMessage() { const input = document.getElementById('messageInput'); const msg = input.value; ws.send(JSON.stringify({ type: 'message', content: msg })); input.value = ''; } </script> </body> </html>
5.2 Probar con Múltiples Clientes
Abre dos navegadores diferentes (Chrome y Firefox)
En cada uno, abre la página de prueba
Envía mensajes desde uno y verás que el otro los recibe
📊 Mensajes del Servidor
Tipos de Mensajes
| Tipo | Dirección | Descripción |
|---|---|---|
auth | Cliente → Servidor | Autenticar usuario |
auth_success | Servidor → Cliente | Autenticación exitosa |
online_users | Servidor → Cliente | Lista de usuarios en línea |
incoming_call | Servidor → Cliente | Alguien te llama |
call_request | Cliente → Servidor | Solicitar llamada |
call_accepted | Cliente → Servidor | Aceptar llamada |
call_rejected | Cliente → Servidor | Rechazar llamada |
call_connected | Servidor → Cliente | Conexión establecida |
webrtc_signal | Cliente ↔ Servidor | Señales WebRTC |
call_ended | Cliente → Servidor | Finalizar llamada |
🛠️ Solución de Problemas
Error: "Address already in use"
Problema: El puerto 8080 ya está ocupado.
Soluciones:
Opción 1: Cambiar el puerto
// server.php $server = IoServer::factory( new HttpServer( new WsServer( new Chat() ) ), 8081 // Cambiar a otro puerto );
Opción 2: Liberar el puerto
# Windows - Encontrar proceso usando el puerto 8080 netstat -ano | findstr :8080 taskkill /PID [PID] /F # Linux sudo lsof -i :8080 sudo kill -9 [PID]
Error: "Class MyApp\Chat not found"
Problema: El autoloader no encuentra la clase.
Solución:
# Regenerar el autoloader composer dump-autoload
Error: "Connection refused"
Problema: El servidor WebSocket no está corriendo.
Solución:
# Verificar que el servidor está ejecutándose php server.php # Verificar el puerto netstat -an | grep 8080
📚 Resumen
En esta lección hemos:
✅ Instalado Ratchet con Composer
✅ Creado el servidor WebSocket (server.php)
✅ Implementado la lógica de chat (Chat.php)
✅ Configurado la autenticación de usuarios
✅ Manejado llamadas WebRTC (oferta, respuesta, candidatos)
✅ Probado el servidor con múltiples clientes
🚀 Próximo Paso
En la siguiente lección, vamos a:
Conectar el frontend JavaScript con el servidor WebSocket
Implementar la interfaz de usuario
Integrar WebRTC para las videollamadas
¡Excelente trabajo! El servidor WebSocket está listo para manejar la señalización.
Comentarios
Publicar un comentario