Взаимодействие модулей

В статье “Вертикальное деление и модули” я рассмотрел принципы по которым код приложения делится на модули.

Руководствуясь ими мы можем эффективно поделить наше приложение на осмысленные части.

Но поделить мало, нужно ещё и обеспечить взаимодействие модулей между собой, не потеряв при этом преимуществ разделения.

Это сложная задача, которую не всегда получается хорошо решить с первого раза.

Модули могут работать друг с другом?

Да. Почему нет? Модули вполне могут взаимодействовать друг с другом.

Единственное условие — способ взаимодействия модулей должен быть явным, и ограниченным публичным интерфейсом.

Так называемый контракт модуля.

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

А не удобнее модулям взаимодействовать при помощи событий?

События это один из допустимых вариантов.

Насчёт удобства это личный вкус каждого, а целесообразность будет определяться тем для чего мы это делаем.

У событий наибольшая гибкость но и наибольший оверхед в неявности и сложности поддержки.

Способы взаимодействия модулей

1. Прямой вызов.

Обращение только к определённому типу классов определяющих публичный контракт, например:

  • Только сущности
  • Только Query и Command
  • Только UseCase
  • Только классы из папки /MyModule/Contract

2. События

3. Publisher/Subscriber

4. Прямой вызов через класс модуля и его публичные методы

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

В своих проектах пока что делаю взаимодействие по юзкейсам и сущностям (способ 1).

Иногда если хочется большей гибкости то связываю через события (способ 2). Написал для этих целей свой диспетчер событий.

Синхронная или асинхронная обработка событий

События могут быть синхронными, например у меня события синхронные так как асинхронность добавляет бóльшую сложность.

А ещё легко ошибиться и не вызвать обработчики при асинхронной обработке.

Но с событиями есть ещё сложность что для отслеживания взаимодействий между модулями нам нужно залезть куда-то в третье место в котором указаны события и их обработчики.

Это снижает наглядность.

Синхронные события - такие бывают?

События могут быть синхронными.

Честно говоря событиям без разницы как ты вызываешь обработчики, синхронно или нет.

Это на усмотрение архитектора системы.

Цель событий отвязаться от прямого вызова, от упоминания внешнего кода.

Изоляция достигается тем что у нас в коде одного модуля нет отсылок к коду другого модуля, а не асинхронностью.

Отличие событий от Publisher/Subscriber

Отличия только косметические.

В Publisher/Subscriber мы сами дёргаем обработчики и ведём их учёт а в событиях мы публикуем событие и диспетчер сам решает что с ним делать.

Publisher/Subscriber

foreach ($this->subscribers as $subscriber) {
    $subscriber->onAccountCreated($accountId);
}

События

$this->dispatcher->publish(new AccountCreatedEvent($accountId));

Отличие прямого вызова модуля (#4) от прямого вызова классов модуля (#1)

Прямой вызов модуля (4 способ) отличается от прямого вызова классов модуля (1 способ) тем что мы обращаемся к нескольким методам одного класса, а не к отдельным классам типа Query, Command, UseCase.

Прямой вызов модуля

$this->chatModule->onAccountCreated($accountId);

Прямой вызов классов модуля

ChatEntity::onAccountCreated($accountId);

Пример связанных действий

Пример из моей практики. Я делаю сервер для онлайн-игры и приложение разделено на модули.

Игрок регистрирует аккаунт. В момент создания аккаунта оповещаются остальные модули с помощью события и создают всё что им нужно, например создаётся склад для предметов игрока и добавляют игрока в общие чаты.

Как это реализовано?

Через обработчик событий. Модуль аккаунтов создаёт событие “Аккаунт создан”, остальные модули оповещаются через диспетчер и по-своему обрабатывают это событие.

Реализация взаимодействия модулей через события, позволяет мне добавить сколько угодно зависимостей на это событие вообще не трогая код модуля аккаунтов.

При добавлении нового обработчика я зарегистрирую его в классе который связывает события и их обработчики и этого будет достаточно.

Пример связанных данных

Мы разделили приложение на модули и всё хорошо, каждый модуль отвечает за свою часть функциональности.

Модули приложения доставки

Но пришёл бизнес и сказал: нам нужен отчёт в котором сводятся данные по СМС и суммам заказов.

Эти данные хранятся в разных модулях. Как нам поступить?

Во-первых, так как это новая функциональность не принадлежащая целиком ни одному из модулей то мы создадим новый модуль для отчётов.

Модули приложения доставки и модуль отчётов

Во-вторых, как же нам вывести данные в модуле отчётов?

1. Запрос данных напрямую из чужих таблиц

Самое простое решение — сделать запрос в котором выбрать данные из таблиц других модулей. Так как БД у нас общая, то мы можем это сделать.

Но у этого решения есть минус: модуль отчётов окажется жёстко связанным с модулями по которым он запрашивает данные.

Как только мы поменяем структуры данных в модуле СМС или модуле заказов то отчёты сразу сломаются, поэтому модуль отчётов придётся обновлять вслед за ними.

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

Модули должны быть разделены как по коду, так и по данным.

Как это обеспечить?

2. Запрос данных через публичный контракт модуля

Мы можем определить публичный контракт модуля Sms и Customer, добавив методы или классы, обращаясь к которым модуль Report сможет извлечь необходимые данные.

// Модуль Report собирает данные из других модулей
$smsRecords = $this->smsModule->getSmsRecords();
$orderRecords = $this->customerModule->getOrderRecords();

Мы уже не будем так зависимы от других модулей. До тех пор пока публичный контракт не изменился, модуль отчётов не сломается.

Тем не менее, есть ещё некоторая зависимость. Когда модулю отчётов понадобятся дополнительные данные, например разбивка по месяцам или фильтрация по поисковому запросу, то придётся менять “главные” модули Sms и Customer в угоду “второстепенному” модулю Report. Это тоже не очень хорошо.

3. Накапливаем данные в модуле

Мы можем отправлять данные напрямую из “главных” модулей, чтобы второстепенный модуль получил эти данные и сохранил их.

Вот так мы можем отправить данные в момент их возникновения с помощью событий:

// Модуль Sms создаёт событие после отправки SMS
(new SmsDelivered())->register($senderId, $cost, $deliveryStatus);

Обработав это событие, модуль Report может заполнить свои собственные таблицы и строить отчёты обращаясь только к своим таблицам.

В этом случае получится максимальная независимость от других модулей.

Если нам понадобится в отчётах агрегация по месяцам или фильтрация по поисковым запросам, мы легко добавим это изменив модуль отчётов и при этом не затронем “главные” модули.

Но… появится дублирование данных в БД!

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

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

Ну а “лишнее место на диске” стоит сильно дешевле чем сэкономленное на простой и надёжной системе время программистов.

Это всё очень сложно… Можно как-нибудь без этого?

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

При этом для сложной системы плюсы перевешивают этот минус.

Других путей для сложных систем я не вижу. Всё остальное лишь снимает часть симптомов.

Слоистая архитектура будет лучше чем вообще никакой. Но модули будут ещё лучше.

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