事件

引言

Laravel 的事件提供了一个简单的观察者模式实现,允许你订阅并监听在应用程序中发生的各种事件。事件类通常存储在 app/Events 目录中,而它们的监听器则存储在 app/Listeners 目录中。如果在你的应用中看不到这些目录,也不用担心,因为当你使用 Artisan 控制台命令生成事件和监听器时,这些目录会自动为你创建。

事件是解耦应用程序各个部分的一个很好的方式,因为一个事件可以有多个相互独立的监听器。例如,你可能希望在每次订单发货时向用户发送 Slack 通知。与其将订单处理代码和 Slack 通知代码耦合在一起,你可以触发一个 App\Events\OrderShipped 事件,然后一个监听器可以接收到该事件,并用来派发 Slack 通知。

生成事件和监听器

为了快速生成事件和监听器,你可以使用 make:eventmake:listener Artisan 命令:

php artisan make:event PodcastProcessed

php artisan make:listener SendPodcastNotification --event=PodcastProcessed

为了方便起见,你还可以在没有额外参数的情况下调用 make:eventmake:listener Artisan 命令。这样,Laravel 会自动提示你输入类名,并且在创建监听器时,还会提示你选择监听的事件:

php artisan make:event

php artisan make:listener

注册事件和监听器

事件发现

默认情况下,Laravel 会通过扫描应用程序的 Listeners 目录自动找到并注册事件监听器。当 Laravel 找到任何以 handle__invoke 开头的监听器类方法时,它会将这些方法注册为对应事件的监听器,事件类型会在方法签名中进行类型提示:

use App\Events\PodcastProcessed;

class SendPodcastNotification
{
    /**
     * 处理给定的事件。
     */
    public function handle(PodcastProcessed $event): void
    {
        // ...
    }
}

你可以使用 PHP 的联合类型监听多个事件:

/**
 * 处理给定的事件。
 */
public function handle(PodcastProcessed|PodcastPublished $event): void
{
    // ...
}

如果你计划将监听器存储在不同的目录或多个目录中,可以通过在应用程序的 bootstrap/app.php 文件中使用 withEvents 方法指示 Laravel 扫描这些目录:

->withEvents(discover: [
    __DIR__.'/../app/Domain/Orders/Listeners',
])

可以使用 event:list 命令列出应用程序中注册的所有监听器:

php artisan event:list

生产环境中的事件发现

为了提升应用程序的速度,你应该使用 optimizeevent:cache Artisan 命令缓存应用程序所有监听器的清单。通常,这个命令应该作为应用程序【部署过程】的一部分运行。这个清单将被框架用来加速事件注册过程。你可以使用 event:clear 命令销毁事件缓存。

手动注册事件

通过使用 Event facade,你可以在应用程序的 AppServiceProvider 类的 boot 方法中手动注册事件及其对应的监听器:

use App\Domain\Orders\Events\PodcastProcessed;
use App\Domain\Orders\Listeners\SendPodcastNotification;
use Illuminate\Support\Facades\Event;

/**
 * 引导应用程序的任何服务。
 */
public function boot(): void
{
    Event::listen(
        PodcastProcessed::class,
        SendPodcastNotification::class,
    );
}

可以使用 event:list 命令列出应用程序中注册的所有监听器:

php artisan event:list

闭包监听器

通常,监听器是定义为类的;然而,你也可以在应用程序的 AppServiceProvider 类的 boot 方法中手动注册基于闭包的事件监听器:

use App\Events\PodcastProcessed;
use Illuminate\Support\Facades\Event;

/**
 * 引导应用程序的任何服务。
 */
public function boot(): void
{
    Event::listen(function (PodcastProcessed $event) {
        // ...
    });
}

可队列的匿名事件监听器

在注册基于闭包的事件监听器时,你可以使用 Illuminate\Events\queueable 函数将监听器闭包包装起来,指示 Laravel 使用队列执行监听器:

use App\Events\PodcastProcessed;
use function Illuminate\Events\queueable;
use Illuminate\Support\Facades\Event;

/**
 * 引导应用程序的任何服务。
 */
public function boot(): void
{
    Event::listen(queueable(function (PodcastProcessed $event) {
        // ...
    }));
}

像队列任务一样,你可以使用 onConnectiononQueuedelay 方法自定义队列监听器的执行:

Event::listen(queueable(function (PodcastProcessed $event) {
    // ...
})->onConnection('redis')->onQueue('podcasts')->delay(now()->addSeconds(10)));

如果你想处理匿名队列监听器的失败,可以在定义可队列的监听器时提供一个 catch 方法中的闭包。这个闭包将接收事件实例和导致监听器失败的 Throwable 实例:

use App\Events\PodcastProcessed;
use function Illuminate\Events\queueable;
use Illuminate\Support\Facades\Event;
use Throwable;

Event::listen(queueable(function (PodcastProcessed $event) {
    // ...
})->catch(function (PodcastProcessed $event, Throwable $e) {
    // 队列监听器失败...
}));

通配符事件监听器

你也可以使用 * 字符作为通配符参数注册监听器,从而使你能够在同一个监听器中捕获多个事件。通配符监听器接收事件名称作为第一个参数,并接收整个事件数据数组作为第二个参数:

Event::listen('event.*', function (string $eventName, array $data) {
    // ...
});

定义事件

事件类本质上是一个数据容器,用于存储与事件相关的信息。例如,假设一个 App\Events\OrderShipped 事件接收一个 Eloquent ORM 对象:

<?php

namespace App\Events;

use App\Models\Order;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class OrderShipped
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    /**
     * 创建一个新的事件实例。
     */
    public function __construct(
        public Order $order,
    ) {}
}

如你所见,这个事件类不包含任何逻辑。它是一个容器,用于存储已购买的 App\Models\Order 实例。事件使用的 SerializesModels 特性会优雅地序列化任何 Eloquent 模型,如果事件对象使用 PHP 的 serialize 函数进行序列化(例如,在使用队列监听器时),它将正常工作。

定义监听器

接下来,我们来看一下我们示例事件的监听器。事件监听器会在它们的 handle 方法中接收事件实例。当使用 --event 选项调用 make:listener Artisan 命令时,它会自动导入正确的事件类,并在 handle 方法中进行类型提示。在 handle 方法中,你可以执行任何必要的操作来响应事件:

<?php

namespace App\Listeners;

use App\Events\OrderShipped;

class SendShipmentNotification
{
    /**
     * 创建事件监听器。
     */
    public function __construct() {}

    /**
     * 处理事件。
     */
    public function handle(OrderShipped $event): void
    {
        // 使用 $event->order 访问订单...
    }
}

你的事件监听器还可以在构造函数中进行类型提示,声明它们所需的任何依赖项。所有事件监听器都是通过 Laravel 【服务容器】解析的,因此依赖项会自动注入。

停止事件的传播

有时,你可能希望阻止事件传播到其它监听器。你可以通过从监听器的 handle 方法中返回 false 来实现这一点。

队列事件监听器

队列监听器对于需要执行慢速任务的场景非常有用,例如发送电子邮件或发起 HTTP 请求。在使用队列监听器之前,请确保已经配置好队列,并且在你的服务器或本地开发环境中启动了队列工作进程。

要指定一个监听器应当被加入队列,只需在监听器类中添加 ShouldQueue 接口。通过 make:listener Artisan 命令生成的监听器类已经自动导入了这个接口,因此你可以立即使用它:

<?php

namespace App\Listeners;

use App\Events\OrderShipped;
use Illuminate\Contracts\Queue\ShouldQueue;

class SendShipmentNotification implements ShouldQueue
{
    // ...
}

就是这么简单!现在,当由该监听器处理的事件被触发时,监听器将自动由事件分发器使用 Laravel 的【队列系统】进行排队。如果在队列执行监听器时没有抛出异常,队列中的任务将在处理完成后自动删除。

自定义队列连接、队列名称与延迟时间

如果你想自定义事件监听器的队列连接、队列名称或延迟时间,可以在监听器类中定义 $connection$queue$delay 属性:

<?php

namespace App\Listeners;

use App\Events\OrderShipped;
use Illuminate\Contracts\Queue\ShouldQueue;

class SendShipmentNotification implements ShouldQueue
{
    /**
     * 任务应当发送到的连接名称。
     *
     * @var string|null
     */
    public $connection = 'sqs';

    /**
     * 任务应当发送到的队列名称。
     *
     * @var string|null
     */
    public $queue = 'listeners';

    /**
     * 任务应当在多少秒后开始处理。
     *
     * @var int
     */
    public $delay = 60;
}

如果你希望在运行时定义监听器的队列连接、队列名称或延迟时间,你可以通过监听器中的 viaConnectionviaQueuewithDelay 方法来定义:

/**
 * 获取监听器的队列连接名称。
 */
public function viaConnection(): string
{
    return 'sqs';
}

/**
 * 获取监听器的队列名称。
 */
public function viaQueue(): string
{
    return 'listeners';
}

/**
 * 获取任务应该延迟处理的秒数。
 */
public function withDelay(OrderShipped $event): int
{
    return $event->highPriority ? 0 : 60;
}

条件队列监听器

有时,你可能需要根据运行时才能获得的数据来判断一个监听器是否应该加入队列。为此,你可以在监听器中添加 shouldQueue 方法来确定是否应该将该监听器加入队列。如果 shouldQueue 方法返回 false,则该监听器将不会被加入队列:

<?php

namespace App\Listeners;

use App\Events\OrderCreated;
use Illuminate\Contracts\Queue\ShouldQueue;

class RewardGiftCard implements ShouldQueue
{
    /**
     * 为客户奖励礼品卡。
     */
    public function handle(OrderCreated $event): void
    {
        // ...
    }

    /**
     * 确定监听器是否应该被加入队列。
     */
    public function shouldQueue(OrderCreated $event): bool
    {
        return $event->order->subtotal >= 5000;
    }
}

手动与队列交互

如果你需要手动访问监听器底层队列任务的 deleterelease 方法,可以使用 Illuminate\Queue\InteractsWithQueue 特性。这个特性在生成的监听器中默认已经被导入,并提供了访问这些方法的能力:

<?php

namespace App\Listeners;

use App\Events\OrderShipped;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;

class SendShipmentNotification implements ShouldQueue
{
    use InteractsWithQueue;

    /**
     * 处理事件。
     */
    public function handle(OrderShipped $event): void
    {
        if (true) {
            $this->release(30); // 将任务重新放回队列,延迟 30 秒后重试
        }
    }
}

InteractsWithQueue 特性使得你可以在事件监听器中调用 release 方法来将任务重新放入队列,或者调用 delete 方法来删除任务。

队列事件监听器与数据库事务

当队列监听器在数据库事务中被分派时,它们可能会在数据库事务提交之前被队列处理。当这种情况发生时,你在数据库事务期间对模型或数据库记录所做的任何更新可能尚未反映在数据库中。此外,在事务中创建的任何模型或数据库记录可能在数据库中不存在。如果你的监听器依赖于这些模型,队列处理该监听器的任务时可能会发生意外错误。

如果你的队列连接的 after_commit 配置选项设置为 false,你仍然可以通过在监听器类中实现 ShouldQueueAfterCommit 接口,指示某个特定的队列监听器应在所有打开的数据库事务提交后再被分派:

<?php

namespace App\Listeners;

use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Queue\InteractsWithQueue;

class SendShipmentNotification implements ShouldQueueAfterCommit
{
    use InteractsWithQueue;
}

要了解更多关于解决这些问题的信息,请查看关于【队列任务和数据库事务】的文档。

处理失败的任务

有时你的队列事件监听器可能会失败。如果队列监听器超过了队列工作者定义的最大尝试次数,监听器上的 failed 方法将被调用。failed 方法接收事件实例和导致失败的 Throwable 实例:

<?php

namespace App\Listeners;

use App\Events\OrderShipped;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Throwable;

class SendShipmentNotification implements ShouldQueue
{
    use InteractsWithQueue;

    /**
     * 处理事件。
     */
    public function handle(OrderShipped $event): void
    {
        // ...
    }

    /**
     * 处理作业失败。
     */
    public function failed(OrderShipped $event, Throwable $exception): void
    {
        // ...
    }
}

指定队列监听器的最大尝试次数

如果某个队列监听器遇到错误,通常你不希望它一直重试。因此,Laravel 提供了多种方式来指定监听器可以尝试多少次或者多久。

你可以在监听器类中定义 $tries 属性,以指定监听器在被视为失败之前可以尝试的次数:

<?php

namespace App\Listeners;

use App\Events\OrderShipped;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;

class SendShipmentNotification implements ShouldQueue
{
    use InteractsWithQueue;

    /**
     * 队列监听器可以尝试的次数。
     *
     * @var int
     */
    public $tries = 5;
}

除了定义监听器失败前的最大尝试次数,你还可以定义一个时间点,在该时间点之后不再尝试监听器。这允许监听器在给定的时间范围内尝试多次。要定义监听器应该停止尝试的时间,请在监听器类中添加一个 retryUntil 方法,该方法应返回一个 DateTime 实例:

use DateTime;

/**
 * 确定监听器超时的时间。
 */
public function retryUntil(): DateTime
{
    return now()->addMinutes(5);
}

指定队列监听器的退避时间

如果你希望配置 Laravel 在重试遇到异常的监听器时等待的秒数,可以通过在监听器类中定义 backoff 属性来实现:

/**
 * 重试队列监听器之前等待的秒数。
 *
 * @var int
 */
public $backoff = 3;

如果你需要更复杂的逻辑来决定监听器的退避时间,可以在监听器类中定义一个 backoff 方法:

/**
 * 计算重试队列监听器之前等待的秒数。
 */
public function backoff(): int
{
    return 3;
}

你也可以通过返回一个退避值数组来轻松配置 “指数” 退避。例如,在这个例子中,第一次重试的延迟为 1 秒,第二次重试的延迟为 5 秒,第三次重试的延迟为 10 秒,而如果还有更多的尝试,则每次重试的延迟为 10 秒:

/**
 * 计算重试队列监听器之前等待的秒数。
 *
 * @return array<int, int>
 */
public function backoff(): array
{
    return [1, 5, 10];
}

派发事件

要触发一个事件,你可以在事件类上调用静态的 dispatch 方法。这个方法是通过 Illuminate\Foundation\Events\Dispatchable 特性提供的。任何传递给 dispatch 方法的参数都会传递给事件的构造函数:

<?php

namespace App\Http\Controllers;

use App\Events\OrderShipped;
use App\Http\Controllers\Controller;
use App\Models\Order;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;

class OrderShipmentController extends Controller
{
    /**
     * 发货给指定的订单。
     */
    public function store(Request $request): RedirectResponse
    {
        $order = Order::findOrFail($request->order_id);

        // 订单发货逻辑...

        OrderShipped::dispatch($order);

        return redirect('/orders');
    }
}

如果你想有条件地触发一个事件,可以使用 dispatchIfdispatchUnless 方法:

OrderShipped::dispatchIf($condition, $order);

OrderShipped::dispatchUnless($condition, $order);

在测试时,可能会有必要断言某些事件是否被触发,而不实际触发其监听器。Laravel 【内置的测试助手】使这一过程变得非常简单。

数据库事务后的事件派发

有时候,你可能希望指示 Laravel 仅在活动的数据库事务提交后才触发事件。为此,你可以在事件类中实现 ShouldDispatchAfterCommit 接口。

这个接口指示 Laravel 在当前数据库事务提交之前不会触发事件。如果事务失败,事件将被丢弃。如果在触发事件时没有活动的数据库事务,事件将立即触发:

<?php

namespace App\Events;

use App\Models\Order;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class OrderShipped implements ShouldDispatchAfterCommit
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    /**
     * 创建一个新的事件实例。
     */
    public function __construct(
        public Order $order,
    ) {}
}

事件订阅者

编写事件订阅者

事件订阅者是可以在一个类内部订阅多个事件的类,它允许你在一个类中定义多个事件处理器。订阅者应定义一个 subscribe 方法,该方法将传递一个事件调度器实例。你可以在给定的调度器上调用 listen 方法来注册事件监听器:

<?php

namespace App\Listeners;

use Illuminate\Auth\Events\Login;
use Illuminate\Auth\Events\Logout;
use Illuminate\Events\Dispatcher;

class UserEventSubscriber
{
    /**
     * 处理用户登录事件。
     */
    public function handleUserLogin(Login $event): void {}

    /**
     * 处理用户登出事件。
     */
    public function handleUserLogout(Logout $event): void {}

    /**
     * 注册订阅者的监听器。
     */
    public function subscribe(Dispatcher $events): void
    {
        $events->listen(
            Login::class,
            [UserEventSubscriber::class, 'handleUserLogin']
        );

        $events->listen(
            Logout::class,
            [UserEventSubscriber::class, 'handleUserLogout']
        );
    }
}

如果你的事件监听方法已在订阅者内部定义,你可能会觉得从订阅者的 subscribe 方法中返回事件和方法名称的数组更为方便。Laravel 会在注册事件监听器时自动确定订阅者的类名:

<?php

namespace App\Listeners;

use Illuminate\Auth\Events\Login;
use Illuminate\Auth\Events\Logout;
use Illuminate\Events\Dispatcher;

class UserEventSubscriber
{
    /**
     * 处理用户登录事件。
     */
    public function handleUserLogin(Login $event): void {}

    /**
     * 处理用户登出事件。
     */
    public function handleUserLogout(Logout $event): void {}

    /**
     * 注册订阅者的监听器。
     *
     * @return array<string, string>
     */
    public function subscribe(Dispatcher $events): array
    {
        return [
            Login::class => 'handleUserLogin',
            Logout::class => 'handleUserLogout',
        ];
    }
}

注册事件订阅者

编写完订阅者后,Laravel 会自动根据事件发现约定注册订阅者中的处理方法。如果不符合约定,你可以使用 Event 门面中的 subscribe 方法手动注册订阅者。通常,应该在应用的 AppServiceProviderboot 方法中完成这一步:

<?php

namespace App\Providers;

use App\Listeners\UserEventSubscriber;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    /**
     * 启动任何应用服务。
     */
    public function boot(): void
    {
        Event::subscribe(UserEventSubscriber::class);
    }
}

测试

在测试触发事件的代码时,你可能希望指示 Laravel 不实际执行事件的监听器,因为监听器的代码可以在与触发事件的代码分开进行测试时直接进行测试。为了测试监听器本身,你可以在测试中实例化一个监听器实例,并直接调用其 handle 方法。

通过使用 Event 门面的 fake 方法,你可以防止监听器的执行,执行测试代码,然后使用 assertDispatchedassertNotDispatchedassertNothingDispatched 方法断言你的应用程序是否触发了事件:

  • Pest

  • PHPUnit

<?php

use App\Events\OrderFailedToShip;
use App\Events\OrderShipped;
use Illuminate\Support\Facades\Event;

test('orders can be shipped', function () {
    Event::fake();

    // Perform order shipping...

    // Assert that an event was dispatched...
    Event::assertDispatched(OrderShipped::class);

    // Assert an event was dispatched twice...
    Event::assertDispatched(OrderShipped::class, 2);

    // Assert an event was not dispatched...
    Event::assertNotDispatched(OrderFailedToShip::class);

    // Assert that no events were dispatched...
    Event::assertNothingDispatched();
});
<?php

namespace Tests\Feature;

use App\Events\OrderFailedToShip;
use App\Events\OrderShipped;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;

class ExampleTest extends TestCase
{
    /**
     * Test order shipping.
     */
    public function test_orders_can_be_shipped(): void
    {
        Event::fake();

        // Perform order shipping...

        // Assert that an event was dispatched...
        Event::assertDispatched(OrderShipped::class);

        // Assert an event was dispatched twice...
        Event::assertDispatched(OrderShipped::class, 2);

        // Assert an event was not dispatched...
        Event::assertNotDispatched(OrderFailedToShip::class);

        // Assert that no events were dispatched...
        Event::assertNothingDispatched();
    }
}

你可以将一个闭包传递给 assertDispatchedassertNotDispatched 方法,以断言触发的事件符合给定的 “真值测试”。如果至少有一个事件通过了给定的真值测试,那么该断言将成功:

Event::assertDispatched(function (OrderShipped $event) use ($order) {
    return $event->order->id === $order->id;
});

如果你只是想断言某个事件的监听器是否在监听给定的事件,你可以使用 assertListening 方法:

Event::assertListening(
    OrderShipped::class,
    SendShipmentNotification::class
);

在调用 Event::fake() 后,不会执行任何事件监听器。因此,如果你的测试使用了依赖于事件的模型工厂(例如,在模型的 creating 事件中创建 UUID),你应该在使用工厂后调用 Event::fake()

虚假事件子集

如果你只想为特定的事件集合伪造事件监听器,可以将这些事件传递给 fakefakeFor 方法:

  • Pest

  • PHPUnit

test('orders can be processed', function () {
    Event::fake([
        OrderCreated::class,
    ]);

    $order = Order::factory()->create();

    Event::assertDispatched(OrderCreated::class);

    // Other events are dispatched as normal...
    $order->update([...]);
});
/**
 * Test order process.
 */
public function test_orders_can_be_processed(): void
{
    Event::fake([
        OrderCreated::class,
    ]);

    $order = Order::factory()->create();

    Event::assertDispatched(OrderCreated::class);

    // Other events are dispatched as normal...
    $order->update([...]);
}

你也可以使用 except 方法来伪造所有事件,除了指定的一些事件:

Event::fake()->except([
    OrderCreated::class,
]);

范围事件虚假

如果你只想在测试的某一部分伪造事件监听器,可以使用 fakeFor 方法:

  • Pest

  • PHPUnit

<?php

use App\Events\OrderCreated;
use App\Models\Order;
use Illuminate\Support\Facades\Event;

test('orders can be processed', function () {
    $order = Event::fakeFor(function () {
        $order = Order::factory()->create();

        Event::assertDispatched(OrderCreated::class);

        return $order;
    });

    // Events are dispatched as normal and observers will run ...
    $order->update([...]);
});
<?php

namespace Tests\Feature;

use App\Events\OrderCreated;
use App\Models\Order;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;

class ExampleTest extends TestCase
{
    /**
     * Test order process.
     */
    public function test_orders_can_be_processed(): void
    {
        $order = Event::fakeFor(function () {
            $order = Order::factory()->create();

            Event::assertDispatched(OrderCreated::class);

            return $order;
        });

        // Events are dispatched as normal and observers will run ...
        $order->update([...]);
    }
}