ThinkPHP6 原始碼閱讀(十):事件

tsin發表於2019-09-01

說明

前面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類物件大概如下:

ThinkPHP6 原始碼閱讀(十):事件

監聽器執行

事件監聽器繫結到事件之後,框架在初始化過程中,將這些配置載入到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決定,$oncetrue,只返回最後一個監聽器執行結果,反之,返回所有結果組成的陣列。

監聽器引數傳遞以及事件類

注意到監聽器的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陣列。最後的結果大概是這樣的:

ThinkPHP6 原始碼閱讀(十):事件
UserLoginUserLogout事件標識下,分別新增了對應的監聽器,分別是事件訂閱類app\subscribe\UseronUserLoginonUserLogout

監聽器新增完成後,接著是等待事件觸發。login操作中,執行了Event::trigger('UserLogin');,這將執行app\subscribe\UseronUserLogin,其執行過程跟前面分析的執行事件監聽器是一樣的,結果輸出如下:

使用者登入了
我知道使用者登入了,因為我訂閱了

同理,訪問User控制器的logout操作,得到:

使用者退出了
我知道使用者退出了,因為我訂閱了

事件訂閱分析完畢。總結一下,事件訂閱就是一種「複合」的監聽器,可以同時監聽多個事件。從其實現過程來看,本質和事件監聽器是一樣的,個人認為,使用事件訂閱的好處是僅僅集中管理程式碼,把對某個物件(被觀察者)的多個動作的監聽,都寫在一個事件訂閱類裡面,因而就不用另外寫相應多個動作的監聽器類。

關於觀察者模式

事件的實現機制,實際上是使用觀察者模式實現的。觀察者模式的好處是實現類的鬆耦合,被觀察者不需要知道觀察者到底做了什麼,只需要觸發事件就夠了;另外,觀察者的數量可以靈活地增加、減少,而不用修改被觀察者。深入理解觀察者模式,可以參考這篇:學好事件,先學學觀察者模式

Was mich nicht umbringt, macht mich stärker

相關文章