Laravelを拡張して、使用したコネクションに対して自動的にトランザクションを張るようにする

この記事は

この記事は GMOペパボ Advent Calendar 2018 の12月26日の記事です。

私はペパボのひとではありませんが、26日目なら名乗ってもOKとのことだったので、これは26日目の記事(非公式)です!

こんにちは。PHPエンジニアの千葉です。すっかり寒くなって毎朝おふとんからなかなか出られません。。

さて。先日、Laravelフレームワークが使われている開発中のとある機能を動かしていたとき、例外が発生したのにロールバックされていないデータがあることに気が付きました。コードを見ると、トランザクション処理は入っていました。

なぜロールバックされなかったのか、そしてそれにどのように対処したのかをまとめた記事です。

Laravelのトランザクション処理はこうやって書ける

DB::transaction(function () {
    $item = Item::find(1);
    $item->price(100);
    $item->save();
});

ものすごくシンプルで簡単ですね。ネスト構造でもかけちゃいます。

function main() {
    DB::transaction(function () {
        $item = Item::find(1);
        $item->price(100);
        $item->save();

        $this->next();
    });
}

function next() {
    DB::transaction(function () {
        $history = History::find(1);
        $history->comment("TEST");
        $history->save();
    });
}

ただ、この書き方はデフォルトに設定しているコネクションに対してしかトランザクションが開始されません。

一部のデータがロールバックされなかった原因

/config/database.phpを確認すると、デフォルトで使われるコネクションはcommonとなっています。

'default' => 'common',
'connections' => [
    'common' => [
        'driver' => 'mysql',
        'host' => env('DB_HOST', '127.0.0.1'),
        'port' => env('DB_PORT', '3306'),
        'database' => 'common',
         // 中略
    ],
    'product' => [
        'driver' => 'mysql',
        'host' => env('DB_HOST', '127.0.0.1'),
        'port' => env('DB_PORT', '3306'),
        'database' => 'product',
         // 中略
    ],
];

先程のサンプルで登場したModelクラスも見てみます。Historyはcommon、Itemはproductコネクションを使っていることがわかります。

class History extends Model
{
    protected $connection = 'common';
}

class Item extends Model
{
    protected $connection = 'product';
}

先程の書き方だと、デフォルトのcommonコネクションに対するトランザクションしか張られていないため、途中で例外が発生してもItemに対する更新はロールバックされませんでした。

こういった場合は、このように実装するべきでした。

DB::connection('common')->beginTransaction();
DB::connection('product')->beginTransaction();

try {
    $item = Item::find(1);
    $item->price(100);
    $item->save();

    $this->next();

    DB::connection('common')->commit();
    DB::connection('product')->commit();
} catch (\Throwable $e) {
    DB::connection('common')-> rollBack();
    DB::connection('product')-> rollBack();
}

どうやって直そう

先のコードを見ていただくと伝わるんじゃないかとおもいますが、
どのコネクションに対して更新が行われるか、ぱっと見わかりません。

この部分に対する理解、注意力がなければ実装やレビューからも抜ける可能性が高く、ちょっと怖い感じです。。

そこで、LaravelのDB操作をするクラスをオーバーライドして、使用したコネクションに対して自動的にトランザクションを張るようにしました。

こうやって実現しました

①MySqlConnectionの代わりにCustomConnectionを使うようにする

MySQL接続時にはMySqlConnectionクラスが使われますが、これを継承したCustomConnectionクラスを使うように指定します。

use App\CustomConnection;
use Illuminate\Support\ServiceProvider;
use Illuminate\Database\Connection;


class AppServiceProvider extends ServiceProvider
{
    public function register()
    {
        Connection::resolverFor('mysql', function (...$parameters) {
            return new CustomConnection(...$parameters);
        });
    }
}

②コネクション情報をCustomManagesTransactionで管理する

①のコードによって、コネクションが開始されるとCustomConnectionクラスのコンストラクタが呼ばれるようになっています。このタイミングで接続を試みたコネクションをCustomManagesTransaction::$connectionに格納しておくようにします。

use Illuminate\Database\MySqlConnection;

class CustomConnection extends MySqlConnection
{
    public function __construct($pdo, $database = '', $tablePrefix = '', array $config = [])
    {
        CustomManagesTransaction::getInstance()->setConnection($config['name']);
        parent::__construct($pdo, $database, $tablePrefix, $config);
    }
}
use Illuminate\Support\Facades\DB;
use Illuminate\Database\Concerns\ManagesTransactions;
use Illuminate\Database\DetectsDeadlocks;

class CustomManagesTransaction
{
    private $transactions = 0;
    private $connection = [];
    private static $instance;

    final public static function getInstance()
    {
        if (!self::$instance) {
            self::$instance = new static();
        }

        return self::$instance;
    }

    public function setConnection($name)
    {
        if (array_search($name, $this->connection) !== false) {
            return;
        }

        $this->connection[] = $name;
    }

    public function getConnection()
    {
        return $this->connection;
    }
}

③各コネクションに対して自動的にbeginTransaction()を実行させる

beginTransaction()、commit()、rollBack()が実行されたら、CustomManagesTransactionが保持している「接続が行われたことのあるコネクション」を取得します。取得できたコネクションすべてに対してbeginTransaction()、commit()、rollback()を実行させるようにします。

ちなみに、setConnection()にも修正が入っているのは、トランザクションが開始されたあと、初めて接続するコネクションに対してトランザクションを開始させるタイミングがここしかないからです。

トランザクション実行回数が $this->transactionsに入っているので、この回数に追いつくまでbeginTransaction()を実行させておきます。

use Illuminate\Support\Facades\DB;
use Illuminate\Database\Concerns\ManagesTransactions;
use Illuminate\Database\DetectsDeadlocks;

class CustomManagesTransaction
{
    public function setConnection($name, CustomConnection $connection)
    {
        if (array_search($name, $this->connection) !== false) {
            return;
        }

        if ($this->transactions >= 1) {
            for ($i = 0; $i < $this->transactions; $i++) {
                $connection->beginTransaction();
            }
        }

        $this->connection[] = $name;
    }

    public function beginTransaction()
    {
        $connections = CustomManagesTransaction::getInstance()->getConnection();
        foreach ($connections as $connection) {
            DB::connection($connection)->beginTransaction();
        }

        $this->transactions++;
    }

    public function commit()
    {
        if ($this->transactions <= 0) {
            throw new \LogicException();
        }

        $connections = CustomManagesTransaction::getInstance()->getConnection();
        foreach ($connections as $connection) {
            DB::connection($connection)->commit();
        }

        $this->transactions--;
    }

    public function rollBack($toLevel = null)
    {
        $toLevel = is_null($toLevel)
            ? $this->transactions - 1
            : $toLevel;
        if ($toLevel < 0 || $toLevel >= $this->transactions) {
            throw new \LogicException();
        }

        while ($this->transactions > $toLevel) {
            $connections = CustomManagesTransaction::getInstance()->getConnection();
            foreach ($connections as $connection) {
                DB::connection($connection)->rollBack();
            }

            $this->transactions--;
        }
    }
}

④トランザクションメソッドを差し替える

最後にDB::transaction()をCustomManagesTransaction::getInstance()->transaction()に書き換えます。

CustomManagesTransaction::getInstance()->transaction(function () {
    $item = Item::find(1);
    $item->price(100);
    $item->save();

    $this->next();
});

これで例外が発生しても、commonとproductに対する更新すべてロールバックされるようになりました!ヽ(´ー`)ノ

フレームワークは便利ですが、過信せずに例外処理もちゃんとテストやりましょう。(とても自戒orz)それでは良いLaravelライフを!