公開日:2021.12.23

【Laravel】Reader/Writer 構成でリダイレクト後にも`sticky`効果を適用する

テクログphp

どうも!師走のわいです!

クリスマスも近いので、今回は良質な記事をプレゼントします!

実装環境

  • PHP 8.0.13
  • Laravel Framework 8.51.0
  • Amazon Aurora (MySQL 5.7 互換)

また、今回のサンプルはステートフルなアプリケーション向けの実装で、セッションを使用しています。

`sticky`とは

DBの負荷分散のために、下記設定をおこなうことはよくあると思います。

  • SELECT はReaderに対して実行
  • INSERT UPDATE DELETE はWriterに対して実行

Laravelではこの設定を config/database.php 内で簡単におこなうことができます。

さらに sticky オプションを有効にすると、INSERT UPDATE DELETE を実行した同一のHTTPリクエスト上では以後の SELECT をWriterに対して実行してくれます。

このおかげで、WriterからReaderへのデータ同期の遅延を考えずに、更新後の最新データを容易に取得することが可能になります。

参考:https://readouble.com/laravel/8.x/ja/database.html

しかし、今回この「同一のHTTPリクエスト上」というのが問題になりました。

「登録・編集フォームの値をPOSTして、問題なければDBに保存し、その後一覧画面にリダイレクトする」といったよくあるフローを実装しようとしたときに、リダイレクトされるので別のHTTPリクエストになってしまいSELECT がReaderに対しておこなわれ更新前のデータを表示してしまうということが起こりました。

本記事では、この問題に対する解決策を提示したいと思います。

実装方針

SELECT をReaderに向けるかWriterに向けるかは、以下の getReadPdo() メソッドで決められています。

<?php

namespace Illuminate\Database;

// ~~~~~~~~~~~~~~~~~~~~~~~~~~~

class Connection implements ConnectionInterface
{

// ~~~~~~~~~~~~~~~~~~~~~~~~~~~

    /**
     * Get the current PDO connection used for reading.
     *
     * @return \PDO
     */
    public function getReadPdo()
    {
        if ($this->transactions > 0) {
            return $this->getPdo();
        }

        if ($this->readOnWriteConnection ||
            ($this->recordsModified && $this->getConfig('sticky'))) {
            return $this->getPdo();
        }

        if ($this->readPdo instanceof Closure) {
            return $this->readPdo = call_user_func($this->readPdo);
        }

        return $this->readPdo ?: $this->getPdo();
    }

// ~~~~~~~~~~~~~~~~~~~~~~~~~~~

ここで注目すべきは、2つ目のif文です。

以下のどちらかの条件が成立するときに、SELECT がWriterに対しておこなわれるということです。

  • $this->readOnWriteConnection (Laravel自体は制御しない真偽値)が True
  • $this->recordsModified (DB更新があったときにLaravelが True にする)かつ sticky オプションが有効

1つ目の条件は、Laravelが制御していないプロパティの真偽値です。同クラスにこのプロパティを制御するための useWriteConnectionWhenReading($value = true) というpublicメソッドが定義されてありますが、Laravelのどこからも呼ばれていません。つまり、こちらは実装者が外部から SELECT の向け先を指定したいときに使用するものです。

2つ目の条件は、まさしく先ほど紹介した sticky オプションの機能です。こちらを使って、リダイレクト後にも sticky 効果を適用するか決定します。ちなみに、$this->recordsModified をpublicから取得するには同クラスの hasModifiedRecords() メソッドを使用します。

以上のことを踏まえて、2つのミドルウェアを作成します。

以下、リクエストの流れとミドルウェアの実装方針です。

  1. HTTPリクエスト①
  2. DB更新して一覧画面へリダイレクト
  3. ミドルウェア①
    1. レスポンスの型がリダイレクトであるか
    2. sticky オプションが有効であるか
    3. hasModifiedRecords() が True であるか(DB更新がおこなわれたか)
    4. 上記のすべてを満たしていたら、shouldConnectWriter をセッションに一時保存
  4. HTTPリクエスト②(リダイレクトのリクエスト)
  5. ミドルウェア②
    1. shouldConnectWriter がセッションにあるか確認
    2. あれば、useWriteConnectionWhenReading() を呼ぶ
  6. 一覧表示用の SELECT 処理(Writerに向く

実装内容

参考:https://readouble.com/laravel/8.x/ja/middleware.html

ミドルウェア①

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Database\Connection;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;

class AfterDataBaseStickiness
{
	private Connection $db;

	public function __construct(Connection $db)
	{
		$this->db = $db;
	}

	// コントローラの処理後に実行
	public function handle(Request $request, Closure $next)
	{
		$response = $next($request);

		if (! ($response instanceof RedirectResponse))
		{ // レスポンスがリダイレクトでない場合
			return $response;
		}

		if (! $this->db->getConfig('sticky'))
		{ // `sticky`オプションが有効でない場合
			return $response;
		}

		if (! $this->db->hasModifiedRecords())
		{ // DB更新がなかった場合
			return $response;
		}

		// 次のHTTPリクエスト上で`shouldConnectWriter`を取得できるようにする
		$request->session()->flash('shouldConnectWriter');

		return $response;
	}
}

ミドルウェア②

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Database\Connection;
use Illuminate\Http\Request;

class BeforeDataBaseStickiness
{
	private Connection $db;

	public function __construct(Connection $db)
	{
		$this->db = $db;
	}

	// コントローラの処理前に実行
	public function handle(Request $request, Closure $next)
	{
		if ($request->session()->exists('shouldConnectWriter'))
		{ // 今回のHTTPリクエスト上ではすべて、Writerに接続するようにする
			$this->db->useWriteConnectionWhenReading();
		}

		return $next($request);
	}
}

上記ミドルウェアの登録

あとは対象のルートに対して、ミドルウェアを指定してあげれば終わりです。

対象のルートが多い場合は、app/Http/Kernel.phpweb ミドルウェアグループの末尾に追加して routes/web.php の全ルートに適用すると良いと思います。重い処理はないので、問題ないです。

<?php

namespace App\Http;

use Illuminate\Foundation\Http\Kernel as HttpKernel;

class Kernel extends HttpKernel
{

// ~~~~~~~~~~~~~~~~~~~~~~~~~~~

	protected $middlewareGroups = [
		'web' => [
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~
			\App\Http\Middleware\BeforeDataBaseStickiness::class,
			\App\Http\Middleware\AfterDataBaseStickiness::class,
		],

// ~~~~~~~~~~~~~~~~~~~~~~~~~~~

さいごに

こんな感じで実装自体はかなりシンプルだと思います。

LaravelのCoreクラスが読みやすいのと、ちゃんとpublicメソッドが提供されていることに、とても助けられました。

これで臆せずDBの負荷分散ができますね!

以上、わいでした。

健闘を祈る!!

この記事を書いた人

わい

入社年2019年

出身地大阪

業務内容システム開発

特技または趣味芸人のラジオを聴く、ダイビング

わいの記事一覧へ

テクログに関する記事一覧