Библиотека для реализации Publish-Subscribe паттерна на TypeScript

Известно, что одним из признаков хорошего архитектурного дизайна является слабая связанность между отдельными модулями приложения. Достичь этого можно разными способами: Dependency Injection, с помощью паттернов проектирования Mediator, Publish-Subscribe и некоторыми другими, многие из которых так или иначе реализуют принцип инверсии зависимостей, ответственных за уменьшение связанности. Об одном из таких паттернов, а именно о Publish-Subscribe (далее PubSub) мы сегодня и поговорим. А заодно, предлагаю рассмотреть мою собственную реализацию на TypeScript, построенную на декораторах — люблю я декларативный подход, ничего тут не сделаешь.

Что такое PubSub и как он работает

Для тех, кто возможно не сильно знаком с данной темой, рассмотрим вкратце что из себя представляет паттерн PubSub, и как именно он помогает уменьшить связанность между модулями приложения. Если говорить просто — PubSub предоставляет некий централизованный канал для коммуникации между модулями, каждый из которых может:

  • Опубликовать (Publish) сообщение, состоящее из идентификатора типа этого сообщения и неких полезных данных (Payload)

  • Подписаться (Subscribe) на получение и обработку сообщений интересующего типа

Модуль, публикующий сообщения, принято называть Publisher, а подписывающийся и обрабатывающий — Subscriber. Все довольно просто, можно изобразить в виде следующей картинки:

image-loader.svg

Ключевой момент здесь в том, что отправители сообщения ничего не знают о подписчиках, а подписчики — ничего не знают об отправителях. И те и другие зависят только от типа и формата сообщений, с которыми они работают. Последние два предложения вам ничего не напоминают? Примерно так и звучит принцип инверсии зависимостей, да и по направлению стрелочек на картинке можно догадаться, что в данном случае реализован именно он. Поскольку модули больше не зависят напрямую друг от друга, их можно создавать раздельно, безболезненно заменять на другие модули, например — в целях тестирования.

Пакет type-pubsub

Предлагаю рассмотреть мою реализацию данного паттерна в виде готового npm-пакета type-pubsub. При реализации я руководствовался следующими соображениями:

  • Подписка при помощи декораторов: использование @Subscribe () на методе класса позволяет автоматически зарегистрировать его в качестве обработчика интересующего нас сообщения

  • Возможность подписчикам указывать канал явно (в случаях, если каналов несколько или используется не канал по-умолчанию) или не указывать (будет использован канал по-умолчанию) — тоже в параметрах декоратора

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

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

  • Возможность подставить кастомную реализацию PubSub-канала (например, адаптер для другой библиотеки или что-то принципиально иное) — все что требуется, реализовать простой интерфейс с методами publish, subscribe и unsubscribe

  • Класс-подписчик — не обязательно синглтон, несколько экземпляров одного и того же класса подписчика, существующие одновременно, должны работать корректно. В случаях, когда нам нужен именно синглтон — возможность создать его экземпляр неявно и автоматически

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

import { PubSub, Subscribe, Subscriber, Unsubscribe } from 'type-pubsub';

@Subscriber()
class SubscriberExample {
  @Subscribe('TEST_MESSAGE')
  foo(payload: string, message: string): void {
    console.log(payload);
  }

  // Calling this the method marked with @Unsibscribe() will unregister 
  // all the subscriptions. No implementation is needed
  @Unsubscribe()
  dispose(): void {}
}

var subscriber = new SubscriberExample();
PubSub.publish('TEST_MESSAGE', 'This message will be displayed');
subscriber.dispose(); // Unsubscribe
PubSub.publish('TEST_MESSAGE', "This message won't be displayed");

В примере выше мы реализовали класс SubscriberExample, пометили его декоратором @Subscriber (), что автоматически и реализует всю магию: методы, объявленные с помощью @Subscribe (…) будут автоматически зарегистрированы в качестве обработчиков интересующего нас сообщения (в данном примере — 'TEST_MESSAGE'), а метод, помеченный @Unsubscribe () будет обернут в оболочку, реализующую отписку данного экземпляра подписчика от всех ранее подписанных сообщений.

В предыдущем примере мы не указывали явно канал подписчику, что означает подписку на канал по-умолчанию, которым в данном пакете является PubSub. В случаях, если нам нужно указать канал явно, мы можем передать параметры в декоратор @Subscriber ():

import { Channel, Subscribe, Subscriber, Unsubscribe } from 'type-pubsub';

const myChannel = new Channel();

@Subscriber({ channel: myChannel })
class SubscriberExample {
  ...
}

myChannel.publish('TEST_MESSAGE', 'Some data');

Как уже было сказано ранее, один и тот же метод можно подписать одновременно на несколько сообщений, для этого нужно указать несколько декораторов @Subscribe ():

@Subscriber()
class SubscriberExample {
  @Subscribe('TEST_MESSAGE')
  @Subscribe('OTHER_MESSAGE')
  foo(payload: string, message: string): void {
    console.log(`Message received: ${message}, Payload: ${payload}`);
  }
}

При срабатывании, тип сообщения придет во втором параметре, данные (Payload) — в первом. На самом деле при вызове обработчика в метод может быть передано четыре параметра: payload, тип сообщения, канал и ссылка на сам обработчик, что позволяет, в частности, отписаться прямо из обработчика:

@Subscriber()
class SubscriberExample {
  @Subscribe('TEST_MESSAGE')
  foo(
      payload: string, 
      message: string, 
      channel: PubSubService, 
      handler: MessageHandler
  ): void {
    ...
    channel.unsubscribe(message, handler);
  }
}

Еще один кейс, который я хотел бы показать — возможность создавать экземпляры подписчиков автоматически. Для того, чтобы подписка срабатывала, экземпляр подписчика должен быть создан. В самом первом примере мы делали это вручную, явно вызывая

var subscriber = new SubscriberExample();

В некоторых случаях хочется просто реализовать и экспортировать класс, единственный экземпляр которого должен существовать на протяжении всего времени работы приложения (синглтон), и в случаях, когда у нас не используются специальные инструменты управления жизненным циклом, такие как DI-контейнеры, чтобы не создавать объект вручную, можно соответствующим образом сконфигурировать @Subscriber ():

@Subscriber({ createInstance: true })
class SubscriberExample {
  ...
}

При регистрации подписок конструктор класса будет вызван автоматически. Если же вам в конструктор нужно передать параметры, их можно указать с помощью свойства constructorParameters:

@Subscriber({ createInstance: true, constructorParameters: ['Test'] })
class SubscriberExample {
  constructor(p: string) { }
  ...
}

Ну и наконец, если не хочется использовать классы и декораторы, подписаться можно и без них:

PubSub.subscribe('TEST_MESSAGE', (payload) => console.log(payload));

В заключение

Пакет может быть использован как для Node.js, так и для Web-браузера. Лично я сейчас использую его в своем React-приложении с Apollo-client в качестве GraphQL-клиента и в какой-то степени State Manager-а. В архитектуре приложения принято некоторое разделение ответственности: кастомные хуки, реализующие взаимодействие с сервером при помощи GraphQL, не являются ответственным за обновление стейта — это не их обязанность. И чтобы реализовать обновление состояния при успешном выполнении, скажем, мутаций, мы просто публикуем сообщение что такая-то такая-то сущность была добавлена/изменена/удалена. Те, кто ответственны за управление shared-стейтом, пусть сами предпримут по этому поводу необходимые действия. Зависимости логических модулей друг от друга пропадают, связанность ослабляется.

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

© Habrahabr.ru