Гибкая модульная архитектура на Yii2 - Часть 1: Подключение модулей, роутинг и события
Часть 1: Подключение модулей, роутинг и события
Часть 2: Взаимодействие между модулями и интернационализация
Часть 3: Работа с базой данных и миграции
Часть 2: Взаимодействие между модулями и интернационализация
Часть 3: Работа с базой данных и миграции
При разработке сложных приложений, которые нужно поддерживать долгое время, очень важно добиться гибкой и легко изменяемой архитектуры. Один из основополагающих принципов, помогающих сделать это, - модульность. Вся система разбивается на изолированные модули с низкой связанностью.
Многие считают, что на Yii2 нельзя писать сложные приложения. Но это не более, чем заблуждение. В этом цикле статей я хочу показать эффективный подход к построению модульной архитектуры на Yii2, который помогает строить гибкие и легко изменяемые системы.
Сразу обращаю ваше внимание на несколько моментов:
- Этот подход наша команда использует в RESTful приложении. Поэтому здесь не затрагиваются вопросы работы с представлениями. Скорее всего данная архитектура будет корректно работать и для классических сайтов. Но могут быть подводные камни.
- Здесь рассматривается только общая архитектура. Все вопросы оптимизации производительности и кастомизации выходят за рамки этой статьи.
Исходники с тестовыми данными лежат здесь (обратите внимание, ссылка указывает не на последний коммит).
Постановка задачи
Итак, какие требования мы предъявляем к нашему модульному приложению?
- Возможность свободно включать и выключать любые модули. Приложение должно корректно работать с любым набором модулей. Но так же модули должны иметь возможность взаимодействовать друг с другом: обмениваться данными и командами.
- Каждый модуль может иметь несколько версий, которые тоже должны безболезненно заменяться. Это позволяет добиться большой гибкости. Мы просто разрабатываем две версии модуля и подключаем ту, которая нужна текущему пользователю.
- Модули должны быть самодостаточными. Это следствие первых двух требований. Если модуль может быть в любой момент добавлен/удален, он должен содержать в себе все, что необходимо для его работы, и абсолютно не зависеть от других модулей.
- Возможность менять список активный модулей и их версий без вмешательства в код. Чтобы это можно было сделать, например, из админки.
Общий взгляд на архитектуру
В любом приложении есть логика, общая для всей системы. Поэтому наша система состоит из ядра и подключаемых модулей. Структура файлов особо не отличается от общепринятой в Yii2.
При инициализации, приложение пробегается по списку активных модулей и подключает их. Важно помнить о том, что Yii2 загружает модули только при первом обращении к ним. Нам, из соображений экономии, тоже необходимо подключать модули без их загрузки. Но при этом нужно сразу добавлять их роуты и обработчики событий в общий список.
Подключение модулей
Прежде всего давайте посмотрим на структуру файлов модулей:
+ app
+ modules
+ example_billing
| + events
| + modules
| + v1
| | + // Стандартные файлы модуля
| | + V1.php
| + v2
| | + // Стандартные файлы модуля
| | + V2.php
| + ExampleBilling.php
+ example_tracker
+ events
+ modules
+ v1
| + // Стандартные файлы модуля
| + V1.php
+ v2
| + // Стандартные файлы модуля
| + V2.php
+ ExampleTracker.php
Здесь мы видим два модуля (созданы для примера):
example_billing и example_tracker. Оба модуля имеют по две версии: v1 и v2. Вся конечная логика описывается в модулях-версиях. В корневых модулях находятся классы, используемые всеми версиями (например, общие объекты событий).Хранение списка модулей и их версий
Доступные модули и их версии хранятся в базе данных в таблицах
module и module_version соответственно. В директории migrations можно увидеть миграции для их создания и заполнения. Их модели имеют следующий вид:namespace app\models\entities;
/**
* @property int $id Id модуля. Это не AI поле, а id модуля в приложении. Соответствует его неймспейсу
* @property string $name Человекопонятное название (для админки)
* @property bool $is_active Если модуль выключен, он не подключается
* @property int $version_id Id активной версии.
* @property string $source Полное имя класса модуля
* @property ModuleVersion[] $versions Список всех версий этого модуля (для админки)
* @property ModuleVersion|null $activeVersion Активная версия
*/
class Module extends ActiveRecord
{
/** @return ModuleVersionQuery */
public function getVersions()
{
return $this->hasMany(ModuleVersion::class, ['module_id' => 'id']);
}
/** @return ModuleVersionQuery */
public function getActiveVersion()
{
return $this->hasOne(ModuleVersion::class, [
'id' => 'version_id',
'module_id' => 'id',
]);
}
// ...
}
/**
* @property int $id Id подмодуля. Это не AI поле, а id модуля в приложении. Соответствует его неймспейсу внутри корневого модуля
* @property string $name Человекопонятное название (для админки)
* @property string $source Полное имя класса модуля
* @property int $module_id Id родительского модуля
*/
class ModuleVersion extends ActiveRecord
{
// ...
}
Вы можете хранить список модулей и их версий где хотите (хоть в настроечных файлах), но с БД работать удобней.
Список формируется вручную через миграции. С одной стороны, ручное добавление модулей и версий в список добавляет работы. Но как часто вы добавляете новые модули? Это происходит относительно редко и добавить еще и миграцию на пару строчек не составит труда. С другой стороны, такой подход дает одно важное преимущество: возможность написания модулей, которые не будут подключаться явно. Например, если у вас несколько версий слабо отличаются друг от друга, вы можете вынести общую логику в отдельную версию, но не добавлять ее в список доступных. С автоматическим формированием списка сделать это было бы сложней.
Инициализация приложения
При инициализации приложения нам нужно сделать две вещи: собрать из модулей правила роутинга и собрать обработчики событий. Это делается через обращение к статичным методам класса активной версии модуля. Использование статичных методов позволит нам обойтись без полной инициализации модуля.
Модули-версии наследуются от класса
\app\components\VersionModule. Этот класс содержит два метода:namespace app\components;
abstract class VersionModule extends Module
{
/**
* Возвращает список правил роутинга.
*
* @return array
*/
public static function getUrlRules()
{
return [];
}
/**
* Возвращает список обработчиков событий.
*
* @return array [
* eventName => [
* handler 1,
* handler 2,
* ...
* ]
* ]
*/
public static function getEventHandlers()
{
return [];
}
// ...
}
Вот как выглядит подключение модулей:
namespace app\components;
use app\models\entities\Module;
use yii\web\Application;
class WebApplication extends Application
{
/** @inheritdoc */
public function init()
{
parent::init();
// ...
$this->enableModules();
}
/**
* Подключает активные версии модулей
*/
protected function enableModules()
{
$modules = Module::find()->active()->with('activeVersion');
foreach ($modules->each() as $module) {
if (!$module->activeVersion) {
continue;
}
$this->setModule($module->id, $module->activeVersion->source); // Добавляем модуль в приложение
$this->urlManager->registerModuleRules($module->activeVersion); // Регистрируем правила роутинга
$this->eventManager->registerModuleHandlers($module->activeVersion); // Регистрируем обработчики событий
}
}
// ...
}
С правилами роутинга все просто.
UrlManager получает список роутов через VersionModule::getUrlRules():namespace app\components;
use app\models\entities\ModuleVersion;
class UrlManager extends \yii\web\UrlManager
{
/**
* Регистрирует роуты модуля.
*
* @param ModuleVersion $module
*/
public function registerModuleRules($module)
{
$class = $module->source;
$this->addRules($class::getUrlRules());
}
// ...
}
Регистрацию событий мы рассмотрим чуть позже. А сейчас обратим внимание на добавление модуля в приложение.
Замете, что добавляется модуль-версия. Но добавляется он под именем своего корневого модуля. Это сделано для того, чтобы к активной версии можно было всегда обращаться одинаково. Если у модуля
example_billing активна версия v1, то обращение к \Yii::$app->getModule('example_billing') вернет нам класс \app\modules\example_billing\modules\v1\V1. Так же благодаря этому не нужно явно указывать версию модуля в урлах. Это избавляет клиентское приложение от еще одной зависимости.Работа с событиями
Событийная модель хорошо подходит для слабо связанных систем. Когда в модуле что-то происходит, мы просто бросаем событие. И отсутствие модулей, обрабатывающих это событие никак не скажется на работоспособности текущего. Но есть один важный момент.
Названия событий лучше хранить в виде констант. Это избавит нас от опечаток и облегчит навигацию по коду. Вопрос в том, где хранить эти названия? Логично было бы хранить их в самой версии. Чтобы было видно, какие события она может генерировать. Но в таком случае мы получаем новую зависимость: из одного модуля мы напрямую обращаемся к константе из другого. Это неприемлемо!
Чтобы такого не было, можно хранить имена событий в одном месте, где-нибудь в ядре. Это рабочий вариант. Но тогда нам придется указывать имя модуля в названии константы, плюс получим огромный список в одном месте.Есть еще один вариант - хранить имена событий в корневом классе модуля. Но в таком случае при полном отключении модуля вы не сможете просто удалить его директорию (такое может понадобиться при сборке приложения с урезанным функционалом) т.к. на корневой класс будут тоже ссылаться. Какой из этих способов использовать - решайте сами. Мне больше нравится второй.
Если версии одного модуля не сильно отличаются друг от друга (в нашем случае это было так), то большинство событий в модулей будут общими для всех версий. И нет необходимости объявлять их дважды. Вот как, например, выглядит описание событий в модуле
ExampleBilling:namespace app\modules\example_billing;
use app\components\MainModule;
class ExampleBilling extends MainModule
{
const EVENT_EXAMPLE_INVOICE_CREATE = 'example_billing.invoice.create'; // Событие, общее для всех версий
const EVENT_V1_EXAMPLE_INVOICE_MODIFY = 'example_billing.v1.invoice.modify'; // Событие, исптользуемое только в версии v1
// ...
}
События ядра можно хранить в классе приложения:
namespace app\components;
use yii\web\Application;
class WebApplication extends Application
{
const EVENT_EXAMPLE_USER_CREATE = 'core.exampleUser.create';
// ...
}
Объекты событий лежат в директориях
events в ядре или в модулях. И точно так же они могут быть общие для нескольких версий (и лежать в корне модуля) или принадлежать к конкретной версии.Подписывание на события
Для работы с событиями мы используем стандартный механизм Yii2, через объект приложения. Для абстракции, делаем это через компонент
EventManager, который подключаем в конфиге:namespace app\components;
use app\models\entities\ModuleVersion;
use yii\base\Event;
class EventManager
{
/**
* Бросает событие
*
* @param string $name Event name
* @param Event $event = null
*/
public function fire($name, $event = null)
{
\Yii::$app->trigger($name, $event);
}
/**
* Регистрирует обработчики
* @param array $handlers
*/
public function registerHandlers($handlers)
{
foreach ($handlers as $event => $callbacks) {
if (!is_array($callbacks)) {
$callbacks = [$callbacks];
}
foreach ($callbacks as $callback) {
\Yii::$app->on($event, $callback);
}
}
}
/**
* Регистрирует обработчики модуля.
*
* @param ModuleVersion $module
*/
public function registerModuleHandlers($module)
{
$class = $module->source;
$handlers = $class::getEventHandlers();
$this->registerHandlers($handlers);
}
// ...
}
Модуль или приложение передает массив обработчиков. Для каждого события можно назначить несколько обработчиков. Обработчиком может служить любой
callable тип. На мой взгляд, удобней держать их в одном месте и поэтому я использую для этого классы EventHandler со статичными методами (но это только на мой взгляд).
Вот в каком виде список обработчиков возвращает модуль
ExampleBilling:namespace app\modules\example_billing\modules\v1;
use app\components\WebApplication;
use app\components\VersionModule;
use app\modules\example_billing\modules\v1\components\EventHandler;
class V1 extends VersionModule
{
/** @inheritdoc */
public static function getEventHandlers()
{
return [
WebApplication::EVENT_EXAMPLE_USER_CREATE => [
/** @see \app\modules\example_billing\modules\v1\components\EventHandler::userCreateHandler() */
[EventHandler::class, 'userCreateHandler'],
/** @see \app\modules\example_billing\modules\v1\components\EventHandler::userCreateOtherHandler() */
[EventHandler::class, 'userCreateOtherHandler'],
],
];
}
// ...
}
Здесь добавляется два обработчика для события из ядра. Но точно так же мы может подписаться на события из других модулей или даже из своего же модуля.
Заключение
В этой статье мы рассмотрели основные механизмы. Это уже рабочий вариант. Дальше мы рассмотрим, как облегчить написание версий, слабо связанных между собой. А так же поговорим о том, как удобно разнести по модулям миграции и словари.