Чат в реальном времени

Библиотека STOMP.js применяется для построения систем обмена сообщениями поверх WebSocket. Наиболее распространённый сценарий — реализация чатов, уведомлений, систем поддержки, совместного редактирования документов и панелей мониторинга.

В основе лежит протокол STOMP (Simple Text Oriented Messaging Protocol), который предоставляет поверх WebSocket полноценную модель обмена сообщениями:

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

Типичная схема работы реального чата выглядит следующим образом:

Браузер
   ↓
STOMP.js
   ↓
WebSocket
   ↓
STOMP Broker
   ↓
Другие клиенты

В роли брокера могут выступать:

  • RabbitMQ;
  • ActiveMQ;
  • Spring WebSocket;
  • Apollo;
  • HornetQ;
  • Artemis.

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

Современная версия STOMP.js распространяется через пакет @stomp/stompjs.

Установка через npm:

npm install @stomp/stompjs

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

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

При использовании SockJS:

npm install sockjs-client
import SockJS from 'sockjs-client';

Базовое подключение к WebSocket

Минимальная конфигурация клиента:

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

const client = new Client({
    brokerURL: 'ws://localhost:8080/chat',
    reconnectDelay: 5000
});

client.activate();

Параметр brokerURL содержит адрес WebSocket endpoint.

Параметр reconnectDelay активирует автоматическое переподключение при потере соединения.


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

Некоторые серверы не поддерживают чистый WebSocket или работают за прокси, ограничивающими апгрейд соединения. В таких случаях применяется SockJS.

Пример:

import { Client } from '@stomp/stompjs';
import SockJS from 'sockjs-client';

const client = new Client({
    webSocketFactory: () => new SockJS('http://localhost:8080/chat'),
    reconnectDelay: 5000
});

client.activate();

webSocketFactory вызывается при каждом переподключении.


Аутентификация пользователя

Чаты почти всегда требуют идентификации клиента.

STOMP поддерживает передачу заголовков подключения:

const client = new Client({
    brokerURL: 'ws://localhost:8080/chat',
    connectHeaders: {
        login: 'user',
        passcode: 'password',
        Authorization: 'Bearer JWT_TOKEN'
    }
});

На сервере заголовки могут использоваться для:

  • JWT-аутентификации;
  • проверки сессии;
  • идентификации комнаты;
  • проверки ролей пользователя.

Событие успешного подключения

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

Именно здесь обычно выполняются:

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

Пример:

client.onConn ect = () => {
    console.log('Подключение установлено');
};

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

Чат строится вокруг подписок.

Пример подписки на общий канал:

client.onConn ect = () => {

    client.subscribe('/topic/chat', message => {
        console.log(message.body);
    });

};

message.body содержит строковое тело сообщения.


Формат сообщений

Чаще всего используется JSON.

Отправка:

client.publish({
    destination: '/app/chat',
    body: JSON.stringify({
        author: 'Alex',
        text: 'Привет',
        time: Date.now()
    })
});

Получение:

client.subscribe('/topic/chat', message => {

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

    console.log(data.author);
    console.log(data.text);

});

Структура сообщения чата

Практически полезная структура:

{
    id: 'msg-101',
    roomId: 'general',
    author: 'Alex',
    text: 'Сообщение',
    createdAt: 1710000000,
    edited: false,
    attachments: [],
    type: 'MESSAGE'
}

Дополнительные типы сообщений:

{
    type: 'JOIN'
}
{
    type: 'LEAVE'
}
{
    type: 'TYPING'
}
{
    type: 'READ'
}

Комнаты чата

Для разделения сообщений применяются отдельные топики.

Подписка:

client.subscribe('/topic/room/general', callback);

Отправка:

client.publish({
    destination: '/app/room/general',
    body: JSON.stringify(message)
});

Динамическое переключение комнат:

let currentSubscription = null;

function joinRoom(roomId) {

    if (currentSubscription) {
        currentSubscription.unsubscribe();
    }

    currentSubscription = client.subscribe(
        `/topic/room/${roomId}`,
        message => {
            console.log(message.body);
        }
    );

}

Личные сообщения

Приватные сообщения обычно маршрутизируются через персональные очереди.

Подписка:

client.subscribe('/user/queue/private', message => {

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

    console.log(data);

});

Отправка:

client.publish({
    destination: '/app/private',
    body: JSON.stringify({
        to: 'user2',
        text: 'Приватное сообщение'
    })
});

История сообщений

STOMP не хранит сообщения самостоятельно. История должна загружаться отдельно через HTTP API.

Типичный сценарий:

async function loadHistory(roomId) {

    const response = await fetch(`/api/rooms/${roomId}/messages`);

    return await response.json();

}

После загрузки истории подключается realtime-канал.


Избежание дублирования сообщений

При переподключениях возможно повторное получение сообщений.

Поэтому сообщениям назначаются уникальные идентификаторы.

Пример фильтрации:

const receivedMessages = new Set();

client.subscribe('/topic/chat', message => {

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

    if (receivedMessages.has(data.id)) {
        return;
    }

    receivedMessages.add(data.id);

    renderMessage(data);

});

Подтверждение доставки сообщений

STOMP поддерживает ACK/NACK.

Подписка:

client.subscribe(
    '/topic/chat',
    message => {

        try {

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

            processMessage(data);

            message.ack();

        } catch (e) {

            message.nack();

        }

    },
    {
        ack: 'client'
    }
);

Режимы подтверждения:

  • auto
  • client
  • client-individual

Индикатор набора текста

Частая функция современных чатов.

Отправка события:

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

    client.publish({
        destination: '/app/typing',
        body: JSON.stringify({
            roomId: 'general',
            user: 'Alex'
        })
    });

});

Получение:

client.subscribe('/topic/typing', message => {

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

    showTyping(data.user);

});

Presence-система

Presence показывает:

  • онлайн;
  • офлайн;
  • время последней активности.

Сообщение подключения:

client.publish({
    destination: '/app/presence',
    body: JSON.stringify({
        status: 'ONLINE'
    })
});

Подписка:

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

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

    updateUserStatus(data);

});

Heartbeat-механизм

Heartbeat предотвращает “зависшие” соединения.

Настройка:

const client = new Client({
    brokerURL: 'ws://localhost:8080/chat',
    heartbeatIncoming: 4000,
    heartbeatOutgoing: 4000
});

Параметры указываются в миллисекундах.


Автоматическое переподключение

При потере сети STOMP.js способен автоматически восстанавливать соединение.

const client = new Client({
    brokerURL: 'ws://localhost:8080/chat',
    reconnectDelay: 5000
});

Интервал переподключения:

5000 ms = 5 секунд

Обработка ошибок

Ошибка STOMP-протокола:

client.onStompEr ror = frame => {

    console.error(frame.headers.message);
    console.error(frame.body);

};

Ошибка WebSocket:

client.onWebSocketEr ror = error => {
    console.error(error);
};

Закрытие соединения:

client.onWebSocketCl ose = () => {
    console.log('Соединение закрыто');
};

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

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

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

Типичная архитектура:

Browser
   ↓
Load Balancer
   ↓
WebSocket Cluster
   ↓
Message Broker
   ↓
Database

Часто применяются:

  • Redis Pub/Sub;
  • RabbitMQ;
  • Kafka;
  • NATS.

RabbitMQ и STOMP.js

RabbitMQ поддерживает STOMP через отдельный плагин.

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

const client = new Client({
    brokerURL: 'ws://localhost:15674/ws',
    connectHeaders: {
        login: 'guest',
        passcode: 'guest'
    }
});

Подписка:

client.subscribe('/queue/chat', message => {
    console.log(message.body);
});

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

Батчинг сообщений

Вместо частой отправки маленьких сообщений:

[
    { text: '1' },
    { text: '2' },
    { text: '3' }
]

Ограничение DOM-операций

Плохой подход:

messages.forEach(renderMessage);

Лучший вариант:

const fragment = document.createDocumentFragment();

messages.forEach(message => {
    fragment.append(createMessageElement(message));
});

container.append(fragment);

Ограничение количества сообщений

Чат не должен бесконечно накапливать DOM-элементы.

Пример:

const MAX_MESSAGES = 100;

function trimMessages() {

    while (container.children.length > MAX_MESSAGES) {
        container.removeChild(container.firstChild);
    }

}

Защита от спама

Клиентская защита:

let lastMessageTime = 0;

function canSendMessage() {

    const now = Date.now();

    if (now - lastMessageTime < 1000) {
        return false;
    }

    lastMessageTime = now;

    return true;

}

Серверная защита обычно включает:

  • rate limiting;
  • flood protection;
  • CAPTCHA;
  • mute-систему;
  • блокировки IP.

Повторная отправка неподтверждённых сообщений

Иногда необходимо хранить локальную очередь.

const pendingMessages = [];

function sendMessage(message) {

    pendingMessages.push(message);

    client.publish({
        destination: '/app/chat',
        body: JSON.stringify(message)
    });

}

После подтверждения сообщение удаляется из очереди.


Работа с бинарными вложениями

STOMP ориентирован на текстовые данные, поэтому файлы обычно передаются отдельно через HTTP.

Схема:

1. Upload файла
2. Получение URL
3. Отправка URL через STOMP

Сообщение:

{
    text: 'Файл',
    attachment: {
        url: '/uploads/file.pdf',
        name: 'file.pdf'
    }
}

Шифрование соединения

Для production-среды используется только WSS.

const client = new Client({
    brokerURL: 'wss://example.com/chat'
});

Дополнительно применяются:

  • JWT;
  • CSRF-защита;
  • Origin validation;
  • TLS termination;
  • message signing.

Очистка ресурсов

При уничтожении интерфейса необходимо закрывать подписки.

const subscription = client.subscribe(
    '/topic/chat',
    callback
);

subscription.unsubscribe();

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

client.deactivate();

Интеграция с React

Базовый пример:

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

export default function Chat() {

    useEffect(() => {

        const client = new Client({
            brokerURL: 'ws://localhost:8080/chat'
        });

        client.onConn ect = () => {

            client.subscribe('/topic/chat', message => {
                console.log(message.body);
            });

        };

        client.activate();

        return () => {
            client.deactivate();
        };

    }, []);

}

Интеграция с Vue

Пример:

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

export default {

    mounted() {

        this.client = new Client({
            brokerURL: 'ws://localhost:8080/chat'
        });

        this.client.activate();

    },

    beforeUnmount() {

        this.client.deactivate();

    }

}

Интеграция с Bitrix

В Bitrix STOMP.js часто используется для:

  • уведомлений;
  • корпоративных чатов;
  • live-обновлений CRM;
  • мониторинга задач;
  • realtime-статусов.

Пример подключения внутри Bitrix-компонента:

BX.ready(() => {

    const client = new StompJs.Client({
        brokerURL: 'ws://localhost:8080/chat'
    });

    client.activate();

});

Отладка STOMP.js

Встроенный debug-режим:

const client = new Client({
    brokerURL: 'ws://localhost:8080/chat',
    debug: str => {
        console.log(str);
    }
});

Логи помогают анализировать:

  • CONNECT;
  • SUBSCRIBE;
  • SEND;
  • MESSAGE;
  • ACK;
  • ERROR.

Типичные проблемы

Потеря соединения при сворачивании вкладки

Мобильные браузеры ограничивают таймеры и сетевую активность.

Решения:

  • heartbeat;
  • reconnect;
  • visibility API;
  • background sync.

Дублирование подписок

Ошибка:

client.subscribe('/topic/chat', callback);
client.subscribe('/topic/chat', callback);

Следствие:

Одно сообщение приходит дважды

Утечки памяти

Причина:

setInterval(...)

без очистки при уничтожении компонента.


Неконтролируемый reconnect

Неправильная конфигурация может вызывать тысячи попыток подключения.

Рекомендуется:

reconnectDelay: 5000

вместо:

reconnectDelay: 1

Пример минимального realtime-чата

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

const client = new Client({
    brokerURL: 'ws://localhost:8080/chat',
    reconnectDelay: 5000
});

Подписка:

client.onConn ect = () => {

    client.subscribe('/topic/chat', message => {

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

        appendMessage(data);

    });

};

Отправка:

function send(text) {

    client.publish({
        destination: '/app/chat',
        body: JSON.stringify({
            text,
            author: 'Alex',
            createdAt: Date.now()
        })
    });

}

Запуск:

client.activate();