суббота, 7 января 2017 г.

modules yii2 part1

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

При разработке сложных приложений, которые нужно поддерживать долгое время, очень важно добиться гибкой и легко изменяемой архитектуры. Один из основополагающих принципов, помогающих сделать это, - модульность. Вся система разбивается на изолированные модули с низкой связанностью.
Многие считают, что на 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'],
            ],
        ];
    }
    // ...
}
Здесь добавляется два обработчика для события из ядра. Но точно так же мы может подписаться на события из других модулей или даже из своего же модуля.

Заключение

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

JavaScript learn

Чтобы вставить элемент после какого-то элемента, нужно создать прототип. Element.prototype.appendAfter = function (element) { element.paren...