🎻

[Symfony] DBマイグレーションの前に常にmysqldumpを実行するようにして心の平穏ゲットだぜ!

2020/06/09に公開

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

これを参考に、

  1. mysqldumpを実行する
  2. 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を実行するようにするだけでもかなり心の平穏が手に入る😇
GitHubで編集を提案

Discussion