事件系统
事件(Events)
简介(Introduction)
Laravel 的事件提供了一种简单的 观察者模式 实现,允许你订阅并监听应用程序中发生的各种事件。事件类通常存放在 app/Events
目录下,而它们的监听器则存放在 app/Listeners
目录下。如果你在应用中没有看到这些目录也不要担心,当你使用 Artisan 命令生成事件和监听器时,这些目录会自动为你创建。
事件是解耦应用程序各个部分的一种绝佳方式,因为一个事件可以有多个监听器,而这些监听器之间互不依赖。例如,你可能希望在每次订单发货时给用户发送一条 Slack 通知。与其把订单处理逻辑和 Slack 通知逻辑耦合在一起,你可以触发一个 App\Events\OrderShipped
事件,然后由一个监听器接收该事件并负责发送 Slack 通知。
生成事件和监听器(Generating Events and Listeners)
在 Laravel 中,可以使用 Artisan 命令快速生成事件和监听器:
php artisan make:event PodcastProcessed
php artisan make:listener SendPodcastNotification --event=PodcastProcessed
如果你不想手动输入类名和事件名,也可以直接运行 make:event
或 make:listener
命令而不带参数。Laravel 会自动提示你输入类名,并在创建监听器时询问它要监听的事件:
php artisan make:event
php artisan make:listener
注册事件和监听器(Registering Events and Listeners)
事件发现(Event Discovery)
Laravel 默认会自动扫描应用的 app/Listeners
目录,找到并注册其中的事件监听器。当 Laravel 发现某个监听器类中包含以 handle
或 __invoke
开头的方法时,它会根据方法参数类型提示(type-hint)来确定要监听的事件,并自动完成注册:
use App\Events\PodcastProcessed;
class SendPodcastNotification
{
/**
* 处理事件
*/
public function handle(PodcastProcessed $event): void
{
// ...
}
}
一个监听器也可以同时监听多个事件,这时可以利用 PHP 的联合类型(union types):
/**
* Handle the event.
*/
public function handle(PodcastProcessed|PodcastPublished $event): void
{
// ...
}
如果你打算把监听器放在其他目录(或多个目录),可以在 bootstrap/app.php
文件中使用 withEvents
方法指定需要扫描的目录:
->withEvents(discover: [
__DIR__.'/../app/Domain/Orders/Listeners',
])
You may scan for listeners in multiple similar directories using the *
character as a wildcard:
->withEvents(discover: [
__DIR__.'/../app/Domain/*/Listeners',
])
你可以使用 event:list
命令查看应用中所有已注册的事件监听器:
php artisan event:list
生产环境中的事件发现
为了给你的应用程序提速,你应该使用 optimize
或 event: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) {
// ...
}));
}
就像队列任务一样,你可以使用 onConnection
、onQueue
和 delay
方法来自定义队列监听器的执行:
Event::listen(queueable(function (PodcastProcessed $event) {
// ...
})->onConnection('redis')->onQueue('podcasts')->delay(now()->addSeconds(10)));
如果你想处理匿名队列监听器的失败情况,你可以在定义 queueable
监听器时,通过 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) {
// 队列监听器失败...
}));
通配符事件监听器(Wildcard Event Listeners)
你也可以使用 *
字符作为通配符参数来注册监听器,从而在同一个监听器中捕获多个事件。通配符监听器接收事件名称作为第一个参数,整个事件数据数组作为第二个参数:
Event::listen('event.*', function (string $eventName, array $data) {
// ...
});
定义事件(Defining Events)
事件类本质上是一个数据容器,用于保存与事件相关的信息。
例如,假设一个 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
trait 可以在事件对象通过 PHP 的 serialize
函数序列化时(例如使用 队列监听器 时),优雅地序列化任何 Eloquent 模型。
定义监听器(Defining Listeners)
接下来,让我们来看一下示例事件的监听器。事件监听器在它们的 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 的 服务容器 解析的,因此依赖会被自动注入。
阻止事件传播(Stopping The Propagation Of An Event)
有时候,你可能希望阻止事件继续传播到其他监听器。你可以通过在监听器的 handle
方法中返回 false
来实现。
队列事件监听器(Queued Event Listeners)
当监听器需要执行耗时操作(例如发送邮件或发起 HTTP 请求)时,将监听器放入队列中会非常有用。
在使用队列监听器之前,请确保你已经 配置好队列,并在服务器或本地开发环境中启动了一个队列工作进程。
要指定监听器应当被放入队列,只需在监听器类上实现 ShouldQueue
接口即可。由 make:listener
Artisan 命令生成的监听器已经在当前命名空间中引入了这个接口,因此你可以立即使用:
<?php
namespace App\Listeners;
use App\Events\OrderShipped;
use Illuminate\Contracts\Queue\ShouldQueue;
class SendShipmentNotification implements ShouldQueue
{
// ...
}
就是这样!现在,当一个由该监听器处理的事件被分发时,监听器将会通过 Laravel 的 队列系统 被事件调度器自动放入队列。如果在监听器被队列执行时没有抛出任何异常,该队列任务在处理完成后将会被自动删除。
自定义队列连接、名称和延迟(Customizing The Queue Connection, Name, & Delay)
如果你想要自定义事件监听器的队列连接、队列名称或队列延迟时间,你可以在监听器类上定义 $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;
}
如果你想在运行时动态定义监听器的队列连接、队列名称或延迟时间,你可以在监听器中定义 viaConnection
、viaQueue
或 withDelay
方法:
/**
* 获取监听器的队列连接名称。
*/
public function viaConnection(): string
{
return 'sqs';
}
/**
* 获取监听器的队列名称。
*/
public function viaQueue(): string
{
return 'listeners';
}
/**
* 获取任务在被处理前的延迟秒数。
*/
public function withDelay(OrderShipped $event): int
{
return $event->highPriority ? 0 : 60;
}
条件队列监听器(Conditionally Queueing Listeners)
有时,你可能需要根据一些仅在运行时可用的数据来决定某个监听器是否应被放入队列。
要实现这一点,你可以在监听器中添加一个 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;
}
}
手动与队列交互(Manually Interacting With the Queue)
如果你需要手动访问监听器底层队列任务的 delete
和 release
方法,可以使用 Illuminate\Queue\InteractsWithQueue
trait。
这个 trait 在生成的监听器中默认已经引入,它为你提供了访问这些方法的能力:
<?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);
}
}
}
队列事件监听器与数据库事务(Queued Event Listeners and Database Transactions)
当队列监听器在数据库事务中被分发时,它们可能会在事务提交之前就被队列处理。
这种情况下,你在数据库事务中对模型或数据库记录所做的更新可能还没有真正写入数据库。
另外,事务中创建的模型或数据库记录在事务提交之前也可能还不存在于数据库中。
如果你的监听器依赖这些模型,那么在处理该队列任务时就可能会发生意料之外的错误。
如果你的队列连接的 after_commit
配置项被设置为 false
,你仍然可以通过在监听器类上实现 ShouldQueueAfterCommit
接口,来指定某个队列监听器必须等到所有未提交的数据库事务完成后才被分发:
<?php
namespace App\Listeners;
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Queue\InteractsWithQueue;
class SendShipmentNotification implements ShouldQueueAfterCommit
{
use InteractsWithQueue;
}
[!注意]
想要进一步了解如何规避这些问题,请查阅关于 队列任务与数据库事务 的文档。
处理失败的任务(Handling Failed Jobs)
有时候,你的队列事件监听器可能会失败。
如果某个队列监听器超过了队列 worker 定义的最大尝试次数,那么监听器的 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
{
// ...
}
}
指定队列监听器的最大尝试次数(Specifying Queued Listener Maximum Attempts)
如果某个队列监听器反复出错,你通常不会希望它无限重试。
因此,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);
}
如果同时定义了 retryUntil
和 $tries
,Laravel 会优先使用 retryUntil
方法。
指定队列监听器的回退时间(Specifying Queued Listener Backoff)
如果你希望配置监听器在遇到异常后,Laravel 等待多少秒再重试,可以在监听器类中定义一个 $backoff
属性:
/**
* 在重试队列监听器前需要等待的秒数
*
* @var int
*/
public $backoff = 3;
如果你需要更复杂的逻辑来决定监听器的回退时间,可以在监听器类中定义一个 backoff
方法:
/**
* 计算重试队列监听器前需要等待的秒数
*/
public function backoff(): int
{
return 3;
}
你还可以很方便地配置“指数回退”(exponential backoff)。
只需要让 backoff
方法返回一个数组即可。
在下面的例子中:
- 第一次重试时延迟 1 秒
- 第二次重试时延迟 5 秒
- 第三次重试时延迟 10 秒
- 如果还有更多尝试,之后的每次都会延迟 10 秒
/**
* 计算重试队列监听器前需要等待的秒数
*
* @return list<int>
*/
public function backoff(): array
{
return [1, 5, 10];
}
派发事件(Dispatching Events)
要派发一个事件,可以在事件类上调用静态方法 dispatch
。
这个方法来自 Illuminate\Foundation\Events\Dispatchable
trait。
所有传给 dispatch
的参数,都会传递给事件的构造函数:
<?php
namespace App\Http\Controllers;
use App\Events\OrderShipped;
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');
}
}
如果你想有条件地派发事件,可以使用 dispatchIf
和 dispatchUnless
方法:
OrderShipped::dispatchIf($condition, $order);
OrderShipped::dispatchUnless($condition, $order);
[!注意]
在测试时,断言某些事件被派发而不实际触发它们的监听器非常有用。Laravel 的 内置测试辅助工具 可以很方便地实现这一点。
在数据库事务之后派发事件(Dispatching Events After Database Transactions)
有时,你可能希望指示 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,
) {}
}
事件订阅者(Event Subscribers)
编写事件订阅者(Writing Event Subscribers)
事件订阅者是可以在订阅者类内部订阅多个事件的类,这样你就可以在单个类中定义多个事件处理器。
订阅者应该定义一个 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',
];
}
}
注册事件订阅者(Registering Event Subscribers)
编写订阅者之后,如果订阅者遵循 Laravel 的 事件发现约定,Laravel 会自动注册订阅者中的处理方法。
否则,你可以使用 Event
facade 的 subscribe
方法手动注册订阅者。
通常,这应该在应用的 AppServiceProvider
的 boot
方法中完成:
<?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);
}
}
测试(Testing)
在测试派发事件的代码时,你可能希望指示 Laravel 不要实际执行事件的监听器,因为监听器的代码可以直接、独立地测试。
当然,要测试监听器本身,你可以在测试中实例化监听器对象,并直接调用它的 handle
方法。
使用 Event
facade 的 fake
方法,你可以阻止监听器执行,然后执行被测试的代码,再通过 assertDispatched
、assertNotDispatched
和 assertNothingDispatched
方法来断言你的应用派发了哪些事件:
<?php
use App\Events\OrderFailedToShip;
use App\Events\OrderShipped;
use Illuminate\Support\Facades\Event;
test('订单可以发货', function () {
Event::fake();
// 执行订单发货逻辑...
// 断言某个事件被派发过
Event::assertDispatched(OrderShipped::class);
// 断言某个事件被派发了两次
Event::assertDispatched(OrderShipped::class, 2);
// 断言某个事件没有被派发
Event::assertNotDispatched(OrderFailedToShip::class);
// 断言没有任何事件被派发
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
{
/**
* 测试订单发货
*/
public function test_orders_can_be_shipped(): void
{
Event::fake();
// 执行订单发货逻辑...
// 断言某个事件被派发过
Event::assertDispatched(OrderShipped::class);
// 断言某个事件被派发了两次
Event::assertDispatched(OrderShipped::class, 2);
// 断言某个事件没有被派发
Event::assertNotDispatched(OrderFailedToShip::class);
// 断言没有任何事件被派发
Event::assertNothingDispatched();
}
}
你可以向 assertDispatched
或 assertNotDispatched
方法传入一个闭包,用于断言事件满足某个“真实性测试”。
只要至少有一个事件满足闭包中的条件,断言就会通过:
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()
。
伪造部分事件(Faking a Subset of Events)
如果你只想伪造特定事件的监听器,可以将这些事件传递给 fake
或 fakeFor
方法:
test('orders can be processed', function () {
Event::fake([
OrderCreated::class,
]);
$order = Order::factory()->create();
Event::assertDispatched(OrderCreated::class);
// 其他事件将按正常方式派发...
$order->update([
// ...
]);
});
/**
* 测试订单处理
*/
public function test_orders_can_be_processed(): void
{
Event::fake([
OrderCreated::class,
]);
$order = Order::factory()->create();
Event::assertDispatched(OrderCreated::class);
// 其他事件将按正常方式派发...
$order->update([
// ...
]);
}
你也可以使用 except
方法伪造 所有事件,除了指定的一部分事件:
Event::fake()->except([
OrderCreated::class,
]);
局部范围事件伪造(Scoped Event Fakes)
如果你只想在测试的某一部分伪造事件监听器,可以使用 fakeFor
方法:
<?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;
});
// 事件将按正常方式派发,观察者也会执行...
$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
{
/**
* 测试订单处理
*/
public function test_orders_can_be_processed(): void
{
$order = Event::fakeFor(function () {
$order = Order::factory()->create();
Event::assertDispatched(OrderCreated::class);
return $order;
});
// 事件将按正常方式派发,观察者也会执行...
$order->update([
// ...
]);
}
}
本译文仅用于学习和交流目的,转载请务必注明文章译者、出处、和本文链接
我们的翻译工作遵照 CC 协议,如果我们的工作有侵犯到您的权益,请及时联系我们。