支持Laravel.io的持续发展 →

使用数据库事务编写更安全的Laravel代码

2021年9月21日 阅读时间约11分钟

介绍

在Web开发中,数据完整性和准确性非常重要。因此,确保我们编写的代码以安全的方式将数据存储、更新和删除到数据库中至关重要。

在本文中,我们将探讨数据库事务是什么,为什么它们很重要,以及如何在Laravel中使用它们。我们还将探讨与队列作业和数据库事务相关的一个常见的“陷阱”。

数据库事务是什么?

在开始研究Laravel中的数据库事务之前,让我们看看它们是什么以及它们的优势。

关于数据库事务有很多技术性的、听起来很复杂的概念解释。但是,对于我们这些Web开发者来说,我们只需要知道交易是作为数据库中一项整体的单元工作来完成的方式。

为了理解这究竟是什么意思,让我们看看一个非常基础的例子,这将提供一些背景信息。

假设我们有一个允许用户注册的应用程序。每当用户注册时,我们都想为他们创建一个新的账户,并分配默认的角色“普通”。

我们的代码可能看起来像这样

$user = User::create([
    'email' => $request->email,
]);

$user->roles()->attach(Role::where('name', 'general')->first());

乍一看,这段代码似乎完全没问题。但当我们仔细观察时,我们可以发现实际上存在一些可能出错的地方。可能出现的情况是我们可以创建用户,但未能分配他们的角色。这可能是由许多不同的事情引起的,比如分配角色的代码中存在错误,甚至是阻止我们到达数据库的硬件问题。

由于这种情况发生,这意味着我们系统中将存在一个没有角色的用户。你可以想象,这很可能会导致你应用程序其他地方的异常和错误,因为你总是在假设用户有一个角色(这当然是对的)。

因此,为了解决这个问题,我们可以使用数据库事务。通过使用事务,如果执行代码时出现任何问题,该事务内对数据库的任何更改都会被回滚。例如,如果用户已插入数据库,但分配角色的查询由于任何原因失败,事务就会被回滚,该用户的行就会被删除。通过这样做,意味着我们不能创建没有分配角色的用户。

换句话说,它是“全有或全无”。

在 Laravel 中使用数据库事务

我们现在对事务是什么以及它们实现的功能有一个大致的了解,让我们来看看如何在 Laravel 中使用它们。

在 Laravel 中,使用事务非常容易入门,这得益于我们可以在 DB 门面中访问的 transaction() 方法。继续使用我们之前的例子代码,让我们看看我们如何在创建用户并分配角色时使用事务。

use Illuminate\Support\Facades\DB;

DB::transaction(function () use ($user, $request): void {
    $user = User::create([
        'email' => $request->email,
    ]);

    $user->roles()->attach(Role::where('name', 'general')->first());
});

现在我们的代码被数据库事务包含,如果在其中任何地方抛出异常,对数据库的任何更改都会被恢复到事务开始之前的状态。

在 Laravel 中手动使用数据库事务

有时候,你可能想要对你的事务拥有更细粒度的控制。例如,让我们想象一下你正在与第三方服务集成;比如 Mailchimp 或 Xero。我们可以假设每次你创建一个新用户时,你也需要向他们的 API 发送 HTTP 请求以在那个系统中也创建用户。

我们可能希望更新我们的代码,以便如果我们不能在我们自己的系统和第三方系统中创建用户,则两者都不应该被创建。如果你与第三方系统交互,你可能有一个用于发起请求的类。或者,可能有可用的包。有时,发起请求的类可能在某些请求无法完成时抛出异常。但是,其中一些可能会静默错误,而不是从你调用的方法返回 false,并将错误放置在类的某个字段上。

让我们想象一下我们有一个以下简单的示例类,该类调用 API

class ThirdPartyService
{
    private $errors;

    public function createUser($userData)
    {
        $request = $this->makeRequest($userData);
  
        if ($request->successful()) {
            return $request->body();
        }

        $errors = $request->errors();
        
        return false;
    }

    public function getErrors()
    {
        return $this->errors;
    }
}

当然,上述请求类的代码不完整,我的示例代码也不够整洁,但它应该能给你一个我想要表达的观点的大致概念。所以让我们使用这个请求类并将其添加到我们之前的代码示例中

use Illuminate\Support\Facades\DB;
use App\Services\ThirdPartyService;

DB::beginTransaction();

$thirdPartyService = new ThirdPartyService();

$userData = [
    'email' => $request->email,
];
  
$user = User::create($userData);

$user->roles()->attach(Role::where('name', 'general')->first());
  
if ($thirdPartyService->createUser($userData)) {
    DB::commit();

    return;
}
 
DB::rollBack();

report($thirdPartyService->getErrors());

查看上述代码,我们可以看到我们开始了一个事务,创建了一个用户并分配了角色,然后我们调用第三方服务。如果用户在外部服务中成功创建,我们可以安全地提交我们的数据库更改,因为我们知道一切都已经正确创建。但是,如果用户在外部服务中没有创建,我们在数据库中回滚更改(删除用户及其角色分配),然后报告错误。

与第三方服务交互的技巧

额外建议,我通常推荐将影响任何第三方系统、文件存储或缓存的代码放在数据库调用之后。

为了更好地理解这一点,让我们来看看上面的代码示例。注意我们对数据库的所有更改都是在请求第三方服务之前完成的。这意味着如果第三方请求返回了错误,我们自己的数据库中的用户和角色分配将会回滚。

然而,如果我们反其道而行之,在我们更改数据库之前发出了请求,情况就不是这样了。如果我们创建数据库中的用户时出现任何错误,我们在第三方系统中创建了新用户,但没有在我们的系统中创建。想象一下,这可能会引发更多问题。我们可以通过编写清除方法从第三方系统中删除用户来降低问题的严重性。但是,就像你想的那样,这很可能会带来更多问题,并导致需要编写更多代码、维护和测试。

所以,我总是建议在API调用之前尝试执行数据库调用。然而,这并不总是可能的。有时你需要在数据库中保存第三方请求返回的值。如果情况是这样,这是完全可以接受的,只要确保你有代码处理任何故障。

使用自动或手动事务

值得注意的是,由于我们的原始示例使用DB::transaction()方法在抛出异常时回滚事务,因此我们也可以用这种方法调用第三方服务。相反,我们可以更新我们的类,如下所示

use Illuminate\Support\Facades\DB;
use App\Services\ThirdPartyService;

DB::transaction(function () use ($user, $request): void {
    $user = User::create([
        'email' => $request->email,
    ]);

    $user->roles()->attach(Role::where('name', 'general')->first());
  
    if (! $thirdPartyService->createUser($userData)) {
        throw new \Exception('User could not be created');
    }
});

这绝对是一个可行的解决方案,它将成功回滚事务,正如预期的那样。事实上,从我个人喜好来看,我甚至更喜欢这种方式,而不是手动使用事务看起来更简单,更容易阅读和理解。

然而,与使用“if”语句相比,异常处理在时间和性能方面可能成本较高,后者是我们手动提交或回滚事务时使用的。

因此,例如,如果这段代码被用于导入包含10,000个用户数据的CSV文件的类似操作,你可能会发现抛出异常会显著减慢导入速度。

然而,如果它仅仅用于简单的网络请求中,用户可以注册,你可能会对抛出异常感到满意。当然,这取决于你的应用程序大小和你对性能的关键因素;因此,这是一个需要根据具体情况决定的。

在数据库事务内部调度队列作业

每当您在事务中处理作业时,需要了解一个潜在的问题。

为了提供一个背景,让我们继续使用我们早期的代码示例。我们将设想在我们创建用户之后,我们想要运行一个作业,提醒管理员有新注册并给新用户发送欢迎邮件。我们将通过调度一个称为AlertNewUser的队列作业来实现这一点

use Illuminate\Support\Facades\DB;
use App\Jobs\AlertNewUser;
use App\Services\ThirdPartyService;

DB::transaction(function () use ($user, $request): void {
    $user = User::create([
        'email' => $request->email,
    ]);

    $user->roles()->attach(Role::where('name', 'general')->first());
  
    AlertNewUser::dispatch($user);
});

当您开始事务并对其中的任何数据进行更改时,这些更改仅对执行事务的请求/进程可用。对于任何其他请求或进程要访问您更改的数据,事务首先必须被提交。因此,这意味着如果我们从事务内部调度任何队列作业、事件监听器、可发送邮件、通知或广播事件,由于竞争条件,我们的数据更改可能不会在它们内部使用。

当队列工人在事务提交之前开始处理队列中的代码时,这种情况可能发生。因此,这可能导致您的队列代码试图访问尚未存在的数据,并可能引发错误。在我们的案例中,如果队列AlertNewUser作业在事务提交之前运行,作业可能会尝试访问数据库中尚未实际存储的用户。不出所料,这将导致作业失败。

为了防止这种竞争条件的发生,我们可以对我们的代码和/或配置进行一些更改,以确保作业仅在事务成功提交后才能分派。

我们可以更新我们的config/queue.php并添加after_commit字段。假设我们正在使用redis队列驱动器,我们可以更新配置如下

<?php

return [

    // ...

    'connections' => [

        // ...

        'redis' => [
            'driver' => 'redis',
            // ...
            'after_commit' => true,
        ],

        // ...

    ],

    // ...
];

通过进行此更改,如果我们尝试在事务中分派作业,则作业将等待事务提交后再实际分派作业。便利的是,如果事务回滚,它将防止作业被分派。

然而,您可能有不想在配置中全局设置此选项的理由。如果是这样,Laravel仍然提供了一些有用的辅助方法,我们可以根据需要使用。

如果我们希望更新事务中的代码,仅在提交后才分派作业,我们可以使用afterCommit()方法如下

use Illuminate\Support\Facades\DB;
use App\Jobs\AlertNewUser;
use App\Services\ThirdPartyService;

DB::transaction(function () use ($user, $request): void {
    $user = User::create([
        'email' => $request->email,
    ]);

    $user->roles()->attach(Role::where('name', 'general')->first());
  
    AlertNewUser::dispatch($user)->afterCommit();
});

Laravel还提供了一个有用的beforeCommit()方法,我们可以使用它。如果我们已经在队列配置中设置了全局的after_commit => true,但不在乎等待事务提交,我们可以简单更新代码如下

use Illuminate\Support\Facades\DB;
use App\Jobs\AlertNewUser;
use App\Services\ThirdPartyService;

DB::transaction(function () use ($user, $request): void {
    $user = User::create([
        'email' => $request->email,
    ]);

    $user->roles()->attach(Role::where('name', 'general')->first());
  
    AlertNewUser::dispatch($user)->beforeCommit();
});

结论

希望这篇文章能为您提供一个关于数据事务概述及其在Laravel中的使用方法的了解。它还应显示如何避免从事务内部分派队列作业时遇到的“陷阱”。

如果您觉得这篇文章有用,我很乐意听到。同样,如果您有任何改进这篇文章的反馈,我也很乐意听到。

如果您想每次我发布新文章时都收到更新,请随时订阅我的通讯

继续构建令人惊叹的东西!🚀

最后更新 1 年前。

shivam-687, ash-jc-allen, faissaloux, geovanek, ronald169, akhmatovalexander, rsmsp, yvan-burrie, django23, peterfox 等人喜欢这篇文章

11
喜欢这篇文章? 让作者知道,并给他们鼓掌!
ash-jc-allen (Ash Allen) 我是一位来自英国普雷斯顿的自由职业Laravel Web开发者。我维护Ash Allen Design博客,并参与了许多酷炫和有趣的项目 🚀

您可能还喜欢以下文章

2024年3月11日

如何使用Larastan将您的Laravel应用程序从0扩展到9

在Laravel应用程序执行前发现错误是可能的,这要归功于Larastan,它是一个...

阅读文章
2024年7月19日

无需特性标准化API响应

我发现的问题:大多数用于API响应的库都使用特性来实现,并且...

阅读文章
2024年7月17日

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

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

阅读文章

我们衷心感谢这些了不起的赞助公司 的支持

您的标志在这里?

Laravel.io

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

© 2024 Laravel.io - 版权所有。