Совместное редактирование

Совместное редактирование представляет собой механизм синхронизации изменений между несколькими клиентами в режиме реального времени. Браузерные приложения подключаются к брокеру сообщений через WebSocket и обмениваются событиями изменения документа.

Типичная схема взаимодействия:

Пользователь A
    ↓
STOMP.js Client
    ↓
WebSocket
    ↓
Message Broker
    ↓
Другие подписчики
    ↓
Пользователь B, C, D

Каждое изменение документа передаётся как отдельное сообщение. Остальные клиенты получают событие и обновляют локальное состояние интерфейса.

Для организации совместного редактирования обычно используются:

  • RabbitMQ
  • ActiveMQ
  • Spring WebSocket Broker
  • Apollo
  • Node.js STOMP brokers

Установка библиотеки

Через npm:

npm install @stomp/stompjs

Подключение:

import { Client } from '@stomp/stompjs';

Создание STOMP-клиента

Базовая конфигурация:

import { Client } from '@stomp/stompjs';

const client = new Client({
    brokerURL: 'ws://localhost:8080/ws',

    reconnectDelay: 5000,

    heartbeatIncoming: 4000,
    heartbeatOutgoing: 4000
});

client.activate();

Ключевые параметры:

Параметр Назначение
brokerURL Адрес WebSocket-сервера
reconnectDelay Автопереподключение
heartbeatIncoming Проверка активности сервера
heartbeatOutgoing Проверка активности клиента

Модель данных совместного редактирования

Изменение документа обычно представляется в виде операции:

{
    type: 'ins ert',
    position: 15,
    val ue: 'Hello'
}

Либо:

{
    type: 'delete',
    start: 10,
    end: 20
}

Расширенная структура:

{
    documentId: 'doc-1',
    userId: 'user-15',
    version: 42,
    operation: {
        type: 'ins ert',
        position: 120,
        val ue: 'Text'
    },
    timestamp: Date.now()
}

Подключение к документу

После соединения клиент подписывается на канал документа.

client.onConn ect = () => {

    client.subscribe('/topic/document/1', (message) => {

        const payload = JSON.parse(message.body);

        applyRemoteOperation(payload);
    });

};

Топики могут формироваться динамически:

const topic = `/topic/document/${documentId}`;

Отправка изменений

Каждое локальное изменение отправляется через publish.

function sendOperation(operation) {

    client.publish({
        destination: '/app/document/edit',
        body: JSON.stringify(operation)
    });

}

Пример:

sendOperation({
    documentId: '1',
    type: 'ins ert',
    position: 5,
    val ue: 'A'
});

Синхронизация текстового поля

Простейшая реализация:

const editor = document.querySelector('#editor');

editor.addEventListener('input', () => {

    const operation = {
        documentId: '1',
        content: editor.value
    };

    client.publish({
        destination: '/app/document/update',
        body: JSON.stringify(operation)
    });

});

Получение обновлений:

client.subscribe('/topic/document/1', (message) => {

    const data = JSON.parse(message.body);

    editor.value = data.content;

});

Подобный подход подходит только для небольших демонстрационных проектов, поскольку при каждом изменении передаётся весь документ.


Инкрементальные изменения

Гораздо эффективнее передавать только операции изменения.

Пример вставки:

{
    type: 'ins ert',
    position: 10,
    text: 'Hello'
}

Пример удаления:

{
    type: 'remove',
    start: 5,
    end: 15
}

Преимущества:

  • минимальный сетевой трафик;
  • меньше нагрузка на брокер;
  • лучшая масштабируемость;
  • ниже задержки;
  • упрощение конфликтного разрешения.

Применение удалённых изменений

Функция вставки:

function applyInsert(textarea, operation) {

    const val ue = textarea.value;

    textarea.value =
        value.slice(0, operation.position) +
        operation.text +
        value.slice(operation.position);

}

Удаление:

function applyDelete(textarea, operation) {

    const value = textarea.value;

    textarea.value =
        value.slice(0, operation.start) +
        value.slice(operation.end);

}

Обработка операций:

function applyRemoteOperation(operation) {

    switch (operation.type) {

        case 'ins ert':
            applyInsert(editor, operation);
            break;

        case 'remove':
            applyDelete(editor, operation);
            break;

    }

}

Исключение собственных сообщений

Без дополнительной логики клиент может повторно применять собственные изменения.

Решение — использовать идентификатор пользователя.

const currentUserId = crypto.randomUUID();

Отправка:

client.publish({
    destination: '/app/document/edit',
    body: JSON.stringify({
        userId: currentUserId,
        operation
    })
});

Фильтрация:

client.subscribe('/topic/document/1', (message) => {

    const data = JSON.parse(message.body);

    if (data.userId === currentUserId) {
        return;
    }

    applyRemoteOperation(data.operation);

});

Обработка конфликтов

Главная проблема совместного редактирования — конфликты изменений.

Пример:

Пользователь A вставляет символ на позиции 5
Пользователь B удаляет символ на позиции 5

Возможные решения:

  • OT (Operational Transformation)
  • CRDT
  • Versioning
  • Server Authority

Operational Transformation

OT трансформирует операции относительно других операций.

Пример:

A: insert(5, "X")
B: delete(5)

После трансформации:

delete(6)

STOMP.js не реализует OT самостоятельно, но отлично подходит как транспортный слой.

Схема:

Редактор → OT Engine → STOMP.js → Сервер

Популярные OT-движки:

  • ShareDB
  • ProseMirror collaborative editing
  • CKEditor Collaboration
  • TinyMCE RTC

Использование версий документа

Каждая операция содержит номер версии.

{
    version: 18,
    operation: {
        type: 'insert',
        position: 25,
        text: 'A'
    }
}

Сервер проверяет:

clientVersion === serverVersion

Если версии различаются:

  • операция отклоняется;
  • выполняется повторная синхронизация;
  • запускается трансформация.

Запрос полной синхронизации

При рассинхронизации клиент может запросить полный документ.

client.publish({
    destination: '/app/document/sync',
    body: JSON.stringify({
        documentId: '1'
    })
});

Ответ:

client.subscribe('/user/queue/document-sync', (message) => {

    const data = JSON.parse(message.body);

    editor.val ue = data.content;

});

Персональные очереди

STOMP поддерживает персональные каналы.

Пример:

client.subscribe('/user/queue/errors', callback);

Использование:

  • ошибки синхронизации;
  • уведомления;
  • приватные данные;
  • восстановление состояния.

Индикатор присутствия пользователей

Для совместного редактирования важна информация о присутствии участников.

Отправка heartbeat-событий:

setInterval(() => {

    client.publish({
        destination: '/app/presence',
        body: JSON.stringify({
            userId: currentUserId,
            documentId: '1'
        })
    });

}, 5000);

Подписка:

client.subscribe('/topic/presence/1', (message) => {

    const user = JSON.parse(message.body);

    updatePresence(user);

});

Курсоры других пользователей

Передача позиции курсора:

editor.addEventListener('keyup', () => {

    client.publish({
        destination: '/app/cursor',
        body: JSON.stringify({
            userId: currentUserId,
            position: editor.selectionStart
        })
    });

});

Получение:

client.subscribe('/topic/cursor/1', (message) => {

    const data = JSON.parse(message.body);

    renderCursor(data);

});

Debounce для снижения нагрузки

Нельзя отправлять сообщение на каждый символ без ограничений.

Используется debounce:

function debounce(callback, delay) {

    let timeout;

    return (...args) => {

        clearTimeout(timeout);

        timeout = setTimeout(() => {
            callback(...args);
        }, delay);

    };

}

Применение:

const sendChanges = debounce((operation) => {

    client.publish({
        destination: '/app/document/edit',
        body: JSON.stringify(operation)
    });

}, 100);

Пакетная отправка операций

Вместо отдельных сообщений:

ins ert
insert
insert

Можно отправлять массив:

{
    operations: [
        { type: 'insert', text: 'H' },
        { type: 'insert', text: 'e' },
        { type: 'insert', text: 'y' }
    ]
}

Преимущества:

  • меньше сообщений;
  • ниже нагрузка;
  • эффективнее работа брокера.

ACK и подтверждение доставки

Для критически важных изменений используется подтверждение доставки.

Подписка:

client.subscribe('/topic/document/1', callback, {
    ack: 'client'
});

Подтверждение:

client.subscribe('/topic/document/1', (message) => {

    const operation = JSON.parse(message.body);

    applyRemoteOperation(operation);

    message.ack();

}, {
    ack: 'client'
});

Транзакции

STOMP поддерживает транзакции.

const tx = client.begin();

Отправка:

client.publish({
    destination: '/app/document/edit',
    body: JSON.stringify(operation),
    transaction: tx.id
});

Подтверждение:

tx.commit();

Отмена:

tx.abort();

Транзакции полезны при:

  • массовых изменениях;
  • импорте текста;
  • откате операций;
  • групповых обновлениях.

Восстановление соединения

Потеря WebSocket-соединения — обычная ситуация.

Автоматическое восстановление:

const client = new Client({

    brokerURL: 'ws://localhost:8080/ws',

    reconnectDelay: 3000

});

Дополнительная логика:

client.onWebSocketCl ose = () => {

    showOfflineIndicator();

};

После восстановления:

client.onConn ect = () => {

    requestFullSync();

};

Очередь офлайн-операций

Во время отсутствия соединения операции могут накапливаться локально.

const pendingOperations = [];

Добавление:

function queueOperation(operation) {

    pendingOperations.push(operation);

}

Повторная отправка:

function flushQueue() {

    while (pendingOperations.length) {

        const operation = pendingOperations.shift();

        client.publish({
            destination: '/app/document/edit',
            body: JSON.stringify(operation)
        });

    }

}

Оптимизация производительности

Ключевые методы оптимизации:

Минимизация payload

Плохо:

{
    content: entireDocument
}

Хорошо:

{
    type: 'insert',
    position: 10,
    text: 'A'
}

Сжатие данных

Можно использовать:

  • gzip;
  • permessage-deflate;
  • бинарные форматы;
  • protobuf.

Ограничение частоты сообщений

Используются:

  • debounce;
  • throttle;
  • batching.

Локальное применение изменений

Сначала операция применяется локально:

applyLocalOperation(operation);

И только затем отправляется:

sendOperation(operation);

Это уменьшает ощущение задержки интерфейса.


Безопасность

Совместное редактирование требует строгой авторизации.

Передача токена:

const client = new Client({

    brokerURL: 'ws://localhost:8080/ws',

    connectHeaders: {
        Authorization: 'Bearer token'
    }

});

На сервере проверяются:

  • права доступа;
  • принадлежность документа;
  • ограничения ролей;
  • валидность операций.

Валидация операций

Нельзя доверять входящим сообщениям.

Пример проверки:

function validateOperation(operation) {

    if (typeof operation.position !== 'number') {
        return false;
    }

    if (typeof operation.text !== 'string') {
        return false;
    }

    return true;

}

Логирование изменений

Совместное редактирование часто требует аудита.

Пример события:

{
    userId: '15',
    documentId: '1',
    operation: 'insert',
    timestamp: 1710000000
}

Использование:

  • история изменений;
  • откат;
  • аналитика;
  • журнал активности.

Реализация undo/redo

Операции можно хранить в стеке.

const undoStack = [];
const redoStack = [];

Добавление:

undoStack.push(operation);

Отмена:

const operation = undoStack.pop();

Инверсия операции:

function invert(operation) {

    if (operation.type === 'insert') {

        return {
            type: 'remove',
            start: operation.position,
            end: operation.position + operation.text.length
        };

    }

}

Масштабирование системы

При большом количестве пользователей появляются дополнительные задачи:

  • горизонтальное масштабирование брокеров;
  • распределение документов;
  • балансировка нагрузки;
  • sharding каналов;
  • кеширование состояний.

Разделение каналов

Плохо:

/topic/all-documents

Хорошо:

/topic/document/1
/topic/document/2
/topic/document/3

Это уменьшает объём ненужного трафика.


Серверная агрегация

Сервер может объединять операции:

insert A
insert B
insert C

В одно сообщение:

insert ABC

Подобная оптимизация особенно важна при высокой скорости печати.


Интеграция с редакторами

STOMP.js часто используется вместе с:

  • Quill;
  • CodeMirror;
  • Monaco Editor;
  • ProseMirror;
  • CKEditor;
  • TinyMCE.

Пример для CodeMirror:

editor.on('change', (instance, change) => {

    client.publish({
        destination: '/app/document/edit',
        body: JSON.stringify(change)
    });

});

Сериализация операций

Операции должны быть компактными.

Хорошо:

{
    t: 'i',
    p: 10,
    v: 'A'
}

Плохо:

{
    operationType: 'insert',
    insertionPosition: 10,
    insertedVal ue: 'A'
}

Мониторинг соединения

STOMP.js предоставляет callback-события.

Ошибки STOMP:

client.onStompEr ror = (frame) => {

    console.error(frame.headers.message);

};

Ошибки WebSocket:

client.onWebSocketEr ror = (event) => {

    console.error(event);

};

Архитектура production-системы

Типичная схема production-решения:

Browser
    ↓
STOMP.js
    ↓
WebSocket Gateway
    ↓
Broker Cluster
    ↓
Collaboration Service
    ↓
Database

Дополнительно используются:

  • Redis;
  • Kafka;
  • ElasticSearch;
  • OT/CRDT движки;
  • сервисы presence;
  • распределённые очереди.

Частые проблемы

Дублирование операций

Причины:

  • повторное подключение;
  • отсутствие idempotency;
  • повторная отправка.

Решение:

{
    operationId: crypto.randomUUID()
}

Потеря порядка сообщений

Решение:

  • sequence numbers;
  • versioning;
  • server ordering.

Зацикливание обновлений

Решение:

let suppressChanges = false;

Пример:

if (suppressChanges) {
    return;
}

Перегрузка брокера

Причины:

  • слишком частые сообщения;
  • отсутствие batching;
  • крупные payload.

Решения:

  • debounce;
  • compression;
  • aggregation;
  • throttling.

Пример полной структуры операции

{
    operationId: 'op-100',
    documentId: 'doc-1',
    userId: 'user-15',

    version: 42,

    type: 'insert',

    position: 120,

    text: 'Hello',

    timestamp: Date.now()
}

Подобная структура обеспечивает:

  • отслеживание изменений;
  • разрешение конфликтов;
  • аудит;
  • синхронизацию;
  • масштабирование;
  • устойчивость к сбоям.