Гибкая модульная архитектура на Yii2 - Часть 3: Работа с базой данных и миграции
Часть 1: Подключение модулей, роутинг и события
Часть 2: Взаимодействие между модулями и интернационализация
Часть 3: Работа с базой данных и миграции
Часть 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]);
}
}
Модульные миграции
У модульных миграций есть несколько отличий от миграций ядра:
- Нужно указывать модуль и версию, с которыми мы хотим работать.
- Таблица, в которой хранится история миграций тоже должна быть для каждого модуля своя.
- Своя логика формирования пространств имен и путей к файлам.
Объявим новую консольную команду:
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.Заключение
В итоге у нас получилось приложение, обладающее гибкой модульной архитектурой. У нас есть возможность динамически добавлять и удалять модули. А так же иметь разные версии для каждого модуля и динамически подключать их. Каждый модуль самодостаточен: он хранит внутри свои словари, миграции, правила роутинга и обработчики событий. При этом модули могут легко взаимодействовать друг с другом.