Уведомления пользователей

Система пользовательских уведомлений в реальном времени строится вокруг нескольких ключевых компонентов:

  • STOMP-клиент в браузере
  • WebSocket-соединение
  • брокер сообщений
  • каналы подписки
  • механизмы подтверждения доставки
  • очередь персональных уведомлений

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

Браузер → WebSocket → STOMP Broker → Notification Service
                                     ↓
                              Пользовательские очереди

В качестве брокера сообщений чаще всего используются:

  • RabbitMQ
  • ActiveMQ
  • Apache Artemis
  • Spring WebSocket Broker

Библиотека STOMP.js выступает транспортным уровнем между frontend-приложением и брокером сообщений.


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

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

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

npm install @stomp/stompjs

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

Для браузеров и серверов без полноценной поддержки WebSocket часто используется SockJS.

npm install sockjs-client

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

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

import { Client } fr om '@stomp/stompjs';

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

    reconnectDelay: 5000,

    heartbeatIncoming: 4000,
    heartbeatOutgoing: 4000,
});

client.onConn ect = () => {
    console.log('Connected');
};

client.activate();

Основные параметры клиента

Параметр Назначение
brokerURL URL WebSocket endpoint
reconnectDelay Интервал переподключения
heartbeatIncoming Входящий heartbeat
heartbeatOutgoing Исходящий heartbeat
debug Логирование кадров
connectHeaders Заголовки авторизации

Авторизация пользователя

Уведомления почти всегда являются персонализированными, поэтому соединение требует аутентификации.

JWT авторизация

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

    connectHeaders: {
        Authorization: 'Bearer ' + token
    }
});

Авторизация через query string

Иногда токен передают через URL:

const socket = new WebSocket(
    `ws://localhost:8080/ws?token=${token}`
);

Однако такой подход менее безопасен, поскольку URL может попасть в логи сервера.


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

После успешного подключения пользователь подписывается на персональный канал.

Пример подписки

client.onConn ect = () => {

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

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

        console.log(notification);
    });
};

Структура уведомления

Обычно уведомление содержит:

{
    "id": 153,
    "type": "NEW_MESSAGE",
    "title": "Новое сообщение",
    "text": "Пользователь отправил сообщение",
    "createdAt": "2026-05-22T14:10:00",
    "read": false
}

Категории уведомлений

Система может разделять уведомления по типам:

Тип Назначение
NEW_MESSAGE Новые сообщения
SYSTEM Системные события
SECURITY Безопасность
ORDER_STATUS Изменение заказа
COMMENT Комментарии
WARNING Предупреждения

Обработка входящих сообщений

Лучше избегать логики непосредственно внутри callback-функции.

Антипаттерн

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

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

    renderNotification(data);
    playSound();
    updateCounter();
    saveToStore(data);
});

Правильная архитектура

client.subscribe('/user/queue/notifications', handleNotification);

function handleNotification(message) {

    const data = parseNotification(message);

    notificationStore.add(data);

    notificationUI.render(data);

    notificationAudio.play(data.type);
}

Централизованный NotificationService

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

class NotificationService {

    constructor(client) {
        this.client = client;
    }

    subscribe() {

        this.client.subscribe(
            '/user/queue/notifications',
            this.handle.bind(this)
        );
    }

    handle(message) {

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

        this.show(notification);
    }

    show(notification) {

        console.log(notification);
    }
}

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

STOMP поддерживает acknowledgements.

Подписка с ACK

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

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

        processNotification(notification);

        message.ack();
    },
    {
        ack: 'client'
    }
);

Зачем нужны ACK

Без подтверждений брокер считает сообщение доставленным сразу после отправки.

Если браузер:

  • упал
  • завис
  • потерял соединение
  • закрыл вкладку

то уведомление может потеряться.

ACK позволяет гарантировать обработку.


Отрицательное подтверждение

При ошибке обработки сообщение можно вернуть обратно в очередь.

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

        try {

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

            processNotification(data);

            message.ack();

        } catch (error) {

            message.nack();
        }
    },
    {
        ack: 'client'
    }
);

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

Для систем уведомлений reconnect является обязательным.

const client = new Client({

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

    reconnectDelay: 5000
});

Что происходит при reconnect

STOMP.js:

  1. обнаруживает разрыв соединения
  2. запускает таймер
  3. создаёт новое WebSocket-соединение
  4. выполняет повторный CONNECT
  5. восстанавливает подписки

Контроль состояния соединения

Обработка отключения

client.onDisconn ect = () => {
    console.log('Disconnected');
};

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

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

Ошибки STOMP

client.onStompEr ror = (frame) => {

    console.error(frame.headers['message']);

    console.error(frame.body);
};

Отображение статуса сети

Пользователь должен понимать текущее состояние соединения.

Пример состояния

const connectionState = {
    connected: false
};

Обновление статуса

client.onConn ect = () => {
    connectionState.connected = true;
};

client.onDisconn ect = () => {
    connectionState.connected = false;
};

Счётчик непрочитанных уведомлений

Одна из самых распространённых задач.

Хранилище уведомлений

class NotificationStore {

    constructor() {
        this.notifications = [];
    }

    add(notification) {

        this.notifications.unshift(notification);
    }

    unreadCount() {

        return this.notifications.filter(
            item => !item.read
        ).length;
    }
}

Отметка уведомления как прочитанного

Отправка команды серверу

client.publish({
    destination: '/app/notifications/read',
    body: JSON.stringify({
        notificationId: 55
    })
});

Группировка уведомлений

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

Пример группировки

Вместо:

Иван отправил сообщение
Анна отправила сообщение
Павел отправил сообщение

Формируется:

3 новых сообщения

Группировка на клиенте

function groupNotifications(items) {

    return items.reduce((groups, item) => {

        const key = item.type;

        if (!groups[key]) {
            groups[key] = [];
        }

        groups[key].push(item);

        return groups;

    }, {});
}

Debounce обновлений интерфейса

Массовые события способны перегружать UI.

Пример debounce

let timer = null;

function scheduleRender() {

    clearTimeout(timer);

    timer = setTimeout(() => {

        renderNotifications();

    }, 200);
}

Работа с React

React Hook

import { useEffect } fr om 'react';

function useNotifications(client) {

    useEffect(() => {

        if (!client) {
            return;
        }

        const subscription = client.subscribe(
            '/user/queue/notifications',
            (message) => {

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

                console.log(data);
            }
        );

        return () => {
            subscription.unsubscribe();
        };

    }, [client]);
}

Работа с Vue

Vue composable

import { onMounted, onUnmounted } fr om 'vue';

export function useNotifications(client) {

    let subscription = null;

    onMounted(() => {

        subscription = client.subscribe(
            '/user/queue/notifications',
            onMessage
        );
    });

    onUnmounted(() => {

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

Работа с Redux

Action creator

export function notificationReceived(notification) {

    return {
        type: 'NOTIFICATION_RECEIVED',
        payload: notification
    };
}

Dispatch внутри subscribe

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

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

        store.dispatch(
            notificationReceived(notification)
        );
    }
);

Push + STOMP архитектура

Иногда система комбинирует:

  • STOMP для активной вкладки
  • Push API для фоновых уведомлений

Логика

Состояние Канал
Пользователь онлайн STOMP
Вкладка закрыта Push
Пользователь офлайн Push
Пользователь активен WebSocket

Heartbeat механизм

Heartbeat нужен для контроля живости соединения.

const client = new Client({

    heartbeatIncoming: 10000,
    heartbeatOutgoing: 10000
});

Как это работает

Клиент и сервер обмениваются heartbeat-пакетами.

Если heartbeat перестал приходить:

  • соединение считается мёртвым
  • запускается reconnect

Обработка дублей уведомлений

Иногда reconnect приводит к повторной доставке.

Решение через Set

const processed = new Set();

function handleNotification(message) {

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

    if (processed.has(notification.id)) {
        return;
    }

    processed.add(notification.id);

    renderNotification(notification);
}

Очередь уведомлений

Визуально уведомления часто показываются последовательно.

Пример очереди

class NotificationQueue {

    constructor() {
        this.queue = [];
        this.active = false;
    }

    push(notification) {

        this.queue.push(notification);

        this.next();
    }

    next() {

        if (this.active) {
            return;
        }

        const notification = this.queue.shift();

        if (!notification) {
            return;
        }

        this.active = true;

        showNotification(notification);

        setTimeout(() => {

            this.active = false;

            this.next();

        }, 3000);
    }
}

Browser Notifications API

STOMP.js отлично сочетается с нативными уведомлениями браузера.

Разрешение уведомлений

await Notification.requestPermission();

Показ уведомления

new Notification('Новое сообщение', {
    body: 'Поступило новое сообщение'
});

Звуковые уведомления

Воспроизведение звука

const audio = new Audio('/sounds/notification.mp3');

audio.play();

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

Основные проблемы

Проблема Причина
Утечки памяти Неотписанные subscriptions
Фризы UI Частые рендеры
Дубли Reconnect
Потеря сообщений Отсутствие ACK
Рост RAM Большой список уведомлений

Ограничение количества уведомлений

class NotificationStore {

    constructor(lim it = 100) {
        this.lim it = lim it;
        this.items = [];
    }

    add(notification) {

        this.items.unshift(notification);

        if (this.items.length > this.limit) {
            this.items.pop();
        }
    }
}

Очистка подписок

Одна из самых критичных задач.

Пример корректной очистки

let subscription = null;

function init() {

    subscription = client.subscribe(
        '/user/queue/notifications',
        onMessage
    );
}

function destroy() {

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

Масштабирование системы уведомлений

При росте нагрузки архитектура усложняется.

Типичная схема

Frontend
    ↓
WebSocket Gateway
    ↓
Message Broker
    ↓
Notification Service
    ↓
Database

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

Хорошая практика — использовать разные destination.

Пример

/user/queue/messages
/user/queue/security
/user/queue/system
/user/queue/orders

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

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

Batch уведомления

Вместо одного сообщения сервер может отправлять массив.

Пример

[
    {
        "id": 1,
        "text": "Message 1"
    },
    {
        "id": 2,
        "text": "Message 2"
    }
]

Обработка

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

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

        notifications.forEach(addNotification);
    }
);

Защита от лавины уведомлений

Иногда сервер способен отправить тысячи событий.

Rate limiting

let received = 0;

setInterval(() => {
    received = 0;
}, 1000);

function onMessage(message) {

    received++;

    if (received > 100) {
        return;
    }

    processMessage(message);
}

Логирование событий

Для диагностики полезно включать debug.

const client = new Client({

    debug(str) {
        console.log(str);
    }
});

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

Основные правила

  • не передавать секреты
  • валидировать payload
  • проверять права доступа
  • использовать WSS
  • фильтровать HTML
  • ограничивать размер сообщений

Санитизация HTML

Если уведомления содержат HTML:

import DOMPurify from 'dompurify';

const safeHTML = DOMPurify.sanitize(
    notification.text
);

Пример полноценного NotificationManager

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

class NotificationManager {

    constructor(token) {

        this.store = [];
        this.processed = new Set();

        this.client = new Client({

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

            reconnectDelay: 5000,

            connectHeaders: {
                Authorization: `Bearer ${token}`
            }
        });

        this.client.onConn ect = this.onConnect.bind(this);

        this.client.activate();
    }

    onConnect() {

        this.client.subscribe(
            '/user/queue/notifications',
            this.onMessage.bind(this),
            {
                ack: 'client'
            }
        );
    }

    onMessage(message) {

        try {

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

            if (this.processed.has(notification.id)) {

                message.ack();

                return;
            }

            this.processed.add(notification.id);

            this.store.unshift(notification);

            this.render(notification);

            message.ack();

        } catch (error) {

            message.nack();
        }
    }

    render(notification) {

        console.log(notification);
    }
}