Construyendo colaboración en tiempo real con WebSockets para directores de tecnología (CTO)

Share
Construyendo colaboración en tiempo real con WebSockets para directores de tecnología (CTO)

La demanda de experiencias colaborativas en tiempo real, similares a Google Docs, ya no es una característica—es una expectativa. Para los directores técnicos y líderes de ingeniería, diseñar un sistema así presenta un conjunto único de desafíos que difieren 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 plan técnico para construir una herramienta de colaboración robusta y en tiempo real. Nos centraremos en los componentes arquitectónicos clave necesarios para un sistema de nivel profesional, incluyendo la gestión de conexiones, la sincronización del estado y la escalabilidad horizontal. Principalmente nos centraremos en implementar 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 expertos, diseñadores, ingenieros de QA y un gerente de proyecto gratuito le ayudan a construir MVPs, escalar productos e innovar con tecnologías modernas como React, Node.js y más.

Try 4Geeks Teams

Arquitectura principal: El "WebSocket Hub" y el "Client"

En esencia, el sistema consta de dos partes principales: un servidor central ("el centro") que gestiona las conexiones y transmite datos, y múltiples clientes (navegadores) que mantienen una conexión WebSocket persistente con ese centro.

  1. El Centro de Servidor: Esto no es un servidor HTTP estándar. Su función principal es:
    • Aceptar y actualizar solicitudes HTTP a conexiones WebSocket.
    • Mantener un registro de todas las conexiones activas, a menudo mapeándolas a documentos o "habitaciones" específicos.
    • Recibir mensajes (p. ej., "el usuario A escribió 'hola'") de un cliente.
    • Transmitir ese mensaje (o una derivación) a todos losotros clientes suscritos al mismo documento.
    • Manejar la terminación de la conexión (desconexiones, señales de latencia).
  2. 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 locales del usuario (p. ej.,eventos keyup en 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 deotros usuarios.

Sección 1: La implementación en el lado del servidor (Node.js)

Implementemos el centro de servidor. Utilizaremos Node.js y la popular ws para su rendimiento y simplicidad. Este servidor gestionará las conexiones y enviará los mensajes a las "salas de documentos" específicas.

El principal desafío: 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" de documentos, 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 un retransmisor. Es sencillo, rápido y básico. No comprende el contenido de los mensajes; simplemente los envía a la sala adecuada. Esta es una decisión de diseño deliberada y crucial, ya que delega el complejo problema de la sincronización del estado a los clientes.

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 expertos, diseñadores, ingenieros de control de calidad y un gestor de proyectos gratuito le ayudan a crear MVPs, escalar productos e innovar con tecnologías modernas como React, Node.js y más.

Try 4Geeks Teams

Sección 2: El problema crítico: Sincronización de estados

Si dos usuarios escriben al mismo tiempo, existe un conflicto.

  • Usuario A (estado: "¡Hola!") escribe "!" al final. (Op: <s2>insert(2, "!")insert(2, "!")
  • Usuario B (estado: "¡Hola!") escribe "!" al final. (Op: <s6>insert(2, "!")insert(2, "!")

Ambos envían sus operaciones al servidor. El servidor las transmite. El usuario A recibe la operación de B y la aplica. El usuario B recibe la operación de A y la aplica.

Resultado: Ambos usuarios ven "¡Hola!!". El estado del documento ha divergido y ahora está dañado.

Este es el principal desafío de los sistemas colaborativos. La solución tradicional, Transformación Operacional (OT), es notoriously compleja de implementar correctamente. Implica crear una función de transformación en el lado del servidor que ajusta matemáticamente las operaciones entrantes basándose en las aplicadas previamente.

Una solución más moderna y pragmática es utilizar Tipos de Datos Replicados sin Conflictos (CRDTs)..

Los CRDTsson estructuras de datos diseñadas para ser modificadas simultáneamente por múltiples usuarios y luego fusionadas, con una convergencia matemática garantizada al mismo estado. Están diseñadas específicamente para este problema.

Yjses la biblioteca de código abierto CRDT líder para el desarrollo 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 traslada al cliente.real lógica

  1. Cada cliente mantiene un documento Yjs local (Y.Doc)
  2. .Cuando un usuario escribe, modifica su propio Y.Doc
  3. .El Y.Doc
  4. genera un pequeño mensaje binario "update" que describe el cambio.
  5. Enviamos este mensaje binario de actualización a través de WebSocket.
  6. El servidor transmite este mensaje binario de actualización (que no entiende) a todos los demás clientes en la sala.Otros clientes reciben el mensaje binario y lo aplican a su documento local Y.Doc (mediante Y.applyUpdate(...).

Porque Yjs es un CRDT (Tipo de Dato Recreativo), 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 implementar el código 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 elnew WebSocket(...)ows.onmessagegestionamos nosotros mismos la lógica. El proveedor se encarga del empaquetado de las actualizaciones de Yjs, de enviarlas y de aplicar las actualizaciones recibidas.

Nota: Esto requiere que nuestro servidor de la Sección 1 sea compatible con el protocolo y-websocketes compatible. Hemos delegado con éxito todas las resoluciones de conflictos al cliente.compatible. Hemos delegado con éxito la resolución de todos los conflictos al cliente.

Sección 4: Arquitectura de producción: Escalabilidad y persistencia

Nuestro servidor de un solo nodo, según la Sección 1, fallará bajo carga. Tiene dos limitaciones principales:

  1. Límite de escalado vertical: Un único proceso Node.js solo puede gestionar un número finito de conexiones WebSocket concurrentes (decenas de miles, normalmente).
  2. 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 bus centralizado (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 una red de comunicación Pub/Sub, que normalmente utiliza Redis.

  1. Cliente A envía un mensaje a Servidor 1.
  2. El Servidor 1 recibe el mensaje. En lugar de simplemente transmitirlo a sus clientes locales, también publica el mensaje en un canal Redis (por ejemplo, doc-123).
  3. El Servidor 1 y el Servidor 2 (y todas las demás instancias) están suscriptos al canal doc-123.
  4. Ambos servidores reciben el mensaje de Redis.
  5. Cada servidor luego transmite el mensaje a su propio conjunto de clientes WebSocket conectados.

Esto desacopla los servidores y permite una escalabilidad horizontal prácticamente ilimitada. La biblioteca y-websockety-websocket tiene un componente del lado del servidor

Resolver para la persistencia

Todavía necesitamos guardar el documento. Yjs proporciona herramientas para ello.

Estrategia: El servidor debe encargarse del almacenamiento de datos.

  1. Carga bajo demanda: Cuando el primer cliente se une a una sala vacía (por ejemplo, documentRooms.get('doc-123') acaba de ser creado), 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 lado del servidor.c. Cuando se conectan nuevos clientes, el servidor les envía el estado completo actual del documento.
  2. Guardado periódico/al cambio: El servidor, que ahora también participa en la sesión Yjs (a través del servidor de 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 agregar "mensajes de actualización" a un registro, lo cual es más complejo pero permite la recuperación en tiempo real.

Utilizar una biblioteca como y-leveldb o y-indexeddb (en el servidor a través de LevelDB) puede gestionar esta capa de persistencia de manera 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 reto 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:

  1. Utilice WebSockets para el protocolo de transporte.
  2. Implemente un centro de difusión del lado del servidor que segmenta las conexiones por documento/sala.Delegue toda la sincronización y resolución de conflictos a una
  3. Delegar todas las tareas de sincronización y resolución de conflictos a unbiblioteca CRDT como Yjs en el cliente.
  4. Escalifique el servidor horizontalmente utilizando un Redis Pub/Sub para transmitir mensajes a través de todas las instancias del servidor.
  5. Implemente la persistenciaen el servidor, cargando y guardando el estado del documento de Yjs desde una base de datos según sea necesario o periódicamente.

Esta arquitectura minimiza la complejidad del lado del servidor, desplaza el procesamiento hacia los dispositivos y utiliza bibliotecas de código abierto probadas para resolver el problema más difícil: alcanzar un estado coherente sin conflictos.

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 mediante 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.

Try 4Geeks Teams

Preguntas frecuentes

¿Cuáles son las ventajas de utilizar WebSockets para la colaboración en tiempo real frente a HTTP tradicional?

WebSockets proporcionan una conexión bidireccional y persistente entre el cliente y el servidor, lo que es esencial para la colaboración en tiempo real. A diferencia de HTTP tradicional, que requiere un nuevo 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 cuando un compañero escribe en un documento o envía 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 sobre 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 (CRDT), 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 crear una herramienta robusta y en tiempo real, un conjunto de tecnologías común incluye Node.js para el backend debido a su I/O no bloqueante, y Socket.io, una librería que simplifica la implementación de WebSocket proporcionando reconexión automática y comunicación basada en salas. En el frontend, se suelen utilizar frameworks modernos como React o Vue para gestionar los estados dinámicos de la interfaz de usuario. Además, el uso de un protocolo de conexión seguro (WSS) es fundamental para proteger los datos sensibles transmitidos durante las sesiones colaborativas.

Read more