Гибкая модульная архитектура на Yii2 - Часть 1: Подключение модулей, роутинг и события

Часть 1: Подключение модулей, роутинг и события
Часть 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'],
            ],
        ];
    }
    // ...
}

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

Заключение

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

Вы можете прочитать эту статью на английском здесь.

Комментарии

Гость

Спасибо за статьи! есть одно предложение, переместить ссылки на следующии статьи вниз.

Haru Atari

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

Используйте Markdown
Спасибо за коментарий!
Ваше сообщение будет доступно после проверки.