Laravel Event的分析和使用
第一部分 概念解釋 請自行檢視觀察者模式
第二部分 原始碼分析 (邏輯較長,不喜歡追程式碼可以直接看使用部分)
第三部分 使用
第一部分 解釋
當一個使用者閱讀了一篇文章,可能需要給文章增加點選量,給閱讀的使用者增加積分,給文章作者傳送通知等功能。對於以上操作,
我們可以使用laravel提供的事件機制進行良好的解耦。以上的使用者閱讀一篇文章,就是laravel中的一個事件,使用者閱讀文章後觸
發的一系列操作就是此事件的監聽者,他們會被逐個執行。實際上laravel的事件服務是觀察者模式的一個實現,
觸發了一個事件,就好象推倒了多米諾骨牌的地一塊,剩下的操作就驕傲給提前擺好的陣型自行完成了。不同的是現實中我們很難讓骨牌
停止倒塌, 但在laravel中我們可以很方便的停止事件的傳播,即終止監聽者的呼叫鏈。
第二部分 追原始碼
事件服務的註冊
# laravel中每個服務,需要先註冊再啟動,其中註冊是一定的,啟動過程可以沒有。事件服務也不例外。但事件服務的註冊位置較為特殊,
# 位於Application.php
protected function registerBaseServiceProviders()
{
# 事件服務就是在此註冊的
# 注意application的register方法實際上呼叫了服務提供者的register方法
$this->register(new EventServiceProvider($this));
$this->register(new LogServiceProvider($this));
$this->register(new RoutingServiceProvider($this));
}
# 事件服務提供者 Illuminate\Events\EventServiceProvider
public function register()
{
# 注意此處的singleton繫結 後面會使用到
$this->app->singleton('events', function ($app) {
// 繫結的是一個disaptcher例項 併為事件服務設定了佇列解析器
// 注意此閉包是在我們嘗試從容器中解析事件服務的時候才會執行
return (new Dispatcher($app))->setQueueResolver(function () use ($app) {
return $app->make(QueueFactoryContract::class);
});
});
}
# 看Illuminate\Events\Dispatcher類
# 簡單的構造方法
public function __construct(ContainerContract $container = null)
{
$this->container = $container ?: new Container;
}
# setQueueResolver方法 一個簡單的set
public function setQueueResolver(callable $resolver)
{
$this->queueResolver = $resolver;
return $this;
}
# 可以看到事件服務的註冊實際上是向容器中註冊了一個事件的分發器
事件服務的啟動一(獲取所有的事件和監聽者)
# 框架啟動的過程中會呼叫app/Providers下所有服務提供者的boot方法,事件服務也不例外。
App\Providers\EventServiceProvider檔案
class EventServiceProvider extends ServiceProvider
{
# 此陣列鍵為事件名,值為事件的監聽者
# 事件服務的啟動階段會讀取此配置,將所有的事件和事件監聽者對應起來並掛載到事件分發器Dispatcher上
protected $listen = [
Registered::class => [
SendEmailVerificationNotification::class,
],
];
# 事件服務啟動真正呼叫的方法 可以看到呼叫了父類的boot方法
# 也可以在boot方法中向事件分發器中自行繫結事件和監聽者
public function boot()
{
parent::boot();
//
}
}
# EventServiceProvider的父類
# 註冊事件監聽器
public function boot()
{
// getEvents方法 獲取事件和監聽器
$events = $this->getEvents();
foreach ($events as $event => $listeners) {
foreach (array_unique($listeners) as $listener) {
// 此處Event facade對應的是Dispatcher的listen方法
// facade的原理和使用之前介紹過
Event::listen($event, $listener);
}
}
foreach ($this->subscribe as $subscriber) {
// 呼叫的是Dispatcher的subscribe方法
Event::subscribe($subscriber);
}
}
# getEvents方法
public function getEvents()
{
if ($this->app->eventsAreCached()) {
$cache = require $this->app->getCachedEventsPath();
return $cache[get_class($this)] ?? [];
} else {
return array_merge_recursive(
// 如果事件非常多,也可以設定事件和監聽者的目錄,讓框架自行幫助查詢
// 如果需要開啟discoveredEvents功能,需要在App\Providers\EventServiceProvider中
// 重寫shouldDiscoverEvents方法 並返回true 代表開啟事件自動發現
// 如果需要指定事件和監聽者的目錄,需要重寫discoverEventsWithin方法,其中返回目錄陣列
// 當然你也可以全部寫在listen屬性中
// 當重寫了以上兩個方法的時候 返回的陣列和$listen屬性的格式是完全一致的 以事件名稱為key 監聽者為value
$this->discoveredEvents(),
// 返回的就是App\Providers\EventServiceProvider下的listen陣列
$this->listens()
);
}
}
# discoveredEvents方法 此方法觸發的前提是重寫了shouldDiscoverEvents方法
public function discoverEvents()
{
// 使用了laravel提供的collect輔助函式 文件有詳細章節介紹
// collect函式返回collection集合例項方便我們鏈式操作
// reject方法的作用是 回撥函式返回 true 就會把對應的集合項從集合中移除
// reduce方法的作用是 將每次迭代的結果傳遞給下一次迭代直到集合減少為單個值
return collect($this->discoverEventsWithin())
// discoverEventsWithin方法返回查詢事件監聽者的目錄陣列
// 預設返回 (array) $this->app->path('Listeners')
// 我們自然可以重寫discoverEventsWithin方法,返回我們指定的監聽者目錄
->reject(function ($directory) {
// 移除集合中不是目錄的元素
return ! is_dir($directory);
})
->reduce(function ($discovered, $directory) {
return array_merge_recursive(
$discovered,
// 使用Symfony的Finder元件查詢Listener檔案
DiscoverEvents::within($directory, base_path())
);
}, []);
}
# Illuminate\Foundation\Events\DiscoverEvents::within方法
# 提取給定目錄中的全部監聽者
public static function within($listenerPath, $basePath)
{
return collect(static::getListenerEvents(
(new Finder)->files()->in($listenerPath), $basePath
))->mapToDictionary(function ($event, $listener) {
return [$event => $listener];
})->all();
}
protected static function getListenerEvents($listeners, $basePath)
{
$listenerEvents = [];
// $listeners是Finder元件返回指定目錄下的迭代器,遍歷可以拿到目錄下的所有檔案
foreach ($listeners as $listener) {
try {
$listener = new ReflectionClass(
// 將絕對路徑轉換為類名
static::classFromFile($listener, $basePath)
);
} catch (ReflectionException $e) {
continue;
}
if (! $listener->isInstantiable()) {
continue;
}
// dump($listener->getMethods(ReflectionMethod::IS_PUBLIC));
foreach ($listener->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
// 代表著一個監聽者類中 可以設定多個監聽器
if (! Str::is('handle*', $method->name) ||
! isset($method->getParameters()[0])) {
continue;
}
$listenerEvents[$listener->name.'@'.$method->name] =
// 可以認為此處返回的是事件名
// 寫在handle*方法中的引數 我建議一定要加上型別提示,並且將型別名引數作為第一個引數傳入
Reflector::getParameterClassName($method->getParameters()[0]);
}
}
// 過濾事件引數名為空的監聽器並返回
return array_filter($listenerEvents);
}
事件服務的啟動二(註冊事件監聽者)
# 上面獲取了全部的事件監聽者 下面就要註冊這些事件監聽者了
# 繼續看EventServiceProvider::boot方法
# 使用php artisan event:list 可以檢視框架中已經註冊的事件
# php artisan event:cache php artisan event:clear
public function boot()
{
# 拿到了$listen屬性和要求自動發現的所有事件(如果開啟了自動發現的話)
$events = $this->getEvents();
// dump($events);
foreach ($events as $event => $listeners) {
foreach (array_unique($listeners) as $listener) {
// 呼叫dispatcher的listen方法
// 事件名為key 事件監聽者為value 進行事件的註冊監聽
Event::listen($event, $listener);
}
}
foreach ($this->subscribe as $subscriber) {
// subscribe方法請自行檢視
Event::subscribe($subscriber);
}
}
# Dispatcher::listen方法
# 遍歷getEvents中所有的事件和監聽者 通過實際呼叫Dispatcher的makeListener建立監聽者
# 以event名為鍵 建立的監聽者閉包為值 儲存在陣列屬性中 供事件觸發的時候查詢呼叫
// 向排程器註冊事件監聽器
public function listen($events, $listener)
{
// dump($events, $listener);
foreach ((array) $events as $event) {
// 如果事件名稱中包含*
if (Str::contains($event, '*')) {
$this->setupWildcardListen($event, $listener);
} else {
// 正常事件名
$this->listeners[$event][] = $this->makeListener($listener);
}
}
}
# 繫結事件和監聽者閉包的對映
protected function setupWildcardListen($event, $listener)
{
// 當一系列的事件都想觸發指定的監聽者的時候 就可以使用*進行匹配
$this->wildcards[$event][] = $this->makeListener($listener, true);
// 每次更新了通配事件後 都清除快取
$this->wildcardsCache = [];
}
# 官方註釋表名此方法向事件分發器中註冊一個事件監聽者
# 其實就是返回事件觸發時執行的監聽者閉包
# 傳入的listener可以使App\Listener\MyListener 或 App\Listener\MyListener@myHandle這種字串
# 或者是一個接收兩個引數的閉包
public function makeListener($listener, $wildcard = false)
{
if (is_string($listener)) {
// 如果傳遞的是一個字串的話 呼叫createClassListener放回閉包
return $this->createClassListener($listener, $wildcard);
}
// 如果listener是個閉包 那麼直接將事件物件作為引數傳入監聽者
// 事件觸發的時候 直接執行此閉包
return function ($event, $payload) use ($listener, $wildcard) {
if ($wildcard) {
return $listener($event, $payload);
}
// 可變數量的引數列表
return $listener(...array_values($payload));
};
}
# createClassListener方法
public function createClassListener($listener, $wildcard = false)
{
// 當傳遞的是一個class名或者是帶@method的字串的時候
return function ($event, $payload) use ($listener, $wildcard) {
if ($wildcard) {
// createClassCallable返回一個陣列 第一個引數是$listener的例項 第二個引數是method
return call_user_func($this->createClassCallable($listener), $event, $payload);
}
return call_user_func_array(
$this->createClassCallable($listener), $payload
);
};
}
# createClassCallable方法
protected function createClassCallable($listener)
{
// 從字串中獲取類名和方法名
[$class, $method] = $this->parseClassCallable($listener);
// 判斷是否需要佇列化監聽器
if ($this->handlerShouldBeQueued($class)) {
// class類名 method 方法名
return $this->createQueuedHandlerCallable($class, $method);
}
// 如果不需要非同步化執行監聽者 直接返回[$listener, 'method']陣列
// class通過container獲得 意味著我們可以利用容器方便的注入listner需要的依賴
// 注意此處返回的是listener的例項 和 呼叫監聽者時執行的方法名
return [$this->container->make($class), $method];
}
# handlerShouldBeQueued方法 判斷如果一個監聽者實現了ShouldQueue介面 就認為此監聽者需要佇列化執行
protected function handlerShouldBeQueued($class)
{
// 檢查監聽者是否實現了ShouldQueue介面
// 是否使用佇列處理事件
try {
return (new ReflectionClass($class))->implementsInterface(
ShouldQueue::class
);
} catch (Exception $e) {
return false;
}
}
# createQueuedHandlerCallable方法
protected function createQueuedHandlerCallable($class, $method)
{
return function () use ($class, $method) {
$arguments = array_map(function ($a) {
return is_object($a) ? clone $a : $a;
}, func_get_args());
// handlerWantsToBeQueued方法 動態判斷監聽者是否需要投遞到佇列執行
if ($this->handlerWantsToBeQueued($class, $arguments)) {
$this->queueHandler($class, $method, $arguments);
}
};
}
# handlerWantsToBeQueued
protected function handlerWantsToBeQueued($class, $arguments)
{
$instance = $this->container->make($class);
// 動態判斷是否需要非同步化事件處理
// 需要我們在監聽器shouldQueue方法中return bool值
if (method_exists($instance, 'shouldQueue')) {
// 可以在監聽者的shouldQueue方法中返回bool值 動態判斷是否需要非同步化
return $instance->shouldQueue($arguments[0]);
}
return true;
}
# queueHandler方法
// 判斷listener的各種屬性 將監聽者投遞到佇列
// laravel 佇列以後會單獨講解 此篇先到這裡
protected function queueHandler($class, $method, $arguments)
{
[$listener, $job] = $this->createListenerAndJob($class, $method, $arguments);
// resolveQueue獲取註冊事件服務時設定的queueResolver
$connection = $this->resolveQueue()->connection(
$listener->connection ?? null
);
$queue = $listener->queue ?? null;
isset($listener->delay)
? $connection->laterOn($queue, $listener->delay, $job)
: $connection->pushOn($queue, $job);
}
# 以上便是事件註冊的基本程式碼 總體來說 我們看到呼叫Dispatcher的listen方法 可以註冊監聽者和事件的繫結
# 監聽者都已閉包的形式進行包裹 這樣的好處是可以儲存上下文變數
# 涉及到的非同步處理 其他文章會進行講解
# 值得注意的是 註冊好的閉包 並不會執行 當觸發相應的事件時才會執行
事件的觸發
# 業務程式碼中呼叫event()方法就可以觸發一個事件了 執行的就是Dispatch::dispatch方法
public function dispatch($event, $payload = [], $halt = false)
{
// 傳遞事件物件本身作為disaptch的引數 會將物件類名作為事件名 並將事件物件作為payload傳遞到監聽者
// 參考使用方式 event(new SomeEvent()) Event::disaptch(new SomeEvent())
[$event, $payload] = $this->parseEventAndPayload(
$event, $payload
);
if ($this->shouldBroadcast($payload)) {
$this->broadcastEvent($payload[0]);
}
$responses = [];
foreach ($this->getListeners($event) as $listener) {
// 執行每個監聽者閉包
$response = $listener($event, $payload);
if ($halt && ! is_null($response)) {
// 直接返回結果給事件觸發
return $response;
}
// 如果某個監聽者返回了false 那麼終止後續監聽者的執行
if ($response === false) {
break;
}
$responses[] = $response;
}
// 返回結果給事件觸發
return $halt ? null : $responses;
}
# parseEventAndPayload方法
protected function parseEventAndPayload($event, $payload)
{
// 如果傳遞的是一個事件物件
if (is_object($event)) {
[$payload, $event] = [[$event], get_class($event)];
}
// 如果event是一個字串 那麼直接包裝payload
return [$event, Arr::wrap($payload)];
}
// 獲取所有事件監聽者
public function getListeners($eventName)
{
$listeners = $this->listeners[$eventName] ?? [];
$listeners = array_merge(
$listeners,
$this->wildcardsCache[$eventName] ?? $this->getWildcardListeners($eventName)
);
// 如果插入的event類存在
return class_exists($eventName, false)
? $this->addInterfaceListeners($eventName, $listeners)
: $listeners;
}
# addInterfaceListeners方法
protected function addInterfaceListeners($eventName, array $listeners = [])
{
foreach (class_implements($eventName) as $interface) {
// 判斷事件或者其父類實現的介面是否繫結了監聽器
if (isset($this->listeners[$interface])) {
foreach ($this->listeners[$interface] as $names) {
$listeners = array_merge($listeners, (array) $names);
}
}
}
// 返回合併後的監聽者
return $listeners;
}
# 部分其他方法
# until方法
# 觸發事件 並返回第一個不為null的監聽器結果
public function until($event, $payload = [])
{
return $this->dispatch($event, $payload, true);
}
# push方法
# 呼叫的還是listen方法 只不過指定了payload引數
public function push($event, $payload = [])
{
$this->listen($event.'_pushed', function () use ($event, $payload) {
$this->dispatch($event, $payload);
});
}
# flush方法 呼叫push註冊的監聽者
public function flush($event)
{
$this->dispatch($event.'_pushed');
}
第三部分 使用
使用一 通過觸發事件給監聽者傳參
1 在App\Porviders\EventServiceProvider的listen屬性中繫結事件和監聽者對映關係
...
use App\Events\TestEvent1;
use App\Listeners\TestListener1;
use App\Listeners\TestListener2;
...
protected $listen = [
...
TestEvent1::class => [
TestListener1::class,
// 自定義監聽者閉包呼叫的方法myHandle
TestListener2::class . '@myHandle'
]
];
...
2 php artisan event:generate 按照listen陣列的事件監聽者對映生成
3 我們不在TestEvent1事件中做過多處理 在本示例中保持原樣即可
4 編寫TestListener1檔案
<?php
namespace App\Listeners;
use App\Components\Log\LogManager;
use App\Events\TestEvent1;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use App\Providers\LogManagerServiceProvider;
class TestListener1
{
protected $logger;
// 通過上面的分析 我們知道監聽者時通過容器解析出來的 所以可以盡情的注入
public function __construct(LogManager $logger)
{
$this->logger = $logger;
}
// 自定義傳參給事件監聽者
public function handle(TestEvent1 $event, string $type)
{
// dump($type);
// dump(debug_backtrace());
$this->logger->driver($type)->logCertains('emergency', 'something emergency');
}
}
5 編寫TestListener2檔案
<?php
namespace App\Listeners;
use App\Events\TestEvent1;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
class TestListener2
{
/**
* Create the event listener.
*
* @return void
*/
public function __construct()
{
//
}
// 其實指定了myHandle方法之後 原生的handle方法就可以註釋或者什麼都不寫了
// 通過上面的分析我們知道 框架會執行監聽者中所有以handle開頭的方法
// public function handle(TestEvent1 $event)
// {
// echo '345';
// }
// 此方法也是會執行的
// public function handleabc(TestEvent1 $event)
// {
// echo 'abc';
// }
public function myHandle(TestEvent1 $event)
{
dump('do whatever you like');
}
}
6 編寫測試路由 觸發事件
Route::get('event1', function () {
// 情況一 傳遞的是事件例項 如果你需要在事件例項中注入依賴 當然可以使用容器解析事件物件
// 當注入的是一個事件物件的時候 會觸發事件類名這個事件 並且將事件物件作為payload傳遞給handle方法
// Event::dispatch(new \App\Events\TestEvent1());
// 情況二 傳遞的是事件名 第二個引數生效
// 演示如何不依賴事件物件 傳遞引數
Event::dispatch(TestEvent1::class, [new TestEvent1(), 'stream']);
});
使用二 設定事件自動發現
1 在App\Porviders\EventServiceProvider中重寫shouldDiscoverEvents方法 啟用事件發現
// 啟用事件自動發現
public function shouldDiscoverEvents()
{
return true;
}
2 我們更近一步 設定自動監聽者所在目錄 重寫discoverEventsWithin方法 指定自動發現目錄
// 指定自動發現目錄
// 預設的就是app_path('Listeners')
public function discoverEventsWithin()
{
return [
// 這裡可以註釋掉Listeners目錄為了避免和上面的listen屬性重複 導致所有的監聽者都會執行兩遍
// app_path('Listeners'),
app_path('MyListeners')
];
}
3 編寫事件App\Events\TestEvent2檔案 我這裡的程式碼沒有任何的實際意義
<?php
namespace App\Events;
class TestEvent2
{
protected $name = 'xy';
public function __construct()
{
// 隨意發揮
}
public function getName()
{
return $this->name;
}
}
4 手動建立App\MyListeners\Listener1檔案
# 通過上面的原始碼分析 我們知道laravel會將所有以handle開頭的方法引數遍歷
# 然後將第一個引數的類名作為要觸發的事件名,將事件引數作為payload傳入
<?php
namespace App\MyListeners;
use App\Events\TestEvent2;
class MyListener1
{
public function handle(TestEvent2 $evt)
{
dump($evt->getName(), 'abc');
return false; // 如果不註釋掉此行程式碼,事件的呼叫鏈到此終結
}
public function handleAbc(TestEvent2 $evt)
{
dump($evt->getName());
}
}
5 手動建立App\MyListeners\Listener2檔案
<?php
namespace App\MyListeners;
use App\Events\TestEvent2;
class MyListener2
{
public function handle(TestEvent2 $evt)
{
dump($evt->getName());
}
}
6 建立事件自動發現路由
// 測試自動發現
Route::get('event2', function(){
Event::dispatch(new TestEvent2());
});
使用三 implement的使用 當事件實現了其他事件介面,會自動觸發其他事件繫結的監聽者
對應的方法為Dispatcher::addInterfaceListeners 請看第二部分
1 建立事件介面
<?php
namespace App\Events;
interface TestEvent3
{ }
<?php
namespace App\Events;
interface TestEvent4
{ }
2 建立監聽者
<?php
namespace App\Listeners;
class TestListener3
{
public function handle()
{
dump('listener3 added by event interface3');
}
}
<?php
namespace App\Listeners;
class TestListener4
{
public function handle()
{
dump('listener3 added by event interface4');
}
}
<?php
namespace App\Listeners;
class TestListener5 implements TestEvent3, TestEvent4
{
public function handle()
{
dump('five own listener');
}
}
3 事件實現上面的兩個介面
<?php
namespace App\Events;
class TestEvent5 implements TestEvent3, TestEvent4
{ }
4 註冊事件監聽者
protected $listen = [
...
TestEvent3::class => [
TestListener3::class
],
TestEvent4::class => [
TestListener4::class
],
# 甚至可以註釋掉下面3行 只需要TestEvent5實現上面兩個介面即可觸發上面註冊的監聽者
TestEvent5::class => [
TestListener5::class
]
];
5 最重要的一步 force and brutal 改原始碼 沒錯 就是改原始碼
# Dispatcher::getListeners方法
...
// return class_exists($eventName, false)
return class_exists($eventName)
? $this->addInterfaceListeners($eventName, $listeners)
: $listeners;
...
6 建立測試路由
Route::get('event5', function () {
Event::dispatch(TestEvent5::class);
});
使用四 until和flush
until方法預設呼叫dispatch方法 當時間監聽者返回不為null則停止執行後面的監聽者 並返回結果給事件觸發位置
1 配置時間監聽者
protected $listen = [
...
TestEvent6::class => [
TestListener6::class,
TestListener7::class,
TestListener8::class
]
];
2 php artisan event:generate
3 簡單編寫事件監聽者
# listener6
public function handle(TestEvent6 $event)
{
dump('return null');
}
# listener7
public function handle(TestEvent6 $event)
{
// 注意此監聽者是有返回值的
return 123;
}
# listener8
public function handle(TestEvent6 $event)
{
// 並不會執行7後面的監聽者 根本就不會執行
return 'return something in vain';
}
4 編寫測試路由
Route::get('event6', function () {
$res = Event::until(new TestEvent6());
// 可以看到監聽者8並沒有執行 因為7返回的不是null
dump($res);
});
使用五 push&flush 請檢視上面的原始碼分析
push方法就是提前將event和payload註冊好 供flush呼叫
1 在App\Providers\EventServiceProvider::boot方法中註冊(這裡只是舉例在boot中進行註冊,你可以在你喜歡的任何地方註冊)
public function boot()
{
parent::boot();
// 註冊一個儲存了payload的事件監聽者
Event::push('longAssEventName', ['type' => 'redis']);
Event::listen('longAssEventName', function ($type) {
dump($type);
});
}
2 建立測試路由
Route::get('event7', function () {
Event::flush('longAssEventName');
});
以上用法沒那麼常見,這裡只是簡單演示下,細節還需各位自行嘗試,常見使用還要各位仔細查閱文件。
至於監聽者的非同步化,只需要監聽者實現ShouldQueue介面,然後簡單配置就可以了。大家可以先行檢視文件事件部分,
具體使用會在laravel佇列篇章講解。如有錯誤,勞煩指正,感謝。
最後,祝各位十一快樂!!!