Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Известно, что одним из признаков хорошего архитектурного дизайна является слабая связанность между отдельными модулями приложения. Достичь этого можно разными способами: Dependency Injection, с помощью паттернов проектирования Mediator, Publish-Subscribe и некоторыми другими, многие из которых так или иначе реализуют принцип инверсии зависимостей, ответственных за уменьшение связанности. Об одном из таких паттернов, а именно о Publish-Subscribe (далее PubSub) мы сегодня и поговорим. А заодно, предлагаю рассмотреть мою собственную реализацию на TypeScript, построенную на декораторах - люблю я декларативный подход, ничего тут не сделаешь.
Что такое PubSub и как он работает
Для тех, кто возможно не сильно знаком с данной темой, рассмотрим вкратце что из себя представляет паттерн PubSub, и как именно он помогает уменьшить связанность между модулями приложения. Если говорить просто - PubSub предоставляет некий централизованный канал для коммуникации между модулями, каждый из которых может:
Опубликовать (Publish) сообщение, состоящее из идентификатора типа этого сообщения и неких полезных данных (Payload)
Подписаться (Subscribe) на получение и обработку сообщений интересующего типа
Модуль, публикующий сообщения, принято называть Publisher, а подписывающийся и обрабатывающий - Subscriber. Все довольно просто, можно изобразить в виде следующей картинки:
Ключевой момент здесь в том, что отправители сообщения ничего не знают о подписчиках, а подписчики - ничего не знают об отправителях. И те и другие зависят только от типа и формата сообщений, с которыми они работают. Последние два предложения вам ничего не напоминают? Примерно так и звучит принцип инверсии зависимостей, да и по направлению стрелочек на картинке можно догадаться, что в данном случае реализован именно он. Поскольку модули больше не зависят напрямую друг от друга, их можно создавать раздельно, безболезненно заменять на другие модули, например - в целях тестирования.
Пакет 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<string>();
@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<string>,
handler: MessageHandler<string, string>
): 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-стейтом, пусть сами предпримут по этому поводу необходимые действия. Зависимости логических модулей друг от друга пропадают, связанность ослабляется.
На момент публикации данной статьи я с большего удовлетворен реализацией и особых глобальных изменений в пакете не вижу, разве что небольшие косметические. Понимаю, что моя реализация далеко не единственная, однако принял решение реализовать самостоятельно т.к. не видел особой сложности и можно адаптировать именно под свои вкусы и предпочтения. Будет здорово, если кому-либо еще данная реализация может показаться полезной, так же как буду рад любым советам, замечаниям и просто вниманию и рассуждениям по данной теме.