[Symfony] DBマイグレーションの前に常にmysqldumpを実行するようにして心の平穏ゲットだぜ!
DBマイグレーションの不安を和らげるために、マイグレーションを実行する前にmysqldumpを出力するようにしてみました。
ちゃんとBlue Green Deploymentしているようなプロジェクトでは無縁の話ですが、サーバー1台にアプリもDBも同居しているような古き良き構成ではとても役立ちます😇(実際それ系のプロジェクトでは、これがあるだけですごく心が平穏になっています💪)
やり方
マイグレーションを実行する際に doctrine:migrations:migrate
コマンドを直接使うのではなく、それをラップした独自コマンドを使うようにすれば簡単に実現できます。
コマンドから別のコマンドを呼び出す方法は下記の公式ドキュメントに解説があります。
How to Call Other Commands (Symfony Docs)
https://symfony.com/doc/current/console/calling_commands.html
これを参考に、
- mysqldumpを実行する
-
doctrine:migrations:migrate
コマンドを実行する
の2つを順に行うようなコマンドを作ればいいわけですね。
mysqldumpを実行する方法
Symfonyの Process Component を使えば簡単に別プロセスで外部コマンドを実行できます。
$process = Process::fromShellCommandline(sprintf('mysqldump -u%s -p%s %s > %s', $username, $password, $database, $pathToSave));
$process->setTimeout(60);
$process->run();
みたいな感じでOKです。簡単ですね!
doctrine:migrations:migrate
コマンドにそのまま渡す
コマンドが受け取ったオプションをすべて このコマンド経由で実行される doctrine:migrations:migrate
コマンドを、 --no-interaction
や --dry-run
といったオプション付きで実行したいことがあり得ます。(というか自動デプロイの際には --no-interaction
は必須です)
なので、このコマンド自身が受け取ったオプションを、そのまま doctrine:migrations:migrate
コマンドに渡すように実装しておきます。
$migrationCommand = $this->getApplication()->find('doctrine:migrations:migrate');
$arguments = [];
foreach ($this->getDefinition()->getOptions() as $inputOption) {
$arguments['--'.$inputOption->getName()] = $input->getOption($inputOption->getName());
}
return $migrationCommand->run(new ArrayInput($arguments), $output);
こんな感じでできそうです。
と思いきや、この実装で実行してみると、なぜか --no-interaction
をつけていても doctrine:migrations:migrate
コマンドで
WARNING! You are about to execute a database migration that could result in schema changes and data loss. Are you sure you wish to continue? (y/n)
と聞かれてしまいます🤔
ググったら下記を見つけました。
php - Symfony command --no-interaction is not working - Stack Overflow
https://stackoverflow.com/questions/52119220/symfony-command-no-interaction-is-not-working
これを参考に下記のようにコードを修正してみたところ、 --no-interaction
をつけておけば確認プロンプトが出ないようになりました🙌(内部の処理まで追ってないので理屈は分かってません🙏)
$migrationCommand = $this->getApplication()->find('doctrine:migrations:migrate');
$arguments = [];
foreach ($this->getDefinition()->getOptions() as $inputOption) {
$arguments['--'.$inputOption->getName()] = $input->getOption($inputOption->getName());
}
$migrationInput = new ArrayInput($arguments);
$migrationInput->setInteractive(!$input->getOption('no-interaction'));
return $migrationCommand->run($migrationInput, $output);
最終的なコマンドのコード
コマンドのコード全体は以下のようになります。
<?php
// src/Command/DatabaseMigrateCommand.php
namespace App\Command;
use Doctrine\DBAL\Driver\Connection;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process;
class DatabaseMigrateCommand extends Command
{
protected static $defaultName = 'app:database:migrate';
private $username;
private $password;
private $database;
public function __construct(Connection $connection)
{
parent::__construct();
$this->username = $connection->getUsername();
$this->password = $connection->getPassword();
$this->database = $connection->getDatabase();
$this->host = $connection->getHost();
}
protected function configure()
{
$this
->setDescription('mysqldumpを保存した上でデータベースマイグレーションを実行する')
->addOption('dry-run', '', InputOption::VALUE_NONE, 'Execute the migration as a dry run.')
// todo: add all options of doctrine:migrations:migrate command
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$pathToSave = sprintf('%s/mysqldump/%s.sql', realpath(__DIR__.'/../..'), date('YmdHis'));
$process = Process::fromShellCommandline(sprintf('mysqldump -u%s -p%s -h%s %s > %s', $this->username, $this->password, $this->host, $this->database, $pathToSave));
$process->setTimeout(60);
$process->run();
$this->ensureSuccessful($process);
$io->success(sprintf('"%s" にmysqldumpを保存しました', $pathToSave));
$migrationCommand = $this->getApplication()->find('doctrine:migrations:migrate');
// pass options to doctrine:migrations:migrate command
$arguments = [];
foreach ($this->getDefinition()->getOptions() as $inputOption) {
$arguments['--'.$inputOption->getName()] = $input->getOption($inputOption->getName());
}
$migrationInput = new ArrayInput($arguments);
$migrationInput->setInteractive(!$input->getOption('no-interaction'));
return $migrationCommand->run($migrationInput, $output);
}
private function ensureSuccessful(Process $process)
{
if (!$process->isSuccessful()) {
throw new ProcessFailedException($process);
}
}
}
これで、 doctrine:migrations:migrate
コマンドの代わりに app:database:migrate
コマンドを使うようにするだけで、 /path/to/project/mysqldump/{YmdHis}.sql
というファイル名でmysqldumpをとった上でマイグレーションが実行されるようになり、心の平穏が手に入ります。
おまけ
上記のコードの
protected function configure()
{
$this
->setDescription('mysqldumpを保存した上でデータベースマイグレーションを実行する')
->addOption('dry-run', '', InputOption::VALUE_NONE, 'Execute the migration as a dry run.')
// todo: add all options of doctrine:migrations:migrate command
;
}
この部分を見て気付いた方もいるかもしれませんが、 doctrine:migrations:migrate
コマンドが取りうるオプションのすべてをこのコマンドでも受け取れるようにするには、普通に configure()
内で1つ1つ addOption()
するしかありません。
はじめは $this->getApplication()->find('doctrine:migrations:migrate')
で取得した Command
のインスタンスに対して getDefinition()->getArguments()
や getDefinition()->getOptions()
を使って定義済みの引数・オプションをすべて取得して、それをそのまま自分自身に add
するというようなことをやろうとしたのですが、 configure()
内では $this->getApplication()
の結果が null
になってしまって無理でした😓(どうやらこの時点ではまだ Application
にコマンドが登録されていないようです)
Doctrine\Migrations\Tools\Console\Command\MigrateCommand
を継承してコマンドを作れば行けるか?とかも考えましたが、一瞬やろうとしてみたらなんか色々エラーが出たので諦めました笑
他に何か賢い方法が分かる方いらっしゃいましたら Twitter 等で教えていただけるとありがたいです🙏
まとめ
- Symfonyで本番DBが容易にロールバックできないインフラ環境の場合はDBマイグレーションの前に常にmysqldumpを実行するようにするだけでもかなり心の平穏が手に入る😇
Discussion