說明
前面1-9篇分析完一個請求的簡單生命週期,其中涵蓋了依賴注入、中介軟體的分析,這些不在單獨分析。接下來將分析框架的事件機制。
事件配置檔案的載入
準備工作
專案根目錄命令列執行:php think make:listen ShowAppInit
建立一個監聽器,這將在app\listener
目錄下生成一個ShowAppInit.php
檔案(如果沒有listener
目錄則建立之)。接著簡單修改ShowAppInit.php
檔案的程式碼如下:
<?php
namespace app\listener;
class ShowAppInit
{
public function handle($event)
{
echo "App 初始化啦" .PHP_EOL;
}
}
監聽器建立完成後,將其新增到app
目錄下的event.php
檔案:
return [
'bind' => [
],
'listen' => [
'AppInit' => [ 'app\listener\ShowAppInit' ], //新增在這裡
'HttpRun' => [],
'HttpEnd' => [],
'LogLevel' => [],
'LogWrite' => [],
],
'subscribe' => [
],
];
這樣就繫結了一個監聽器(觀察者)到AppInit
事件,一旦該事件被觸發,監聽器將開始工作——執行其handle
方法下的程式碼。
配置載入
上面繫結監聽器後,系統是在哪裡載入了這些配置呢?
順著一個請求的生命週期:Http::run()→Http::runWithRequest()→Http::initialize()->App::initialize()→App::load(),發現在load
方法有這樣幾行:
if (is_file($appPath . 'event.php')) {
$this->loadEvent(include $appPath . 'event.php');
}
就是在這個位置,執行loadEvent
方法載入事件的配置——該方法程式碼如下:
public function loadEvent(array $event): void
{
if (isset($event['bind'])) {
// 將事件標識到事件(操作,比如一個控制器操作)的對映合併到「Event」類「$bing」成員變數中
// 比如 'UserLogin' => 'app\event\UserLogin',
$this->event->bind($event['bind']);
}
if (isset($event['listen'])) {
// 合併所有觀察者(監聽者)到Event類的listener陣列
// 其形式為實際事件(被觀察者)到觀察者的對映
$this->event->listenEvents($event['listen']);
}
if (isset($event['subscribe'])) {
// 訂閱,實際上是一個批量的監聽
// 就像一個人他同時訂閱天氣預報、股市行情、小花上QQ了……
// 一個訂閱器,裡面可以實現多個事件的監聽
// 比如,我在一個訂閱器中,同時監聽使用者登入,使用者退出等操作
$this->event->subscribe($event['subscribe']);
}
}
最終得到的Event
類物件大概如下:
監聽器執行
事件監聽器繫結到事件之後,框架在初始化過程中,將這些配置載入到Event
類的物件(當然也可以在程式中手動繫結監聽器),接下來就可以決定在何時觸發事件。AppInit
事件是在App::initialize()
方法中觸發的,其程式碼如下:
$this->event->trigger('AppInit');
接著,我們看看trigger
方法是如何觸發事件的(如何呼叫監聽器的handle
方法)——其程式碼如下:
public function trigger($event, $params = null, bool $once = false)
{
// A 如果設定了關閉事件,則直接返回,不再執行任何監聽器
if (!$this->withEvent) {
return;
}
// B
// 如果是一個物件,解析出物件的類
if (is_object($event)) {
//將物件例項作為傳入引數
$params = $event;
$event = get_class($event);
}
//根據事件標識解析出實際的事件
if (isset($this->bind[$event])) {
$event = $this->bind[$event];
}
$result = [];
// 解析出事件的監聽者(可多個)
$listeners = $this->listener[$event] ?? [];
foreach ($listeners as $key => $listener) {
// C
// 執行監聽器的操作
$result[$key] = $this->dispatch($listener, $params);
// 如果返回false,或者沒有返回值且 $once 為 true,直接中斷,不再執行後面的監聽器
if (false === $result[$key] || (!is_null($result[$key]) && $once)) {
break;
}
}
// 是否返回多個監聽器的結果
// $once 為 false 則返回最後一個監聽器的結果
return $once ? end($result) : $result;
}
A 決定是否繼續執行監聽器
trigger
方法首先通過$this->withEvent
判斷監聽器是否要執行,如果為否,則直接終止該方法。withEvent
的值可以通過如下方法設定:
-
配置檔案中,通過設定
app.with_event
的值。該值在Http::runWithRequest()
方法中讀取進來:$this->app->event->withEvent($this->app->config->get('app.with_event', true));
由此,我們可以在配置檔案中全域性開啟或者關閉事件機制。
-
通過
Event::withEvent
方法設定。由此也可以得知,我們在執行完一個監聽器之後,可以通過Event::withEvent
方法設定後面的監聽器是否還要執行。
B 事件標識解析
這裡傳給trigger
方法的是一個字串(事件標識)AppInit
,通過$event = $this->bind[$event]
得到$event
的值為think\event\AppInit
,再將該值作為鍵,$listeners = $this->listener[$event]
,從listener
陣列中獲取實際的監聽器,這裡將得到$listeners
為[app\listener\ShowAppInit]
。
C 執行監聽器
這裡主要看dispatch
方法:
protected function dispatch($event, $params = null)
{
// 如果不是字串,比如,一個閉包
if (!is_string($event)) {
$call = $event;
//一個類的靜態方法
} elseif (strpos($event, '::')) {
$call = $event;
} else {
$obj = $this->app->make($event);
$call = [$obj, 'handle'];
}
return $this->app->invoke($call, [$params]);
}
不管是閉包、靜態類方法,還是監聽器的handle
方法,都是通過invoke
方法來執行,invoke
方法實現如下:
public function invoke($callable, array $vars = [], bool $accessible = false)
{
// 如果$callable是閉包
if ($callable instanceof Closure) {
return $this->invokeFunction($callable, $vars);
}
// $callable不是閉包的情況
return $this->invokeMethod($callable, $vars, $accessible);
}
最終通過PHP反射類來執行對應的方法。
D 是否中斷和返回結果
從程式碼實現可以看出,如果一個監聽器方法最終返回false,或者沒有返回值且 $once 為 true,則不再執行後面的監聽器。trigger
方法是返回多個監4聽器的執行結果還是最後一個,由最後一個引數$once
決定,$once
為true
,只返回最後一個監聽器執行結果,反之,返回所有結果組成的陣列。
監聽器引數傳遞以及事件類
注意到監聽器的handle
方法還可以接收一個引數,從上面的分析可知,trigger
方法的第二個引數最終將傳給handle
方法。
那麼,在什麼情況下需要用到這個引數呢?舉個例子,假如要監聽一個使用者登入,我們可以新建一個監聽器,繫結事件標識,在handle
方法中實現業務邏輯——例如,輸出:「有使用者登入啦」,然後在登入程式碼的後面trigger
這個事件標識。但如果我們又要知道是誰登入的話,這時我們可以把使用者名稱作為trigger
的第二個引數傳入,在監聽器的handle
方法可以這樣使用:echo $event . 使用者登入啦
。
當然,這裡的$event
也可以是一個事件類物件。
訂閱
這裡的訂閱,本質上一種「複合」的監聽器,比如,小明同時要監聽小花跑、跳、吃飯、睡覺,這時,就可以把小明要針對這些動作做出的反應都放在一個類裡面,方便管理。
從程式碼的實現看,訂閱是依賴於監聽器存在的,訂閱類的中實現的方法,會先被新增為監聽器——所以對應的事件標識需要先有,最後,需要你trigger
需要的事件標識,才會執行訂閱類中對應的方法。
舉個例子以及分析
準備工作
-
在
app
目錄下的event.php
檔案中的listen
鍵新增兩個事件標識,如下所示:'listen' => [ 'UserLogin' => [], 'UserLogout' => [], ],
如果沒有這兩個事件標識,訂閱類的方法將無法被新增到
Event
類的$listener
陣列,導致最後無法執行到訂閱類的方法的。 -
建立一個訂閱類
專案根目錄下,命令列執行:php think make:subscribe User
,會在app/subscribe
目錄下建立一個訂閱類,在該類中新增以下方法(如程式碼所示):class User { public function onUserLogin(){ echo '我知道使用者登入了,因為我訂閱了<br>'; } public function onUserLogout(){ echo '我知道使用者退出了,因為我訂閱了<br>'; } }
-
建立控制器
在app/controller
目錄下建立一個User
控制器,新增程式碼如下:class User { public function __construct(){ //新增一個訂閱類 Event::subscribe(\app\subscribe\User::class); } public function login(){ echo "使用者登入了<br> "; Event::trigger('UserLogin'); } public function logout(){ echo "使用者退出了<br> "; Event::trigger('UserLogout'); } }
分析
假如訪問User
控制器的login
操作,除錯執行程式碼到Event::subscrible()
方法,該方法程式碼如下:
public function subscribe($subscriber)
{
if (!$this->withEvent) {
return $this;
}
// 強制轉換為陣列
$subscribers = (array) $subscriber;
foreach ($subscribers as $subscriber) {
if (is_string($subscriber)) {
//例項化事件訂閱類
$subscriber = $this->app->make($subscriber);
}
// 如果該事件訂閱類存在'subscribe'方法,執行該方法
if (method_exists($subscriber, 'subscribe')) {
// 手動訂閱
$subscriber->subscribe($this);
} else {
// 智慧訂閱
$this->observe($subscriber);
}
}
return $this;
}
這裡關鍵看observe
方法:
public function observe($observer)
{
if (!$this->withEvent) {
return $this;
}
//如果是字串,例項化對應的類
if (is_string($observer)) {
$observer = $this->app->make($observer);
}
// 獲取listener陣列所有的KEY
$events = array_keys($this->listener);
foreach ($events as $event) {
// 如果存在「\」,獲取「\」後面的字元
$name = false !== strpos($event, '\\') ? substr(strrchr($event, '\\'), 1) : $event;
//事件訂閱類中的方法,命名規則是on+事件類名/事件標識
$method = 'on' . $name;
// 如果方法存在,則新增到$listen陣列,且入口方法為$method
if (method_exists($observer, $method)) {
$this->listen($event, [$observer, $method]);
}
}
return $this;
}
執行過程見上面的註釋。該方法主要邏輯:取出Event::listener
陣列的所有KEY(這些KEY可以來自app/event.php
檔案和Event::listen()
方法新增的事件監聽),逐個迴圈,用每個KEY前面加上on
構造出一個方法名,如果$observer
對應的類存在該方法名,則將該事件訂閱類及其方法新增到Event::listener
陣列。最後的結果大概是這樣的:
在UserLogin
和UserLogout
事件標識下,分別新增了對應的監聽器,分別是事件訂閱類app\subscribe\User
的onUserLogin
和onUserLogout
。
監聽器新增完成後,接著是等待事件觸發。login
操作中,執行了Event::trigger('UserLogin');
,這將執行app\subscribe\User
的onUserLogin
,其執行過程跟前面分析的執行事件監聽器是一樣的,結果輸出如下:
使用者登入了
我知道使用者登入了,因為我訂閱了
同理,訪問User
控制器的logout
操作,得到:
使用者退出了
我知道使用者退出了,因為我訂閱了
事件訂閱分析完畢。總結一下,事件訂閱就是一種「複合」的監聽器,可以同時監聽多個事件。從其實現過程來看,本質和事件監聽器是一樣的,個人認為,使用事件訂閱的好處是僅僅集中管理程式碼,把對某個物件(被觀察者)的多個動作的監聽,都寫在一個事件訂閱類裡面,因而就不用另外寫相應多個動作的監聽器類。
關於觀察者模式
事件的實現機制,實際上是使用觀察者模式實現的。觀察者模式的好處是實現類的鬆耦合,被觀察者不需要知道觀察者到底做了什麼,只需要觸發事件就夠了;另外,觀察者的數量可以靈活地增加、減少,而不用修改被觀察者。深入理解觀察者模式,可以參考這篇:學好事件,先學學觀察者模式