跳到內容

事件與事件監聽器

編輯此頁

在 Symfony 應用程式的執行期間,會觸發許多事件通知。您的應用程式可以監聽這些通知,並透過執行任何程式碼來回應它們。

Symfony 在處理 HTTP 請求時,會觸發數個與核心相關的事件。第三方套件也可能派發事件,您甚至可以從自己的程式碼中派發自訂事件

本文中顯示的所有範例都為了保持一致性,而使用相同的 KernelEvents::EXCEPTION 事件。在您自己的應用程式中,您可以使用任何事件,甚至可以在同一個訂閱器中混合使用多個事件。

建立事件監聽器

監聽事件最常見的方式是註冊事件監聽器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// src/EventListener/ExceptionListener.php
namespace App\EventListener;

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;

class ExceptionListener
{
    public function __invoke(ExceptionEvent $event): void
    {
        // You get the exception object from the received event
        $exception = $event->getThrowable();
        $message = sprintf(
            'My Error says: %s with code: %s',
            $exception->getMessage(),
            $exception->getCode()
        );

        // Customize your response object to display the exception details
        $response = new Response();
        $response->setContent($message);
        // the exception message can contain unfiltered user input;
        // set the content-type to text to avoid XSS issues
        $response->headers->set('Content-Type', 'text/plain; charset=utf-8');

        // HttpExceptionInterface is a special type of exception that
        // holds status code and header details
        if ($exception instanceof HttpExceptionInterface) {
            $response->setStatusCode($exception->getStatusCode());
            $response->headers->replace($exception->getHeaders());
        } else {
            $response->setStatusCode(Response::HTTP_INTERNAL_SERVER_ERROR);
        }

        // sends the modified response object to the event
        $event->setResponse($response);
    }
}

現在類別已建立,您需要將其註冊為服務,並透過使用特殊的「標籤」通知 Symfony 它是一個事件監聽器

1
2
3
4
# config/services.yaml
services:
    App\EventListener\ExceptionListener:
        tags: [kernel.event_listener]

Symfony 遵循此邏輯來決定在事件監聽器類別中呼叫哪個方法

  1. 如果 kernel.event_listener 標籤定義了 method 屬性,則這是要呼叫的方法名稱;
  2. 如果未定義 method 屬性,請嘗試呼叫 __invoke() 魔術方法(這使事件監聽器可調用);
  3. 如果 __invoke() 方法也未定義,則拋出例外。

注意

kernel.event_listener 標籤有一個選用屬性稱為 priority,它是一個正或負整數,預設為 0,它控制監聽器執行的順序(數字越高,監聽器執行得越早)。當您需要保證一個監聽器在另一個監聽器之前執行時,這非常有用。Symfony 內部監聽器的優先順序通常範圍從 -256256,但您自己的監聽器可以使用任何正或負整數。

注意

kernel.event_listener 標籤有一個選用屬性稱為 event,當監聽器 $event 參數未類型化時,這非常有用。如果您設定它,它將變更 $event 物件的類型。對於 kernel.exception 事件,它是 ExceptionEvent。查看 Symfony 事件參考以查看每個事件提供的物件類型。

透過此屬性,Symfony 遵循此邏輯來決定在事件監聽器類別中呼叫哪個方法

  1. 如果 kernel.event_listener 標籤定義了 method 屬性,則這是要呼叫的方法名稱;
  2. 如果未定義 method 屬性,請嘗試呼叫方法,其名稱為 on + 「PascalCase 事件名稱」(例如,kernel.exception 事件的 onKernelException() 方法);
  3. 如果該方法也未定義,請嘗試呼叫 __invoke() 魔術方法(這使事件監聽器可調用);
  4. 如果 __invoke() 方法也未定義,則拋出例外。

使用 PHP 屬性定義事件監聽器

定義事件監聽器的另一種方法是使用 AsEventListener PHP 屬性。這允許在類別內部設定監聽器,而無需在外部檔案中新增任何組態

1
2
3
4
5
6
7
8
9
10
11
12
namespace App\EventListener;

use Symfony\Component\EventDispatcher\Attribute\AsEventListener;

#[AsEventListener]
final class MyListener
{
    public function __invoke(CustomEvent $event): void
    {
        // ...
    }
}

您可以新增多個 #[AsEventListener] 屬性來設定不同的方法。method 屬性是選用的,且在未定義時,預設為 on + 大寫事件名稱。在以下範例中,'foo' 事件監聽器未明確定義其方法,因此將呼叫 onFoo() 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
namespace App\EventListener;

use Symfony\Component\EventDispatcher\Attribute\AsEventListener;

#[AsEventListener(event: CustomEvent::class, method: 'onCustomEvent')]
#[AsEventListener(event: 'foo', priority: 42)]
#[AsEventListener(event: 'bar', method: 'onBarEvent')]
final class MyMultiListener
{
    public function onCustomEvent(CustomEvent $event): void
    {
        // ...
    }

    public function onFoo(): void
    {
        // ...
    }

    public function onBarEvent(): void
    {
        // ...
    }
}

AsEventListener 也可以直接應用於方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
namespace App\EventListener;

use Symfony\Component\EventDispatcher\Attribute\AsEventListener;

final class MyMultiListener
{
    #[AsEventListener]
    public function onCustomEvent(CustomEvent $event): void
    {
        // ...
    }

    #[AsEventListener(event: 'foo', priority: 42)]
    public function onFoo(): void
    {
        // ...
    }

    #[AsEventListener(event: 'bar')]
    public function onBarEvent(): void
    {
        // ...
    }
}

注意

請注意,如果方法已類型提示預期的事件,則屬性不需要設定其 event 參數。

建立事件訂閱器

監聽事件的另一種方式是透過事件訂閱器,它是一個類別,定義一個或多個監聽一個或多個事件的方法。與事件監聽器的主要區別在於,訂閱器始終知道他們正在監聽的事件。

如果不同的事件訂閱器方法監聽相同的事件,它們的順序由 priority 參數定義。此值是一個正或負整數,預設為 0。數字越高,方法被呼叫得越早。優先順序會彙總所有監聽器和訂閱器,因此您的方法可能會在其他監聽器和訂閱器中定義的方法之前或之後呼叫。若要深入了解事件訂閱器,請閱讀 EventDispatcher 組件

以下範例顯示一個事件訂閱器,其定義了數個監聽相同 kernel.exception 事件的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// src/EventSubscriber/ExceptionSubscriber.php
namespace App\EventSubscriber;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\KernelEvents;

class ExceptionSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        // return the subscribed events, their methods and priorities
        return [
            KernelEvents::EXCEPTION => [
                ['processException', 10],
                ['logException', 0],
                ['notifyException', -10],
            ],
        ];
    }

    public function processException(ExceptionEvent $event): void
    {
        // ...
    }

    public function logException(ExceptionEvent $event): void
    {
        // ...
    }

    public function notifyException(ExceptionEvent $event): void
    {
        // ...
    }
}

就是這樣!您的 services.yaml 檔案應該已經設定為從 EventSubscriber 目錄載入服務。Symfony 會處理剩下的事情。

提示

如果您的方法在拋出例外時被呼叫,請再次檢查您是否從 EventSubscriber 目錄載入服務,並且已啟用自動設定。您也可以手動新增 kernel.event_subscriber 標籤。

請求事件,檢查類型

單一頁面可以發出多個請求(一個主要請求,然後是多個子請求 - 通常在在範本中嵌入控制器時)。對於核心 Symfony 事件,您可能需要檢查事件是否用於「主要」請求或「子請求」

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// src/EventListener/RequestListener.php
namespace App\EventListener;

use Symfony\Component\HttpKernel\Event\RequestEvent;

class RequestListener
{
    public function onKernelRequest(RequestEvent $event): void
    {
        if (!$event->isMainRequest()) {
            // don't do anything if it's not the main request
            return;
        }

        // ...
    }
}

某些事項,例如檢查真實請求的資訊,可能不需要在子請求監聽器上執行。

監聽器或訂閱器

監聽器和訂閱器可以在同一個應用程式中不加區分地使用。決定使用哪一個通常是個人品味的問題。但是,它們各自有一些小的優點

  • 訂閱器更容易重複使用,因為事件的知識保留在類別中,而不是在服務定義中。這就是 Symfony 內部使用訂閱器的原因;
  • 監聽器更具彈性,因為套件可以根據某些組態值有條件地啟用或停用它們中的每一個。

事件別名

透過依賴注入設定事件監聽器和訂閱器時,Symfony 的核心事件也可以透過對應事件類別的完整限定類別名稱 (FQCN) 來引用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// src/EventSubscriber/RequestSubscriber.php
namespace App\EventSubscriber;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;

class RequestSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        return [
            RequestEvent::class => 'onKernelRequest',
        ];
    }

    public function onKernelRequest(RequestEvent $event): void
    {
        // ...
    }
}

在內部,事件 FQCN 被視為原始事件名稱的別名。由於在編譯服務容器時已發生對應,因此當檢查事件派發器時,使用 FQCN 而非事件名稱的事件監聽器和訂閱器將顯示在原始事件名稱下。

此別名對應可以透過註冊編譯器傳遞 AddEventAliasesPass 來擴展自訂事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// src/Kernel.php
namespace App;

use App\Event\MyCustomEvent;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\EventDispatcher\DependencyInjection\AddEventAliasesPass;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;

class Kernel extends BaseKernel
{
    protected function build(ContainerBuilder $container): void
    {
        $container->addCompilerPass(new AddEventAliasesPass([
            MyCustomEvent::class => 'my_custom_event',
        ]));
    }
}

編譯器傳遞將始終擴展現有的別名清單。因此,使用不同組態註冊傳遞的多個實例是安全的。

偵錯事件監聽器

您可以使用主控台找出事件派發器中註冊了哪些監聽器。若要顯示所有事件及其監聽器,請執行

1
$ php bin/console debug:event-dispatcher

您可以透過指定事件名稱來取得特定事件的已註冊監聽器

1
$ php bin/console debug:event-dispatcher kernel.exception

或可以取得與事件名稱部分相符的所有內容

1
2
$ php bin/console debug:event-dispatcher kernel // matches "kernel.exception", "kernel.response" etc.
$ php bin/console debug:event-dispatcher Security // matches "Symfony\Component\Security\Http\Event\CheckPassportEvent"

安全性系統為每個防火牆使用一個事件派發器。使用 --dispatcher 選項來取得特定事件派發器的已註冊監聽器

1
$ php bin/console debug:event-dispatcher --dispatcher=security.event_dispatcher.main

如何設定前置與後置過濾器

在 Web 應用程式開發中,非常常見的是需要一些邏輯在您的控制器動作之前或之後立即執行,以充當過濾器或掛鉤。

某些 Web 框架定義了諸如 preExecute()postExecute() 之類的方法,但在 Symfony 中沒有這種東西。好消息是,有一種更好的方法可以使用 EventDispatcher 組件來干預請求 -> 回應流程。

Token 驗證範例

假設您需要開發一個 API,其中某些控制器是公開的,但其他控制器僅限於一個或多個用戶端。對於這些私人功能,您可能會向用戶端提供 Token 以識別自己。

因此,在執行控制器動作之前,您需要檢查動作是否受限。如果受限,您需要驗證提供的 Token。

注意

請注意,為了本食譜的簡潔性,Token 將在組態中定義,並且不會使用資料庫設定或透過安全性組件進行驗證。

使用 kernel.controller 事件的前置過濾器

首先,將一些 Token 組態定義為參數

1
2
3
4
5
# config/services.yaml
parameters:
    tokens:
        client1: pass1
        client2: pass2

標記要檢查的控制器

kernel.controller(又名 KernelEvents::CONTROLLER)監聽器會在每次請求時收到通知,就在控制器執行之前。因此,首先,您需要某種方法來識別與請求相符的控制器是否需要 Token 驗證。

一種乾淨且簡單的方法是建立一個空的介面,並讓控制器實作它

1
2
3
4
5
6
namespace App\Controller;

interface TokenAuthenticatedController
{
    // ...
}

實作此介面的控制器如下所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
namespace App\Controller;

use App\Controller\TokenAuthenticatedController;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;

class FooController extends AbstractController implements TokenAuthenticatedController
{
    // An action that needs authentication
    public function bar(): Response
    {
        // ...
    }
}

建立事件訂閱器

接下來,您需要建立一個事件訂閱器,它將保存您想要在控制器之前執行的邏輯。如果您不熟悉事件訂閱器,可以在事件與事件監聽器中了解更多資訊

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// src/EventSubscriber/TokenSubscriber.php
namespace App\EventSubscriber;

use App\Controller\TokenAuthenticatedController;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ControllerEvent;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\KernelEvents;

class TokenSubscriber implements EventSubscriberInterface
{
    public function __construct(
        private array $tokens
    ) {
    }

    public function onKernelController(ControllerEvent $event): void
    {
        $controller = $event->getController();

        // when a controller class defines multiple action methods, the controller
        // is returned as [$controllerInstance, 'methodName']
        if (is_array($controller)) {
            $controller = $controller[0];
        }

        if ($controller instanceof TokenAuthenticatedController) {
            $token = $event->getRequest()->query->get('token');
            if (!in_array($token, $this->tokens)) {
                throw new AccessDeniedHttpException('This action needs a valid token!');
            }
        }
    }

    public static function getSubscribedEvents(): array
    {
        return [
            KernelEvents::CONTROLLER => 'onKernelController',
        ];
    }
}

就是這樣!您的 services.yaml 檔案應該已經設定為從 EventSubscriber 目錄載入服務。Symfony 會處理剩下的事情。您的 TokenSubscriber onKernelController() 方法將在每次請求時執行。如果即將執行的控制器實作 TokenAuthenticatedController,則會套用 Token 驗證。這可讓您在任何您想要的控制器上擁有「前置」過濾器。

提示

如果您的訂閱器在每次請求時被呼叫,請再次檢查您是否從 EventSubscriber 目錄載入服務,並且已啟用自動設定。您也可以手動新增 kernel.event_subscriber 標籤。

使用 kernel.response 事件的後置過濾器

除了擁有在控制器之前執行的「掛鉤」之外,您還可以新增一個在控制器之後執行的掛鉤。對於此範例,假設您想要將 sha1 雜湊(使用該 Token 的鹽)新增到所有已通過此 Token 驗證的回應中。

另一個核心 Symfony 事件 - 稱為 kernel.response(又名 KernelEvents::RESPONSE) - 會在每次請求時收到通知,但在控制器傳回 Response 物件之後。若要建立「後置」監聽器,請建立一個監聽器類別,並將其註冊為此事件的服務。

例如,採用上一個範例中的 TokenSubscriber,並首先記錄請求屬性內的驗證 Token。這將作為一個基本標誌,表明此請求已接受 Token 驗證

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public function onKernelController(ControllerEvent $event): void
{
    // ...

    if ($controller instanceof TokenAuthenticatedController) {
        $token = $event->getRequest()->query->get('token');
        if (!in_array($token, $this->tokens)) {
            throw new AccessDeniedHttpException('This action needs a valid token!');
        }

        // mark the request as having passed token authentication
        $event->getRequest()->attributes->set('auth_token', $token);
    }
}

現在,設定訂閱器以監聽另一個事件並新增 onKernelResponse()。這將在請求物件上尋找 auth_token 標誌,並在找到時在回應上設定自訂標頭

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// add the new use statement at the top of your file
use Symfony\Component\HttpKernel\Event\ResponseEvent;

public function onKernelResponse(ResponseEvent $event): void
{
    // check to see if onKernelController marked this as a token "auth'ed" request
    if (!$token = $event->getRequest()->attributes->get('auth_token')) {
        return;
    }

    $response = $event->getResponse();

    // create a hash and set it as a response header
    $hash = sha1($response->getContent().$token);
    $response->headers->set('X-CONTENT-HASH', $hash);
}

public static function getSubscribedEvents(): array
{
    return [
        KernelEvents::CONTROLLER => 'onKernelController',
        KernelEvents::RESPONSE => 'onKernelResponse',
    ];
}

就是這樣!現在,TokenSubscriber 會在每個控制器執行之前 (onKernelController()) 和每個控制器傳回應答之後 (onKernelResponse()) 收到通知。透過讓特定的控制器實作 TokenAuthenticatedController 介面,您的監聽器知道它應該對哪些控制器採取動作。透過將值儲存在請求的「屬性」容器中,onKernelResponse() 方法知道要新增額外的標頭。玩得開心!

如何在不使用繼承的情況下客製化方法行為

如果您想要在方法呼叫之前或之後立即執行某些操作,您可以分別在方法的開頭或結尾派發事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class CustomMailer
{
    // ...

    public function send(string $subject, string $message): mixed
    {
        // dispatch an event before the method
        $event = new BeforeSendMailEvent($subject, $message);
        $this->dispatcher->dispatch($event, 'mailer.pre_send');

        // get $subject and $message from the event, they may have been modified
        $subject = $event->getSubject();
        $message = $event->getMessage();

        // the real method implementation is here
        $returnValue = ...;

        // do something after the method
        $event = new AfterSendMailEvent($returnValue);
        $this->dispatcher->dispatch($event, 'mailer.post_send');

        return $event->getReturnValue();
    }
}

在此範例中,派發了兩個事件

  1. mailer.pre_send,在方法呼叫之前,
  2. 以及 mailer.post_send 在方法呼叫之後。

每個都使用自訂的 Event 類別來與兩個事件的監聽器溝通資訊。例如,BeforeSendMailEvent 可能會像這樣

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// src/Event/BeforeSendMailEvent.php
namespace App\Event;

use Symfony\Contracts\EventDispatcher\Event;

class BeforeSendMailEvent extends Event
{
    public function __construct(
        private string $subject,
        private string $message,
    ) {
    }

    public function getSubject(): string
    {
        return $this->subject;
    }

    public function setSubject(string $subject): string
    {
        $this->subject = $subject;
    }

    public function getMessage(): string
    {
        return $this->message;
    }

    public function setMessage(string $message): void
    {
        $this->message = $message;
    }
}

AfterSendMailEvent 甚至像這樣

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// src/Event/AfterSendMailEvent.php
namespace App\Event;

use Symfony\Contracts\EventDispatcher\Event;

class AfterSendMailEvent extends Event
{
    public function __construct(
        private mixed $returnValue,
    ) {
    }

    public function getReturnValue(): mixed
    {
        return $this->returnValue;
    }

    public function setReturnValue(mixed $returnValue): void
    {
        $this->returnValue = $returnValue;
    }
}

這兩個事件都允許您取得一些資訊 (例如 getMessage()),甚至變更該資訊 (例如 setMessage())。

現在,您可以建立一個事件訂閱器來掛勾這個事件。例如,您可以監聽 mailer.post_send 事件並變更該方法的回傳值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// src/EventSubscriber/MailPostSendSubscriber.php
namespace App\EventSubscriber;

use App\Event\AfterSendMailEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

class MailPostSendSubscriber implements EventSubscriberInterface
{
    public function onMailerPostSend(AfterSendMailEvent $event): void
    {
        $returnValue = $event->getReturnValue();
        // modify the original $returnValue value

        $event->setReturnValue($returnValue);
    }

    public static function getSubscribedEvents(): array
    {
        return [
            'mailer.post_send' => 'onMailerPostSend',
        ];
    }
}

就這樣!您的訂閱器應該會自動被呼叫 (或者閱讀更多關於 事件訂閱器設定 的資訊)。

這份作品,包含程式碼範例,以 Creative Commons BY-SA 3.0 授權條款授權。
目錄
    版本