Гибкая модульная архитектура на Yii2 - Часть 2: Взаимодействие между модулями и интернационализация

Часть 1: Подключение модулей, роутинг и события
Часть 2: Взаимодействие между модулями и интернационализация
Часть 3: Работа с базой данных и миграции

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

Исходники с тестовыми данными лежат здесь (обратите внимание, ссылка указывает не на последний коммит).

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

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

Нам нужен механизм, который будет решать две задачи:

  1. Возможность непрямого обращения к модульным классам. Так, чтобы в случае отсутствия модуля он просто возвращал null без падения.
  2. Возможность получения нужного класса из активной версии модуля. Мы просто запрашиваем класс MyClass из модуля MyModule. А система сама определяет активную версию этого модуля и ищет нужный класс в ней.

Обычно в таких случаях используют DI-контейнеры. Yii2 предоставляет собственную реализацию этого контейнера. Использование этого компонента решит первую задачу, но не поможет нам в решении второй. Дело в том, что в контейнер надо заранее передавать подключенные классы. А нам необходимо делать это динамически в момент обращения к нему.

Для решения этой задачи добавим два прокси-метода в класс базовых модулей:

namespace app\components;
use app\models\entities\Module as ModuleAr;
use yii\base\Exception;
use yii\helpers\Inflector;
abstract class MainModule extends Module
{
    /**
     * Возвращает объект класса $className из версии $version.
     *
     * @param string $className Имя класса, относительно корня модуля.
     * @param array $constructorArgs = [] Аргументы, передаваемые в конструктор.
     * @param string $moduleVersion = null Версия, в которой будет осуществляться поиск класса. Если NULL - будет использована активная версия.
     * @return mixed|null Объект класса или NULL, если класс не доступен.
     * @throws \yii\base\Exception
     */
    public static function getObject($className, array $constructorArgs = [], $moduleVersion = null)
    {
        $class = static::createFullClassName($className, $moduleVersion);
        return $class && class_exists($class) ? new $class(...$constructorArgs) : null;
    }
    /**
     * Возвращает полное имя класса $className из версии $version.
     *
     * @param string $className Имя класса, относительно корня модуля.
     * @param string $moduleVersion = null Версия, в которой будет осуществляться поиск класса. Если NULL - будет использована активная версия.
     * @return mixed|null Имя класса или NULL, если класс не доступен.
     * @throws \yii\base\Exception
     */
    public static function getClass($className, $moduleVersion = null)
    {
        $class = static::createFullClassName($className, $moduleVersion);
        return $class && class_exists($class) ? $class : null;
    }
    /**
     * Формирует полное имя класса из его относительного имени и версии модуля.
     *
     * @param string $name Имя класса, относительно корня модуля.
     * @param string $moduleVersion = null Id of module's version. Версия модуля. Если NULL - будет использована активная версия.
     * @return string|null Full name of class. Имя класса или NULL, если у модуля нет активной версии.
     */
    private static function createFullClassName($name, $moduleVersion = null)
    {
        $moduleClass = static::class;
        $moduleId = Inflector::underscore(substr($moduleClass, strrpos($moduleClass, '\\') + 1));
        if ($moduleVersion === null) {
            $moduleVersion = ModuleAr::getActiveVersionIdByModuleId($moduleId);
        }
        if ($moduleVersion === null) {
            return null;
        }
        $namespace = substr($moduleClass, 0, strrpos($moduleClass, '\\'));
        $className = ltrim($name, "\\");
        return "\\{$namespace}\\modules\\{$moduleVersion}\\{$className}";
    }
    // ...
}

Посмотрим, как работают эти методы. Например, в модуле ExampleBilling у нас есть модель ExampleInvoice. Нам нужно получить экземпляр этого класса. Мы пишем следующее:

use \app\modules\example_billing\ExampleBilling;
// ...
$object = ExampleBilling::getObject('models\entities\ExampleInvoice');
if ($object) {
  // Работаем с полученным объектом.
}

Фасад сначала определяет, что сейчас для модуля ExampleBilling подключена версия v1. Проверяет существование класса \app\modules\example_billing\modules\v1\models\entities\ExampleInvoice. Если класс доступен - создает его экземпляр и возвращает нам.

Так же эти методы удобны, когда мы пишем общую логику для нескольких версий модуля. Например, у нас есть две версии v1 и v2, которые незначительно отличаются моделями ExampleInvoice. Мы выносим общую логику в версию common (которую нельзя назначить активной) и наследуем от нее классы в версиях. Но мы не можем обращаться к модели, находящейся в какой-то версии, напрямую из common т.к. активна может быть любая версия. В таком случае, используем тот же фасад и получим класс из той версии, которая будет активна в момент выполнения. Пример можно посмотреть в методе \app\modules\example_billing\modules\v1\models\entities\ExampleCommonModel().

Таким образом, использую фасады и события, мы можем связывать модули друг с другом, не создавая при этом зависимостей, ломающих работу приложения. Как и в случае с событиями, если вы не хотите завязываться на классы корневых модулей, вы можете вынести эти фасады в какой-нибудь глобальный объект и вызывать их, явно указывая модуль.

Интернационализация (I18N)

Практически любое приложение требует перевода на несколько языков. Yii2 предоставляет для этого готовый инструмент. Все, что нам остается сделать, это разнести хранение словарей по модулям. Так чтобы каждый модуль хранил свои переводы.

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

Есть решение, которое позволяет сделать это достаточно просто. Суть его заключается в том, чтобы, основываясь на модуле и версии, формировать правильный путь к файлу словаря. Для этого переопределим компонент источника переводов:

// app/config/common.php
return [
    // ...
    'components' => [
        // ...
        'i18n' => [
            'translations' => [
                '*' => [
                    'class' => \app\components\PhpMessageSource::class,
                    'basePath' => '/messages',
                ],
            ],
        ],
    ],
];

Переопределим метод \app\components\PhpMessageSource::getMessageFilePath() следующим образом:

namespace app\components;
use yii\base\Exception;
class PhpMessageSource extends \yii\i18n\PhpMessageSource
{
    /**
     * @param string $category Категория. Должна иметь один из видов:
     * - moduleId.moduleVersion.categoryName
     * - moduleId.categoryName
     * - categoryName
     * @param string $language
     * @return string Путь к файлу
     * @throws Exception
     * @see \yii\i18n\PhpMessageSource::getMessageFilePath()
     */
    protected function getMessageFilePath($category, $language)
    {
        $categoryList = explode('.', $category);
        $bathPath = trim($this->basePath, '\\/');
        switch (count($categoryList)) {
            case 1:
                return $this->getFullFileName("@app/{$bathPath}/{$language}", $categoryList[0]);
                break;
            case 2:
                return $this->getFullFileName("@app/modules/{$categoryList[0]}/{$bathPath}/{$language}", $categoryList[1]);
                break;
            case 3:
                return $this->getFullFileName("@app/modules/{$categoryList[0]}/modules/{$categoryList[1]}/{$bathPath}/{$language}", $categoryList[2]);
                break;
            default :
                throw new Exception("Invalid category name: '{$category}.");
                break;
        }
    }

    private function getFullFileName($path, $category)
    {
        $path = \Yii::getAlias($path);
        return isset($this->fileMap[$category])
            ? $path . $this->fileMap[$category]
            : $path . '/'.str_replace('\\', '/', $category) . '.php';
    }
}

Здесь все понятно. Путь к файлу формируется на основе полученного модуля и версии.

  • dictionary - вернет путь \app\messages\dictionary.php.
  • example_billing.dictionary - вернет путь \app\modules\example_billing\messages\dictionary.php. Для словарей, общих для все версий модуля.
  • example_billing.v1.dictionary - вернет путь \app\modules\example_billing\modules\v1\messages\dictionary.php.

Теперь мы можем получить перевод из любого модуля. Теперь для удобства добавим фасад, который будет автоматически подставлять id модуля и его активную версию:

namespace app\components;

use app\models\entities\Module as ModuleAr;
use yii\base\Exception;
use yii\helpers\Inflector;

abstract class MainModule extends Module
{
    public static function t($category, $message, $params = [], $language = null, $version = null)
    {
        $class = static::class;
        $id = Inflector::underscore(substr($class, strrpos($class, '\\') + 1));
        if ($version === null) {
            $version = ModuleAr::getActiveVersionIdByModuleId($id);
        }
        if ($version === null) {
            throw new Exception("Invalid module id: {$id}");
        }
        $category = "{$id}.{$version}.{$category}";
        return \Yii::t($category, $message, $params, $language);
    }
}

Примеры использования можно посмотреть в методе: \app\controllers\ExampleUserController::actionTestMessage().

Заключение

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

Комментарии

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