Cómo crear colaboración en tiempo real con WebSockets
La demanda de experiencias colaborativas en tiempo real, similares a Google Docs, ya no es una característica, sino una expectativa. Para los directores de tecnología y líderes de ingeniería, diseñar un sistema así presenta un conjunto único de desafíos que se diferencian notablemente de los patrones estándar de solicitud-respuesta. El enfoque ingenuo del "polling" HTTP no es viable, lo que resulta en alta latencia y una carga de servidor inmanejable.
La solución reside en un canal de comunicación bidireccional y persistente. Este es el dominio del protocolo WebSocket.
Este artículo proporciona un esquema técnico para construir una herramienta de colaboración robusta y en tiempo real. Pasaremos de una simple demostración de chat y nos centraremos en los componentes arquitectónicos clave necesarios para un sistema de nivel de producción, incluyendo la gestión de conexiones, la sincronización de estados y la escalabilidad horizontal. Nos centraremos principalmente en la implementación de un editor de texto compartido, ya que sus desafíos son representativos de la mayoría de las tareas colaborativas.
Equipo de Ingeniería de Software Compartido Bajo Demanda, Por Suscripción.
Acceda a un equipo flexible y compartido de ingeniería de software bajo demanda a través de una suscripción mensual predecible. Desarrolladores, diseñadores, ingenieros de control de calidad y un gerente de proyecto gratuito le ayudan a crear MVPs, escalar productos e innovar con tecnologías modernas como React, Node.js y más.
Arquitectura central: El Hub y el Cliente WebSocket
En esencia, el sistema consta de dos partes principales: un servidor central ("el nodo") que gestiona las conexiones y transmite datos, y múltiples clientes (navegadores) que mantienen una conexión WebSocket persistente con ese nodo.
- El Centro del Lado del Servidor: Esto no es un servidor HTTP estándar. Su función principal es:
- Recibir y actualizar las solicitudes HTTP a conexiones WebSocket.
- Mantener un registro de todas las conexiones activas, que a menudo las mapea a "documentos" o "habitaciones" específicos.
- Recibir mensajes (p. ej., "el usuario A escribió 'hola'") de un cliente.
- Transmitir ese mensaje (o una versión de él) a todos los otros clientes que estén suscritos al mismo documento.
- Manejar la terminación de la conexión (desconexiones, "heartbeats").
- La Integración del Lado del Cliente: La aplicación del lado del cliente debe:
- Iniciar y establecer una conexión WebSocket (
(new WebSocket('wss://api.example.com')). - Escuchar las acciones del usuario local (p. ej.,
eventos de teclaen un editor de texto). - Serializar estas acciones en un formato de mensaje definido (p. ej., JSON) y enviarlas al servidor (
(ws.send(...)). - Escuchar mensajes del servidor (
(ws.onmessage). - Deserializar estos mensajes y aplicar los cambios recibidos al estado local del documento, reflejando las acciones de otros usuarios.
- Iniciar y establecer una conexión WebSocket (
Sección 1: La implementación del lado del servidor (Node.js)
Implementemos el centro de servidores. Utilizaremos Node.js y la popular ws para su rendimiento y simplicidad. Este servidor gestionará las conexiones y dirigirá los mensajes a "salas de documentos" específicas.
Desafío principal: Un único servidor debe gestionar muchas sesiones colaborativas distintas. No podemos simplemente transmitir cada mensaje a todos los clientes. Debemos segmentar las conexiones.
Implementación: Utilizaremos un mapa para almacenar los "espacios" del documento, donde cada espacio contiene un conjunto de clientes conectados (objetos WebSocket).
// server.js
const WebSocket = require('ws');
const http = require('http');
const url = require('url');
// We use a Map to store "rooms."
// Key: documentId (e.g., 'doc-123')
// Value: Set of connected WebSocket clients
const documentRooms = new Map();
// Create a standard HTTP server to handle the initial WebSocket upgrade
const server = http.createServer((req, res) => {
// This is where you would serve your main application
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('WebSocket server is running.');
});
const wss = new WebSocket.Server({ noServer: true });
server.on('upgrade', (request, socket, head) => {
// Parse the URL to get the document ID
const { pathname } = url.parse(request.url);
// Example URL: wss://api.example.com/documents/doc-123
const documentId = pathname.split('/')[2];
if (!documentId) {
socket.destroy();
return;
}
// Here, you MUST perform authentication/authorization
// e.g., check a JWT token from cookies or query params
// if (!isValidUser(request)) {
// socket.destroy();
// return;
// }
wss.handleUpgrade(request, socket, head, (ws) => {
// Add this client to the correct document room
if (!documentRooms.has(documentId)) {
documentRooms.set(documentId, new Set());
}
documentRooms.get(documentId).add(ws);
console.log(`Client connected to document: ${documentId}`);
// Handle incoming messages from this client
ws.on('message', (messageBuffer) => {
// We broadcast the raw message to all *other* clients in the same room
const clients = documentRooms.get(documentId);
if (clients) {
clients.forEach(client => {
if (client !== ws && client.readyState === WebSocket.OPEN) {
// Forward the message
client.send(messageBuffer);
}
});
}
});
// Handle client disconnect
ws.on('close', () => {
console.log(`Client disconnected from document: ${documentId}`);
const clients = documentRooms.get(documentId);
if (clients) {
clients.delete(ws);
// Clean up the room if it's empty
if (clients.size === 0) {
documentRooms.delete(documentId);
}
}
});
ws.on('error', (err) => {
console.error('WebSocket error:', err);
});
});
});
server.listen(8080, () => {
console.log('WebSocket server listening on port 8080');
});
Este servidor es unretransmisor de difusión. Es sencillo, rápido y básico. No comprende elcontenido de los mensajes; simplemente los envía a la sala correcta. Esta es una decisión de diseño deliberada y crucial, ya que delega el complejo problema de la sincronización de estado a los clientes.
Equipo de Ingeniería de Software Compartido Bajo Demanda, Por Suscripción.
Acceda a un equipo flexible y compartido de ingeniería de software bajo demanda a través de una suscripción mensual predecible. Desarrolladores, diseñadores, ingenieros de control de calidad y un gerente de proyecto gratuito le ayudan a crear MVPs, escalar productos e innovar con tecnologías modernas como React, Node.js y más.
Sección 2: El Problema Crítico: Sincronización de Estado
Si dos usuarios escriben al mismo tiempo, tenemos un conflicto.
- Usuario A (estado: "¡Hola!") escribe "!" al final. (Op:
insert(2, "!")) - Usuario B (estado: "¡Hola!") escribe "!" al final. (Op:
insert(2, "!"))
Ambos envían sus operaciones al servidor. El servidor las transmite. El Usuario A recibe la operación del Usuario B y la aplica. El Usuario B recibe la operación del Usuario A y la aplica.
Resultado: Ambos usuarios ven "¡Hola!!". El estado del documento ha divergido y ahora está corrupto.
Este es el principal desafío de los sistemas colaborativos. La solución tradicional, Transformación Operacional (OT), es notoriamente compleja de implementar correctamente. Implica la creación de una función de transformación en el servidor que ajusta matemáticamente las operaciones entrantes en función de las que se hayan aplicado previamente.
Una solución más moderna y pragmática es utilizar Tipos de datos replicados sin conflictos (CRDTs).
CRDTsson estructuras de datos diseñadas para ser modificadas simultáneamente por múltiples clientes y luego fusionadas, con una convergencia matemática garantizada al mismo estado. Están diseñadas específicamente para este problema.
Yjs es la biblioteca de CRDT de código abierto líder para la creación de aplicaciones colaborativas. Utilizaremos esta biblioteca para diseñar nuestro sistema.
Con Yjs, el papel de nuestro servidor sigue siendo un simple centro de difusión. La lógica principal se desplaza al cliente.
- Cada cliente mantiene un documento local de Yjs (
Y.Doc). - Cuando un usuario escribe, modifica su documento local
Y.Doc. - El
Y.Docgenera un mensaje binario pequeño "update message" que describe el cambio. - Enviamos este mensaje binario de actualización a través de WebSocket.
- El servidor transmite este mensaje binario de actualización (que no entiende) a todos los demás clientes de la sala.
- Otros clientes reciben el mensaje binario de actualización y lo aplican a su documento local
Y.Doc(Y.applyUpdate(...)).
Dado que Yjs es un CRDT, el orden en que se reciben las actualizaciones no importa. El estado siempre convergerá.
Sección 3: Implementación en el lado del cliente con Yjs
Aquí se explica cómo configurar el JavaScript del lado del cliente, integrando un WebSocket con Yjs y un editor de texto (como el editor Quill.
// client.js
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { QuillBinding } from 'y-quill';
import Quill from 'quill';
import 'quill/dist/quill.snow.css';
// 1. Get the document ID (e.g., from the URL)
const documentId = 'doc-123'; // Example
// 2. Create the Yjs document
const ydoc = new Y.Doc();
// 3. Connect to the WebSocket server using the Yjs provider
// This provider handles all the complex WebSocket logic for us.
// It connects, sends/receives updates, and handles reconnection.
const provider = new WebsocketProvider(
'wss://api.example.com/documents/', // Base URL
documentId, // Room/Document ID
ydoc // The Yjs document
);
// 4. Get the shared data type for text
// 'quill' is just a name for this piece of shared data
const ytext = ydoc.getText('quill');
// 5. Initialize the Quill editor
const editorContainer = document.querySelector('#editor');
const quill = new Quill(editorContainer, {
theme: 'snow',
placeholder: 'Start collaborating...',
});
// 6. Bind the Yjs shared text type to the Quill editor
// This is the magic. The binding automatically syncs:
// - Local Quill changes -> to the Y.Doc
// - Remote Y.Doc changes -> to the Quill editor
const binding = new QuillBinding(ytext, quill);
// 7. Optional: Observe connection status
provider.on('status', event => {
console.log(`WebSocket connection status: ${event.status}`);
// You can update the UI (e.g., "Connecting...", "Connected")
});
Al utilizar el proveedor y-websocketproveedor, ni siquiera necesitamos escribir ellógica de WebSocket (new WebSocket(...))ows.onmessage.
Nota: Esto requiere que nuestro servidor de la Sección 1 sea compatible con el protocolo y-websocket, que simplemente transmite mensajes a otros clientes en la sala. Nuestro servidor es compatible. Hemos delegado con éxito todas las resoluciones de conflictos al cliente.
Sección 4: Arquitectura de Producción: Escalabilidad y Persistencia
Nuestro servidor de una sola unidad, según la Sección 1, fallará bajo carga. Tiene dos limitaciones principales:
- Límite de escalado vertical: Un único proceso de Node.js solo puede manejar un número finito de conexiones WebSocket simultáneas (de decenas de miles, típicamente).
- Sin estado: Si el servidor se reinicia, se pierde todo el estado de la conexión. Más importante aún, el estado del documento solo se almacena en la memoria de los clientes. Un nuevo cliente que se una no tendrá ningún historial de documentos.
Escalar con una arquitectura de comunicación Pub/Sub
Para escalar horizontalmente, debemos ejecutar múltiples instancias de nuestro servidor WebSocket. Sin embargo, si el Usuario A está en el Servidor 1 y el Usuario B está en el Servidor 2, no podrán comunicarse.
La solución es un sistema de comunicación Pub/Sub, que normalmente utiliza Redis.
- Cliente A envía un mensaje a Servidor 1.
- El Servidor 1 recibe el mensaje. En lugar de simplemente enviarlo a sus clientes locales, también publica
el mensaje en un canal de Redis (por ejemplo,doc-123 - ). El Servidor 1 y el Servidor 2 (y todas las demás instancias) están
suscritasal canal de Redis - doc-123
- .Ambos servidores reciben el mensaje desde Redis.Cada servidor luego transmite el mensaje a su
Esto desacopla los servidores y permite una escalabilidad horizontal prácticamente infinita. La biblioteca y-websockety-websocket tiene un componente en el lado del servidor
Resolver para la persistencia
Todavía necesitamos guardar el documento. Yjs proporciona utilidades para esto.
Estrategia: El servidor debe encargarse de la persistencia.
- Carga bajo demanda: Cuando el primer cliente se une a una sala vacía (por ejemplo, documentRooms.get('doc-123') se ha creado recientemente), el servidor debe:a. Cargar el último estado del documento Yjs desde una base de datos (por ejemplo, PostgreSQL, S3 o una base de datos de documentos).b. Instanciar un Y.Doc en el servidor.c. Cuando se conectan nuevos clientes, el servidor les envía el estado completo actual del documento.
- Guardado periódico/al cambiar: El servidor, que ahora también participa en la sesión Yjs (a través del servidor y-websocket), escucha los cambios del documento.a. Puede guardar el estado completo del documento (un blob binario) en la base de datos periódicamente (por ejemplo, cada 5 segundos).b. Alternativamente, puede añadir "mensajes de actualización" a un registro, lo que es más complejo pero permite la recuperación en un momento específico.
Utilizar una biblioteca como y-leveldb o y-indexeddb (en el servidor a través de LevelDB) puede gestionar esta capa de persistencia de forma eficiente.
Conclusión
Construir la colaboración en tiempo real es un importante desafío arquitectónico. Al utilizar WebSockets para la capa de transporte, obtenemos un canal de comunicación persistente y de baja latencia. Sin embargo, el verdadero desafío radica en la gestión del estado.
Intentar construir Transformación Operacional (TO) desde cero es un proyecto de alto riesgo y alto costo.
Un enfoque moderno, práctico y sólido es:
- Utilice WebSockets para el protocolo de transporte.
- Implemente un centro de difusión en el servidor que segmenta las conexiones por documento/sala.Delegue toda la sincronización de estado y la resolución de conflictos a una que segmenta las conexiones según documento/sala.
- Delegar toda la sincronización y la resolución de conflictos a unbiblioteca CRDT como Yjs en el cliente.
- Escalifique el servidor horizontalmente utilizando un Redis Pub/Sub para transmitir mensajes a través de todas las instancias del servidor.
- Implemente la persistencia en el servidor cargando/guardando el estado del documento Yjs de una base de datos bajo demanda o periódicamente.
Esta arquitectura minimiza la complejidad en el lado del servidor, desplaza la inteligencia hacia el borde y aprovecha bibliotecas de código abierto probadas para resolver el problema más difícil: la convergencia en un estado sin conflictos.
Equipo de ingeniería de software compartido bajo demanda, mediante suscripción.
Acceda a un equipo flexible y compartido de ingeniería de software bajo demanda a través de una suscripción mensual predecible. Desarrolladores, diseñadores, ingenieros de control de calidad y un gerente de proyecto gratuito le ayudan a crear MVPs, escalar productos e innovar con tecnologías modernas como React, Node.js y más.
Preguntas frecuentes
¿Cuáles son las ventajas de utilizar WebSockets para la colaboración en tiempo real en comparación con HTTP tradicional?
WebSockets proporcionan una conexión bidireccional y persistente entre el cliente y el servidor, lo cual es esencial para la colaboración en tiempo real. A diferencia de HTTP tradicional, que requiere un ciclo de solicitud-respuesta para cada actualización (polling), WebSockets permiten que los datos fluyan instantáneamente en ambas direcciones. Esto reduce significativamente la latencia y la carga del servidor, garantizando que las actualizaciones (como un compañero de trabajo escribiendo en un documento o enviando un mensaje) se reflejen inmediatamente en todos los usuarios conectados.
¿Cómo maneja una herramienta basada en WebSocket la sincronización de datos entre múltiples usuarios?
La sincronización de datos se logra a través de un sistema de transmisión basado en eventos. Cuando un usuario realiza una acción (como editar una línea de código o dibujar en un lienzo), el cliente envía un mensaje al servidor a través de la conexión WebSocket. El servidor, a continuación, transmite esta actualización a todos los demás clientes activos. Para herramientas más complejas, los desarrolladores a menudo implementan estrategias de resolución de conflictos, como la Transformación Operacional (OT) o los Tipos de Datos Replicados sin Conflicto (CRDTs), para garantizar que las modificaciones concurrentes de diferentes usuarios no se anulen entre sí.
¿Qué tecnologías son las más adecuadas para construir una aplicación colaborativa en tiempo real?
Para construir una herramienta robusta en tiempo real, una pila tecnológica común incluye Node.js para el backend debido a su E/S no bloqueante, y Socket.io, una biblioteca que simplifica la implementación de WebSocket proporcionando reconexión automática y comunicación basada en salas. En el frontend, los frameworks modernos como React o Vue se utilizan comúnmente para gestionar los estados de la interfaz de usuario dinámicos. Además, el uso de un protocolo de conexión seguro (WSS) es fundamental para proteger los datos sensibles transmitidos durante las sesiones colaborativas.