Гибкая модульная архитектура на Yii2 - Часть 3: Работа с базой данных и миграции

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

В прошлой статье мы рассмотрели способы взаимодействия между модулями и организацию словарей для интернационализации приложения. Нам осталось немного подправить ActiveRecord модели и разнести по модулям миграции.

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

Работа с базой данных

Обычно структура базы данных повторяет структуру приложения. И будет полезным отражать принадлежность таблиц к модулям. Имя таблиц имеет следующий вид: moduleId_versionId_tableName. Т.е. таблица для модели \app\modules\example_billing\modules\v1\models\entities\ExampleInvoice будет называться example_billing_v1_example_invoice. Добиться этого просто. Для этого достаточно переопределить метод tableName() в базовом классе модульных ActiveRecord моделей (Обратите внимание: только модульных. Это изменение не затрагивает модели ядра).

namespace app\components;
use yii\helpers\Inflector;
use yii\helpers\StringHelper;
abstract class ModuleActiveRecord extends ActiveRecord
{
    /** @inheritdoc */
    public static function tableName()
    {
        $className = static::class;
        return
            static::getTablePrefix($className) .
            Inflector::camel2id(StringHelper::basename($className), '_');
    }

    /**
     * Формирует префикс для имени таблицы.
     *
     * @param string $className
     * @return string
     */
    public static function getTablePrefix($className)
    {
        list(, $idModule, $tail) = explode('\\modules\\', $className);
        if (!$idModule) {
            return '';
        }
        $idVersion = explode('\\', $tail)[0];
        return $idModule . '_' . $idVersion . '_';
    }
}

Метод getTablePrefix() формирует префикс, который добавляется к стандартному названию таблицы в tableName().

Миграции

Перед работой с базой данных, ее надо заполнить - создать необходимую структуру. Для этого используются миграции. И их тоже надо хранить внутри модуля. Но здесь все несколько сложнее. Не достаточно просто разнести файлы по разным директориям. По умолчанию Yii2 не использует пространства имен для миграций. Из-за этого будут возникать конфликты, если в нескольких модулях будут миграции с одинаковыми названиями (а они обязательно будут). Важно помнить, что работа с миграциями ядра и модульными миграциями происходит немного по-разному.

Начнем с миграций ядра. Переопределим консольную команду для работы с миграциями.

// app/config/console.php
return \yii\helpers\ArrayHelper::merge(require(__DIR__ . '/common.php'), [
    // ...
    'controllerNamespace' => 'app\commands',
    'controllerMap' => [
        'migrate' => [
            'class' => \app\commands\MigrateController::class,
        ],
    ],
]);

Так же переопределим шаблон для создаваемых миграций. Добавим в него объявление пространства имен.

// app\views\migration.php
namespace <?= $namespace; ?>; // Добавилась эта строка.

use yii\db\Schema;
use yii\db\Migration;

class <?= $className ?> extends Migration
{
// ...

Создание миграций

Теперь нам надо переопределить метод actionCreate(), чтобы в шаблон передавалось еще и пространство имен. Из-за не очень удачной (на мой взгляд) структуры этого класса в фреймворке, изменить поведение получится только полностью заменив метод.

namespace app\commands;
use yii\console\Exception;
use yii\helpers\Console;
class MigrateController extends \yii\console\controllers\MigrateController
{
    /** @inheritdoc */
    public $templateFile = '@app/views/migration.php';
    /** @inheritdoc */
    public $migrationTable = 'migration';
    /** @inheritdoc */
    protected $namespace = 'app\migrations';

    /** @inheritdoc */
    public function actionCreate($name)
    {
        if (!preg_match('/^\w+$/', $name)) {
            throw new Exception("The migration name should contain letters, digits and/or underscore characters only.");
        }

        $className = 'm' . gmdate('ymd_His') . '_' . $name;

        // По сравнению со стандартной логикой поменялись только следующие три строчки. Остальное не тронуто.
        $namespace = $this->namespace;
        $fullClassName = "$namespace\\{$className}";
        $file = $this->getFileOfClass($fullClassName);

        if ($this->confirm("Create new migration '$file'?")) {
            $content = $this->renderFile(\Yii::getAlias($this->templateFile), [
                'className' => $className,
                'namespace' => $namespace,
            ]);
            file_put_contents($file, $content);
            $this->stdout("New migration created successfully.\n", Console::FG_GREEN);
        }
    }

    /**
     * Формирует имя файла с миграцией на основе полного имени класса.
     *
     * @param string $className Полное имя класса.
     * @return string Путь к файлу.
     */
    protected function getFileOfClass($className)
    {
        $alias = '@' . str_replace('\\', '/', $className);
        return \Yii::getAlias($alias) . '.php';
    }
    // ...
}

Таким образом, вызвав ./yii migrate/create my_migration, мы создадим класс с подобным названием: \app\migrations\m160409_122700_my_migration. Находится этот класс будет в положенной для него директории.

Применение и отмена миграций

Для этого переопределим методы migrateUp() и migrateDown(). Мы рассмотрим только метод migrateUp(). migrateDown() работает аналогично.

namespace app\commands;
use yii\console\Exception;
use yii\helpers\Console;
class MigrateController extends \yii\console\controllers\MigrateController
{
    /** @inheritdoc */
    protected function migrateUp($class)
    {
        if ($class === self::BASE_MIGRATION) {
            return true;
        }
        $this->stdout("*** applying $class\n", Console::FG_YELLOW);
        $start = microtime(true);

        // По сравнению со стандартной логикой поменялись только следующие три строчки. Остальное не тронуто.
        $namespace = $this->namespace;
        $fullClass = "{$namespace}\\{$class}";
        $migration = $this->createMigration($fullClass);

        if ($migration->up() !== false) {
            $this->addMigrationHistory($class);
            $time = microtime(true) - $start;
            $this->stdout("*** applied $class (time: " . sprintf("%.3f", $time) . "s)\n\n", Console::FG_GREEN);
            return true;
        } else {
            $time = microtime(true) - $start;
            $this->stdout("*** failed to apply $class (time: " . sprintf("%.3f", $time) . "s)\n\n", Console::FG_RED);
            return false;
        }
    }

    /** @inheritdoc */
    protected function createMigration($class)
    {
        $file = $this->getFileOfClass($class);
        require_once($file);
        return new $class(['db' => $this->db]);
    }
}

Модульные миграции

У модульных миграций есть несколько отличий от миграций ядра:

  1. Нужно указывать модуль и версию, с которыми мы хотим работать.
  2. Таблица, в которой хранится история миграций тоже должна быть для каждого модуля своя.
  3. Своя логика формирования пространств имен и путей к файлам.

Объявим новую консольную команду:

namespace app\commands;
use app\components\ModuleActiveRecord;
class ModuleMigrateController extends MigrateController
{
    public $moduleId;
    public $versionId;

    /** @inheritdoc */
    public function options($actionId)
    {
        return array_merge(parent::options($actionId), ['moduleId', 'versionId']);
    }
    // ...
}

Вызов ./yii module-migrate --moduleId=example_billing будет работать с миграциями, принадлежащими активной версии модуля example_billing. В случае необходимости, мы можем указать версию явно: ./yii module-migrate --moduleId=example_billing --versionId=v1.

Класс \app\commands\MigrateController был спроектирован таким образом, что нам нужно только подставить нужные значения для полей $migrationTable, $migrationPath и $namespace.

namespace app\commands;
use app\components\ModuleActiveRecord;
use app\models\entities\Module;
use app\models\entities\ModuleVersion;
use yii\console\Exception;
class ModuleMigrateController extends MigrateController
{
    /** @inheritdoc */
    public function beforeAction($action)
    {
        if (!parent::beforeAction($action)) {
            return false;
        }
        $this->prepare();
        return true;
    }

    protected function prepare()
    {
        // Ищем нужнуы модуль.
        if ($this->moduleId === null) {
            throw new Exception('$moduleId parameter is required.');
        }
        $module = Module::find()->byId($this->moduleId)->one();
        if (!$module) {
            throw new Exception("Invalid moduleId '{$this->moduleId}'");
        }

        $version = $this->versionId
            ? $module->getVersions()->byId($this->versionId)->one()
            : $module->activeVersion;
        if (!$version) {
            throw new Exception("Invalid versionId '{$this->versionId}'");
        }

        // Устанавливаем значения для полей.
        $this->namespace = $this->getNamespace($version);
        $this->migrationTable = $this->getMigrationTable($version);
        $this->migrationPath = $this->getMigrationPath($version);
    }

    /**
     * Формирует пространство имен.
     *
     * @param ModuleVersion $version
     * @return string
     */
    protected function getNamespace($version)
    {
        $class = $version->source;
        return substr($class, 1, strrpos($class, '\\')) . 'migrations';
    }

    /**
     * Формирует название для таблицы истории.
     *
     * @param ModuleVersion $version
     * @return string
     */
    protected function getMigrationTable($version)
    {
        $prefix = ModuleActiveRecord::getTablePrefix($version->source);
        return $prefix . $this->migrationTable;
    }

    /**
     * Формирует путь к директории с файлами миграций.
     *
     * @param ModuleVersion $version
     * @return string
     */
    protected function getMigrationPath($version)
    {
        $alias = '@' . str_replace('\\', '/', $this->getNamespace($version));
        return \Yii::getAlias($alias);
    }
    // ...
}

Теперь, вызвав ./yii module-migrate/create my_migrate --moduleId=example_billing --versionId=v1, мы создадим класс с подобным названием: \app\modules\example_billing\modules\v1\migrations\m160417_154728_example_migrate. А так же при применении миграции автоматически будет создана таблица example_billing_v1_migrations.

Заключение

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

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

Комментарии

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