エラーや例外でDBをロールバックするMiddlewareと、LaravelのMiddlewareの小話


なにがしたい?

データベーストランザクションを、Middlewareとして定義して、後は何も意識せず自動的に適用されるようにしたい。と思ってググってみたのですが意外とまとまった情報がないし、いきなりコケたのもあって、他の方のお役に立てればと思い、経験談をまとめておきます。

結論

これつかってください。
https://gist.github.com/aambrozkiewicz/5b38416fc84a824649b1a137bd4fa84c

app/Http/Middleware/DBTransaction.php
<?php
namespace App\Http\Middleware;
use Closure;
class DBTransaction
{
    public function handle($request, Closure $next)
    {
        \DB::beginTransaction();
        $response = $next($request);
        if ($response->exception) {
            \DB::rollBack();
        } else {
            \DB::commit();
        }
        return $response;
    }
}

次に実際にこのMiddlewareを通す方法ですが、推奨プランは、下記のように、Kernelに3行だけ追記します。

app/Http/Kernel.php
    //...

    protected $middlewareGroups = [
        'web' => [
            //...
            'dbtransaction', // すべての web ルートに適用
        ],

        'api' => [
            //...
            'dbtransaction', // すべての api ルートに適用
        ],
    ];

    protected $routeMiddleware = [
        //...
        'dbtransaction' => \App\Http\Middleware\DBTransaction::class, // 定義
    ];

    //...

注意点

  • 1回のリクエストをまるごと BEGIN と COMMIT で囲うので、その中の処理の途中で BEGIN や COMMIT を使っている既存システムにはうまく載らないかもしれません。
  • ていうかそもそも、1回のリクエスト内の処理数はできるだけ減らしましょう。
  • DB::beginTransaction()コネクション単位なので、複数のコネクションを使用しているシステムでは、それなりの工夫をしてください(投)
  • エラーログやセッション情報など、エラーが起きても残しておきたい情報は、それを逆に利用して、コネクションを分けると良いかもしれません。詳しくは後述。
  • ミドルウェアの適用順序次第でうまくいくよ!というのはLaravelのミドルウェアの設計思想に反する気がするので非推奨としたい(´ε` )。順番に依存するミドルウェアはご容赦ください。

解説

データベースの「トランザクション」とは?

データベースには「トランザクション」というコンセプトがあって、処理が途中でコケたら、それまでに行っていた中間的な処理を全部破棄してなかったことにすることができます。例えば「送金処理」があったとして、

  1. Aさんの口座から10,000円出金
  2. Bさんの口座に10,000円入金

とするところ

  1. Aさんの口座から10,000円出金
  2. 闇の力で予期しない例外が発生!

となったら大変です。10,000円が消えてなくなります。そのため

  1. ここから処理スタート(BEGIN TRANSACTION)
  2. Aさんの口座から10,000円出金(仮実行)
  3. Bさんの口座に10,000円入金(仮実行)
  4. すべての仮実行を確定する(COMMIT)

または

  1. ここから処理スタート(BEGIN TRANSACTION)
  2. Aさんの口座から10,000円出金(仮実行)
  3. 闇の力で予期しない例外が発生!
  4. 最初から全部なかったことにする(ROLLBACK)

このようにして、エラーが起きても情報に「矛盾」が残るのを防ぎます。

ミドルウェア

ユーザーとLaravelがやり取りするときの「リクエスト」に対する前処理と「レスポンス」に対する後処理を、共通化してカンタンに管理しておくための仕組みです。

Middlewareと実処理(コントローラなど)の位置関係としてはこのように並列して連結されているイメージですが、実際にはMiddlewareの処理 handle メソッドの中に次のミドルウェアを含んでいるので、下記のようなイメージが実態に近いです。(図では書きづらいけど)

今回のケースだと、リクエストの前処理として「トランザクションの開始」、レスポンスの後処理として「コミット」または「ロールバック」すれば良さそうです。

非推奨な方法論 try & catch

例えばこちら。
https://gist.github.com/rodrigopedra/a4a91948bd41617a9b1a

try&catchで例外を捕捉しようという試みです。ふつうのPHPらしいやりかたですし、Googleで検索すると一番に出てくるので最初はこちらを使っていたんですが、条件によってはうまくいきません。

    public function handle($request, Closure $next)
    {
        \DB::beginTransaction();
        try {
            $response = $next($request);
        } catch (\Exception $e) {
            \DB::rollBack();
            throw $e;
        }
        if ($response instanceof Response && $response->getStatusCode() > 399) {
            \DB::rollBack();
        } else {
            \DB::commit();
        }
        return $response;
    }

なぜかというと……。実際に動かしてみたり、Laravelの仕組みを見てみるとわかるのですが、すべてのミドルウェアやプロセスは、それ自体が予め、ぴったりサイズの try&catch でラッピングされているからです。何をしているかというと、処理中に発生したすべての例外を、標準のエラーハンドラに渡そうとしています。

なので、上記、try&catchのミドルウェアのこの部分

        } catch (\Exception $e) {
            \DB::rollBack();
            throw $e;
        }

は実際には通過しません。
ならば、と。しかも実際に動いたとして、この段階で出てくる Exception の中身は、実際に発生したエラーの内容ではなく、Internal Server Errorに置き換わっていたりします。すでにエラーハンドラがエラーをきれいに処理してくれている状態。
ならば、と、どんどん深いところへ潜っていってしまいましたが、そもそもこのコードを使っていたことが間違いだと気づくにはもう少し時間が必要でした。

というわけで、ミドルウェアでむやみに try&catch を追加するのは、標準のエラーハンドラに良からぬ影響を与えかねないので非推奨。(とはいえ、エラーログが正常に出てこない、程度の影響しか無いように思います。)

エラーログが出てこない

程度の影響しか無い、と言いましたが、うちのシステムでは「エラーログはDBに書き込む」としていたので、このミドルウェアが動くとエラーログもRollbackされてしまって何も出てこなくなります

.env
DB_LOG_TABLE=logs
DB_LOG_CONNECTION=mysql
DB_LOG_FLUSH_RATIO=100
DB_LOG_PRESERVE_DAYS=30

エラーログをデータベースにするのはLaravel標準手法で、テーブルを用意して.envに追記するだけなのでとてもカンタン♬ でもRollbackされたら困ります。

最も簡単な方法は、DBのコネクションを分離することです。

Laravelのトランザクションは「コネクション単位」なので、コネクションを分けるとロールバックの影響を受けなくなります。しかも方法は簡単で……

cinfig/database.php
    'connections' => [

        'mysql' => [
           // この内容を
        ],

        // 新しくコネクションを追加して
        'mysql_log' => [
           // まるごとコピペ。全く同じ内容でOK
        ],
.env
DB_LOG_TABLE=logs
DB_LOG_CONNECTION=mysql_log  # 変更する
DB_LOG_FLUSH_RATIO=100
DB_LOG_PRESERVE_DAYS=30

コネクションを分けたくない場合は、例えば、ミドルウェア内で、エラーをキャッチしたら、もう一回投げ直す、という手もあります。

app/Http/Middleware/DBTransaction.php
        if ($response->exception) {
            \DB::rollBack();
            throw $response->exception; // 追記
        } else {

こうすると、DBをロールバックした後に改めてエラー処理が走ります。が、これで書き込まれるのは「最後のエラー」だけです。処理中に INFO や NOTICE として記録したエラーログは破棄されてしまうので、やはりコネクションを分けるのを推奨します。

リードレプリカを使った複数構成にしている

Laravelは、書き込み専用のマスター1台、読み込み専用レプリカ複数台の負荷分散構成に対応していますが、トランザクションを効かせるとこれを無視して、すべてマスターに接続しに行く、ということをしています。(していました。知らずにこのミドルウェアを入れた途端に、全接続がマスターに集中してサーバーを落としかけたのは内緒。)

ソースコードだとココです。transactionsが1以上のときは、ReadPdo をくれというメソッドが getPdo(マスター)を返しています。

vendor/laravel/framework/src/Illuminate/Database/Connection.php
    public function getReadPdo()
    {
        if ($this->transactions >= 1) {
            return $this->getPdo();
        }
        // ...
    }

この場合は大変ご面倒ですが、書き込みが発生するエンドポイントのみ、ミドルウェアを通すようにしてはどうか……と思っています。
(そもそも、読み込み専用のエンドポイントにトランザクションは必要ないし、書き込みするエンドポイントでは不整合を防ぐために読み込みもマスターを参照スべきなので、とても理にかなっています。)
しかしめんどくさいので、他に方法がないか検証中です。

routes/api.php
Route::get   ('order_number',  ['uses' => 'Api\OrderController@order_number']);
Route::get   ('order_summary', ['uses' => 'Api\OrderController@order_summary']);
Route::post  ('/',             ['uses' => 'Api\OrderController@store',   'middleware' => 'dbtransaction']);
Route::delete('{id}',          ['uses' => 'Api\OrderController@destroy', 'middleware' => 'dbtransaction']);
Route::put   ('{id}',          ['uses' => 'Api\OrderController@update',  'middleware' => 'dbtransaction']);

感想

ぜひ「フレームワーク標準化」してほしいです! 何も知らない人が何も知らないままシステムを作ったらトランザクションがうまくいく、くらいのものを期待♡