支持laravel.io的持续开发 →

使用原子锁防止重复表单提交

7 Mar, 2024 6 min read

重复表单提交或请求是网络应用中常见的问题,经常导致 unintended consequences. 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)
    {
        /* validate the request */

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

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

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

        /* process the 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.'.',
            ]);
    }
}

如上 SendPaymentController 代码所示,当前的实现仅关注请求验证和处理,这工作是正常的。然而,如果没有额外的措施,多次提交表单将自然导致请求被多次处理。这种预期行为是因为没有设置预防机制。让我们探索如何通过实施 原子锁 来解决这个问题。

要创建一个原子锁,我们使用Cache::lock方法,它接受三个参数

name:这是锁的名称。为每个锁使用一个唯一的名称对于防止冲突和确保其预定用途至关重要。

seconds:该参数指定锁应该有效的持续时间。

此外,Cache::lock方法还提供一个可选的第三个参数owner,我们将在本文稍后讨论。

// SendPaymentController

public function __invoke(Request $request)
{
    /* validate the 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.',
            ]);
    }

    /* process the 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)
    {
        /* validate the 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)
{
    /* validate the 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) {
            /* process the request */

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

在我们的ProcessPayment作业中,我们使用了Cache::restoreLock方法,该方法首次在Laravel <strong>5.8</strong>中引入,是由pull request的贡献者@janpantel引入的。

此方法接受两个参数

name:这对应于锁的名称,并符合我们最初创建锁的方式。

owner:该参数指定锁的所有者标记,这是我们作为ProcessPayment作业的第四个参数传递的。

此外,我们将支付请求处理代码封装在数据库事务中。最后,为了确保正确释放锁,一旦支付处理完成,我们调用release方法。

值得注意的是,使用数据库事务是可选的,具体取决于您的特定应用程序要求。但是,我强烈建议在处理财务事务时使用数据库事务。

最后更新时间:4个月前。

driesvints, antoniputra 赞同了这篇文章

2
喜欢这篇文章? 让作者知道并为他们鼓掌!

你可能还喜欢别的文章

如何使用 Larastan 将你的 Laravel 应用从 0 升级到 9

在 Laravel 应用执行前找到错误,得益于 Larastan...

阅读文章

在不使用 traits 的情况下标准化 API 响应

我注意到大多数用于 API 响应的库都是...

阅读文章

通过 Discord 通知在 Laravel 项目中收集反馈

如何在 Laravel 项目中创建反馈模块并在收到消息时从 Discord 收到通知...

阅读文章

我们想感谢这些令人惊叹的公司 为我们的支持

您的标志在这里?

Laravel.io

Laravel 问题解决,知识共享和社区建设的门户。

© 2024 Laravel.io - 版权所有。