Предотвращение повторной отправки форм с помощью атомарной блокировки

Источник: «Preventing Duplicate Form Submissions Using Atomic Locks»
Предотвращение повторных отправок форм и диспетчеризации заданий с помощью атомарных блокировок Laravel.

Повторные отправки форм или запросов могут быть распространённой проблемой в веб-приложениях, часто приводящей к непредвиденным последствиям. Laravel предлагает простое решение для предотвращения таких дубликатов с помощью атомарной блокировки. В этой статье мы рассмотрим реализацию атомарной блокировки для обеспечения того, чтобы отправленная форма обрабатывалась только один раз. Кроме того, мы рассмотрим, как атомарные блокировки могут предотвратить многократную диспетчеризацию одного и того же задания.

Атомарные блокировки позволяют манипулировать распределёнными блокировками, не опасаясь возникновения состояний гонки.

Рассмотрим сценарий, в котором пользователи могут инициировать платежи другим пользователям с помощью формы. Если пользователь отправляет форму несколько раз, мы хотим гарантировать, что будет обработан только первый запрос, а последующие будут проигнорированы. Учитывая, что эти транзакции имеют денежное выражение, многократная обработка запроса может привести к непреднамеренным многократным платежам для пользователя.

Рассмотрим пример контроллера SendPaymentController:

<?php

namespace App\Http\Controllers;

use App\Models\Account;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;

class SendPaymentController
{
public function __invoke(Request $request)
{
/* валидация запроса */

$account = $request->user()->accounts()->findOrFail($request->input('account'));

$recipient = Account::findOrFail($request->input('recipient'));

$amount = $request->input('amount');

/* обработка запроса */

return to_route('payments.create')
->with('status', [
'type' => 'success',
'message' => 'You have successfully sent a payment of '.number_format($request->input('amount'), 2).' to '.$recipient->name.'.',
]);
}
}

Как видно из приведённого выше кода SendPaymentController, текущая реализация сосредоточена только на валидации и обработке запроса, и это вполне нормально. Однако без дополнительных мер многократная отправка формы, естественно, приведёт к тому, что запрос будет обработан несколько раз. Такое поведение вполне ожидаемо, поскольку отсутствует механизм предотвращения. Рассмотрим, как можно решить эту проблему с помощью реализации атомарных блокировок.

Для создания атомарной блокировки мы используем метод Cache::lock, принимающий три аргумента:

name: Это имя блокировки. Очень важно использовать уникальное имя для каждой блокировки, для предотвращения коллизий и обеспечения их целевого назначения.

seconds: Этот аргумент задаёт продолжительность, в течение которой блокировка должна оставаться валидной.

Кроме того, метод Cache::lock предлагает необязательный третий аргумент — owner, который мы рассмотрим далее в этой статье.

// SendPaymentController

public function __invoke(Request $request)
{
/* валидация запроса */

$account = $request->user()->accounts()->findOrFail($request->input('account'));

$recipient = Account::findOrFail($request->input('recipient'));

$amount = $request->input('amount');

$lock = Cache::lock($account->id.':payment:send', 10);

if (! $lock->get()) {
return to_route('payments.create')
->with('status', [
'type' => 'error',
'message' => 'There was a problem processing your request.',
]);
}

/* обработка запроса */

return to_route('payments.create')
->with('status', [
'type' => 'success',
'message' => 'You have successfully sent a payment of '.number_format($request->input('amount'), 2).' to '.$recipient->name.'.',
]);
}

В приведённом выше коде мы создаём блокировку с именем {$account->id}:payment:send, действующую в течение 10 секунд. Если блокировка будет снята, мы обработаем запрос и перенаправим пользователя обратно на форму с сообщением об успехе. Если блокировка не будет снята, то мы перенаправим пользователя обратно на форму с сообщением об ошибке.

Сообщение об ошибке необязательно, вы можете просто перенаправить пользователя обратно на форму без какого-либо сообщения, но в данном примере мы покажем сообщение об ошибке.

🎉 Вот и все! Мы реализовали атомарные блокировки для предотвращения дублирования отправки форм.

Предотвращение многократной отправки задач

Рассмотрим ещё один пример использования атомарных блокировок для предотвращения многократной отправки задач.

Мы можем воспользоваться примером из предыдущего раздела и перенести код, обрабатывающий запрос, в задачу и отправить его.

Контроллер SendPaymentController может выглядеть следующим образом:

<?php

namespace App\Http\Controllers;

use App\Jobs\ProcessPayment;
use App\Models\Account;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;

class SendPaymentController
{
public function __invoke(Request $request)
{
/* валидация запроса */

$account = $request->user()->accounts()->findOrFail($request->input('account'));

$recipient = Account::findOrFail($request->input('recipient'));

$amount = $request->input('amount');

dispatch(new ProcessPayment($account, $recipient, $amount));

return to_route('payments.create')
->with('status', [
'type' => 'success',
'message' => 'You have successfully sent a payment of '.number_format($request->input('amount'), 2).' to '.$recipient->name.'.',
]);
}
}

В представленном коде мы отправляем задачу ProcessPayment для обработки запроса. Здесь также возникает та же проблема, что и в предыдущем разделе. Если пользователь отправит форму несколько раз, то задача будет отправлено несколько раз. Рассмотрим, как можно это предотвратить.

// SendPaymentController

public function __invoke(Request $request)
{
/* валидация запроса */

$account = $request->user()->accounts()->findOrFail($request->input('account'));

$recipient = Account::findOrFail($request->input('recipient'));

$amount = $request->input('amount');

$lock = Cache::lock($account->id.':payment:send', 10, 'account:'$account->id);

if (! $lock->get()) {
return to_route('payments.create')
->with('status', [
'type' => 'error',
'message' => 'There was a problem processing your request.',
]);
}

dispatch(new ProcessPayment($account, $recipient, $amount, $lock->owner()));

return to_route('payments.create')
->with('status', [
'type' => 'success',
'message' => 'You have successfully sent a payment of '.number_format($request->input('amount'), 2).' to '.$recipient->name.'.',
]);
}

Обновлённый код, приведённый выше, будет отлично работать; блокировка будет автоматически снята через 10 секунд. Но что, если мы хотим снять блокировку сразу после завершения задания, а не ждать 10 секунд до истечения срока действия блокировки?

Именно поэтому мы передали токен владельца блокировки в качестве четвёртого аргумента задаче ProcessPayment. Он будет использован для снятия блокировки после завершения задания.

Как это сделать, мы рассмотрим ниже в задаче ProcessPayment:

<?php

namespace App\Jobs;

use App\Models\Account;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;

class ProcessPayment implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

public function __construct(
private Account $account,
private Account $recipient,
private int $amount,
private string $owner,
) {
}

public function handle(): void
{
$lock = Cache::restoreLock($this->account->id.':payment:send', $this->owner);

DB::transaction(function () use ($lock) {
/* обработка запроса */

$lock->release();
});
}
}

В задаче ProcessPayment мы используем метод Cache::restoreLock, впервые появившийся в Laravel 5.8 благодаря вкладу @janpantel в pull request.

Данный метод принимает два аргумента:

name: Он соответствует имени блокировки и согласуется с тем, как мы её изначально создавали.

owner: Этот аргумент определяет токен владельца блокировки, который мы передали в качестве четвёртого аргумента задаче ProcessPayment.

Кроме того, мы инкапсулируем код обработки запроса на оплату в транзакцию базы данных. Наконец, чтобы обеспечить корректное снятие блокировки, мы вызываем метод release после завершения обработки платежа.

Следует отметить, что использование транзакций базы данных является необязательным и зависит от конкретных требований приложения. Однако я бы настоятельно рекомендовал использовать транзакцию базы данных, особенно при работе с финансовыми операциями.

Дополнительные материалы

Предыдущая Статья

Новое в Symfony 6.4: Улучшения безопасности

Следующая Статья

Веб-производительность и параллельная vs. waterfall загрузка