はじめに
多重送信ができてしまう
ボタンを連打したり、リロードしたり、色々システム操作の中で、同じフォームが重複して送信されることがあります。
開発しているシステムでも、こちらの対策をしておかないと、unique制約のバリデーションなどをすり抜けた上で重複して登録されてしまいます。
多重送信の対策として、Laravel 10 の環境下でやってみたことをメモします。
多重送信に備えて
その1 2回目以降のボタン無効化
ボタンの連打対応です。
JQueryで、ボタンが1回クリックされたらそのボタンは非活性にして、そのあとサブミットします。
これで連打されても2回目以降のリクエストは送られません。
フロントエンドの対応として一般的、かつ必須の方法かと思います。
<button id="submit" class="btn" type="submit" formaction="{{ $route }}">登録</button>
($route
には操作したいControllerへのルート名を指定します)
$("#submit").click(function () {
$(this).prop("disabled", true); // ボタン非活性にする
$("#submit").submit(); // ボタン非活性にしたあと、サブミットする
});
その2 トークンを再取得する
その1の対策に加え、バックエンド側でも対策が必要です。
よく二重送信防止で検索すると出てきます。
CSRF対策で生成されたトークンを再生成して、2回目以降のリクエストがトークン不一致で、無効となるようにします。
$request->session()->regenerateToken();
フォームの方はこんな感じ
<form method="post" action="{{ $route }}" enctype="multipart/form-data">
@csrf
<label for="name">名前</label>
<input id="name" name="name" type="text">
// {{-- 他にもいろいろ --}}
<div class="my-3 text-center">
<button id="submit" class="btn" type="submit" formaction="{{ $route }}">登録</button>
</div>
</form>
その1の機能を一旦オフにして連打してみます。
ここで気づきます。
あれ。送信できてしまう。。。
複数レコードが同時刻で作成されてしまいました😵
タイミングによっては、トークンの書き換えが行われる前に処理が進んでしまうため、多重リクエストが可能となってしまうようです。
こちらの記事などで詳しく解説されています。
その3(記事のメイン)
先輩方に相談すると、、、1分後、ありがたいお言葉をいただきます。
👀
Atomic locks allow for the manipulation of distributed locks without worrying about race conditions. (アトミックロックを使用すると、競合状態を気にすることなく分散ロックを操作できます。)
とのこと。なんと。
処理実行時にロックしてみる
ドキュメントの通り、設定してみます。
$lock = Cache::lock('key', 10);
if ($lock->get()) {
DB::beginTransaction();
try {
// 処理
} catch (Throwable $e) {
DB::rollBack();
Log::error($e->getMessage());
}
DB::commit();
$lock->release();
}
こちらでも同様の処理です
Cache::lock('key', 10)->get(function () use ($data) {
DB::beginTransaction();
try {
// 処理
} catch (Throwable $e) {
DB::rollBack();
Log::error($e->getMessage());
}
DB::commit();
});
ポイントは、'key'
のところで、どう指定するか、
全リクエスト同じにすると、同じタイミングのリクエスト全部にロックがかかり、指定秒数以内のリクエストは429エラーになってしまいます。
クラス名やユーザーID、セッションIDでそれぞれユニークにする必要があります。
また、秒数はシステムによって、いい感じにする感じかと思われます。
指定している10秒以内に多重送信すると、429エラーが返り、処理は進みません🙆♀️
ミドルウェアで設定してみる
どこでこのロックを実行するかは、それぞれの目的によって異なるので、Middlewareで指定しなくてもいいかとは思いますが、こちらでもできました。
キャッシュロックをするMiddlewareを作成します。
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
class CacheLockMiddleware
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
* @param Request $request
*/
public function handle(Request $request, Closure $next): Response
{
// ① GETのリクエスト以外を対象
if ($request->method() === 'GET') {
return $next($request);
}
// ② keyを作る
// route名とセッションIDを指定することでリクエストごとでユニークになるように設定
$key = 'route_lock_' . $request->route()->getName() . '_' . $request->session()->getId();
// ③ 10sで ロックを指定
// 多重送信されると、429エラー、TOO MANY REQUESTSが返却
$lock = Cache::lock($key, 10);
if ($lock->get()) {
try {
return $next($request);
} finally {
$lock->release();
}
} else {
throw new HttpException(Response::HTTP_TOO_MANY_REQUESTS, 'Too Many Requests');
}
}
}
GET以外のリクエストで、作成したMiddlewareが作動し、複数送信を防いでくれます。
基本的に、
- POSTにはしておく
- PUTは二重に送信されても2回実行されるだけでいいけど
- DELETEは2回目エラーが出るよね
という考えのもと、念のためGET以外に設定しました。
ルート全体で、作成したMiddlewareを指定することができます。
Route::group(['middleware' => 'cache_lock'], function () {
// ルート指定
});
(Kernel.php
の middlewareAliases
に追加して有効化しています)
気をつけること
Atomic Locksの機能を使うには、キャッシュ領域について確認しなければならないようで、
複数台サーバーが立つ場合などには調整が必要だそうです。(勉強中です)
ドキュメントの最初にも書いていました。
To utilize this feature, your application must be using the memcached, redis, dynamodb, database, file, or array cache driver as your application's default cache driver. In addition, all servers must be communicating with the same central cache server.
おわり
対策の仕方は色々だと思いますが、それぞれレイヤーでどういった防ぎ方をするのか、
考えられる点を身につけて、気をつけて実装していきたいと思います。