Идентификаторы подписок

В протоколе STOMP каждая подписка клиента на очередь, топик или иной канал сообщений должна иметь уникальный идентификатор. В библиотеке STOMP.js этот идентификатор задаётся через заголовок id при вызове метода subscribe().

Идентификатор подписки выполняет несколько функций:

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

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


Базовая подписка с идентификатором

Простейший пример:

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

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

client.onConn ect = () => {
  client.subscribe(
    '/topic/news',
    (message) => {
      console.log(message.body);
    },
    {
      id: 'news-subscription'
    }
  );
};

client.activate();

В данном случае подписка получает идентификатор:

news-subscription

Этот идентификатор отправляется брокеру внутри STOMP-фрейма:

SUBSCRIBE
id:news-subscription
destination:/topic/news

Что происходит без указания id

Некоторые версии STOMP.js способны автоматически генерировать идентификаторы подписок. Однако поведение зависит от:

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

Автоматическая генерация удобна только для простых сценариев. В production-системах рекомендуется всегда задавать id явно.

Причины:

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

Формат идентификаторов

Протокол STOMP не навязывает строгий формат id. Обычно используются:

Строковые идентификаторы

id: 'chat-room-1'

UUID

id: '550e8400-e29b-41d4-a716-446655440000'

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

id: 'user-42-notifications'

Идентификаторы с namespace

id: 'dashboard.orders.active'

Требование уникальности

Идентификатор должен быть уникальным внутри одного соединения.

Нельзя создавать две подписки с одинаковым id:

client.subscribe('/topic/a', callbackA, {
  id: 'same-id'
});

client.subscribe('/topic/b', callbackB, {
  id: 'same-id'
});

Возможные последствия:

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

Поведение зависит от брокера.


Генерация уникальных идентификаторов

Использование счётчика

let subscriptionCounter = 0;

function createSubscriptionId() {
  subscriptionCounter++;

  return `sub-${subscriptionCounter}`;
}

Пример:

const id = createSubscriptionId();

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

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

const id = `sub-${Date.now()}`;

Использование crypto.randomUUID()

Современный браузерный API:

const id = crypto.randomUUID();

client.subscribe('/topic/data', callback, { id });

Пример результата:

f3d5e77d-4d0b-4df0-91e7-f19f26c92f61

Хранение идентификаторов

В крупных приложениях идентификаторы обычно сохраняются.

Хранение в Map

const subscriptions = new Map();

const subscription = client.subscribe(
  '/topic/orders',
  callback,
  {
    id: 'orders-feed'
  }
);

subscriptions.set('orders-feed', subscription);

Хранение в объекте

const subscriptions = {};

subscriptions.news = client.subscribe(
  '/topic/news',
  callback,
  {
    id: 'news-sub'
  }
);

Связь id и unsubscribe

Метод unsubscribe() использует идентификатор подписки.

const subscription = client.subscribe(
  '/topic/news',
  callback,
  {
    id: 'news-sub'
  }
);

subscription.unsubscribe();

Внутри STOMP отправляется:

UNSUBSCRIBE
id:news-sub

Ручное удаление подписки

Иногда unsubscribe выполняется напрямую:

client.unsubscribe('news-sub');

Это возможно только при известном идентификаторе.


Идентификаторы и ACK

При использовании режима подтверждения сообщений (ack) идентификатор подписки играет важную роль.

Пример:

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

    message.ack();
  },
  {
    id: 'task-worker',
    ack: 'client'
  }
);

Брокер связывает:

  • подписку;
  • доставленное сообщение;
  • ACK/NACK.

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


Использование id в нескольких вкладках браузера

Проблема часто возникает в multi-tab приложениях.

Например:

id: 'notifications'

Если несколько вкладок используют одинаковые идентификаторы и общий backend-session, возможны конфликты.

Решение:

const tabId = crypto.randomUUID();

const subscriptionId = `notifications-${tabId}`;

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

При reconnect STOMP.js обычно создаёт новые подписки.

Важно понимать:

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

Стабильные идентификаторы при reconnect

Иногда используются постоянные id:

id: 'main-notifications'

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

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

Недостатки:

  • возможны конфликты при дублирующихся соединениях;
  • некоторые брокеры кэшируют старые подписки;
  • возможны race condition.

Динамические идентификаторы при reconnect

Другой подход:

id: `main-notifications-${Date.now()}`

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

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

Недостатки:

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

Идентификаторы и React

В React-приложениях подписки часто создаются внутри useEffect.

Неправильный вариант:

useEffect(() => {
  client.subscribe('/topic/data', callback, {
    id: 'data-sub'
  });
}, []);

При повторном монтировании компонента возможен конфликт.

Более безопасный подход:

useEffect(() => {
  const id = crypto.randomUUID();

  const subscription = client.subscribe(
    '/topic/data',
    callback,
    { id }
  );

  return () => {
    subscription.unsubscribe();
  };
}, []);

Идентификаторы и Vue

Во Vue подписки часто размещаются внутри lifecycle hooks.

mounted() {
  this.subscription = client.subscribe(
    '/topic/chat',
    this.onMessage,
    {
      id: `chat-${this._uid}`
    }
  );
}

Идентификаторы и Angular

Angular-сервисы нередко управляют множеством подписок одновременно.

Пример registry:

class SubscriptionRegistry {
  constructor() {
    this.items = new Map();
  }

  add(id, subscription) {
    this.items.set(id, subscription);
  }

  remove(id) {
    const sub = this.items.get(id);

    if (sub) {
      sub.unsubscribe();
      this.items.delete(id);
    }
  }
}

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

Одна из самых распространённых ошибок:

setInterval(() => {
  client.subscribe('/topic/live', callback, {
    id: 'live-feed'
  });
}, 1000);

Каждую секунду создаётся новая подписка с тем же идентификатором.

Последствия:

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

Диагностика подписок

Полезно логировать идентификаторы:

const id = crypto.randomUUID();

console.log('Subscribe:', id);

client.subscribe('/topic/orders', callback, {
  id
});

Централизованный менеджер подписок

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

Пример:

class StompSubscriptionManager {
  constructor(client) {
    this.client = client;
    this.subscriptions = new Map();
  }

  subscribe(destination, callback) {
    const id = crypto.randomUUID();

    const subscription = this.client.subscribe(
      destination,
      callback,
      { id }
    );

    this.subscriptions.set(id, subscription);

    return id;
  }

  unsubscribe(id) {
    const subscription = this.subscriptions.get(id);

    if (!subscription) {
      return;
    }

    subscription.unsubscribe();

    this.subscriptions.delete(id);
  }
}

Идентификаторы и брокеры сообщений

Разные брокеры по-разному относятся к subscription id.

Особенности RabbitMQ

RabbitMQ обычно требует строгой уникальности идентификаторов внутри соединения.


Особенности ActiveMQ

Apache ActiveMQ поддерживает более гибкое управление подписками, включая durable subscriptions.


Особенности Apollo и Artemis

Apache ActiveMQ Artemis может использовать дополнительные внутренние механизмы маршрутизации подписок.


Durable subscriptions

Некоторые брокеры поддерживают постоянные подписки.

Пример:

client.subscribe(
  '/topic/events',
  callback,
  {
    id: 'durable-events'
  }
);

В таких системах id становится частью постоянного состояния брокера.

Изменение идентификатора может привести к:

  • потере старой durable subscription;
  • созданию новой очереди;
  • накоплению сообщений;
  • дублированию потоков.

Идентификаторы и микрофронтенды

В микрофронтенд-архитектуре особенно важны namespace.

Пример:

id: 'billing.notifications'

id: 'orders.notifications'

id: 'analytics.notifications'

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


Именование идентификаторов

Распространённые схемы:

По назначению

orders-feed
chat-room
notifications

По пользователю

user-42-chat
user-42-alerts

По компоненту

dashboard-orders
sidebar-chat
header-notifications

По окружению

prod-orders-feed
dev-orders-feed

Отладка конфликтов id

Типичные симптомы:

  • сообщения перестают приходить;
  • unsubscribe удаляет не ту подписку;
  • появляются дубликаты;
  • брокер закрывает соединение;
  • reconnect зацикливается.

Для диагностики полезно:

  • логировать все subscribe/unsubscribe;
  • проверять registry подписок;
  • отслеживать reconnect;
  • анализировать STOMP-фреймы;
  • включать debug STOMP.js.

Debug-режим STOMP.js

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

В логах будут видны:

>>> SUBSCRIBE
id:orders-feed
destination:/topic/orders

Практика безопасного управления идентификаторами

Наиболее устойчивой считается комбинация:

  • централизованного registry;
  • UUID;
  • namespace;
  • автоматического unsubscribe;
  • очистки подписок при reconnect;
  • логирования всех операций.

Пример итогового подхода:

function createSubscriptionId(module, topic) {
  return `${module}.${topic}.${crypto.randomUUID()}`;
}

const id = createSubscriptionId(
  'dashboard',
  'orders'
);

const subscription = client.subscribe(
  '/topic/orders',
  callback,
  { id }
);