跳到内容

事件和事件监听器

编辑此页

在 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 + "PascalCased event name" 的方法(例如,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 目录 加载服务 并启用了 autoconfigure。你也可以手动添加 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 组件 干预 Request -> Response 过程。

令牌验证示例

假设你需要开发一个 API,其中一些控制器是公共的,但另一些控制器仅限于一个或多个客户端。对于这些私有功能,你可以向客户端提供令牌以识别自己。

因此,在执行控制器操作之前,你需要检查操作是否受限。如果受限,你需要验证提供的令牌。

注意

请注意,为了简单起见,在此示例中,令牌将在配置中定义,既不会设置数据库,也不会使用安全组件进行身份验证。

使用 kernel.controller 事件的前置过滤器

首先,将一些令牌配置定义为参数

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

标记要检查的控制器

kernel.controller(又名 KernelEvents::CONTROLLER)监听器在每个请求上收到通知,就在控制器执行之前。因此,首先,你需要某种方法来识别与请求匹配的控制器是否需要令牌验证。

一种干净而简单的方法是创建一个空接口并使控制器实现它

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,则应用令牌身份验证。这使你可以在任何想要的控制器上拥有 "前置" 过滤器。

提示

如果你的订阅器没有在每个请求上调用,请仔细检查你是否正在从 EventSubscriber 目录 加载服务 并启用了 autoconfigure。你也可以手动添加 kernel.event_subscriber 标签。

使用 kernel.response 事件的后置过滤器

除了拥有在控制器之前执行的 "挂钩" 之外,你还可以添加在控制器之后执行的挂钩。对于此示例,假设你想将 sha1 哈希(使用该令牌的盐)添加到所有已通过此令牌身份验证的响应中。

另一个核心 Symfony 事件 - 称为 kernel.response(又名 KernelEvents::RESPONSE) - 在每个请求上收到通知,但在控制器返回 Response 对象之后。要创建 "后置" 监听器,请创建一个监听器类并将其注册为此事件的服务。

例如,采用上一个示例中的 TokenSubscriber,并首先在请求属性中记录身份验证令牌。这将作为此请求已通过令牌身份验证的基本标志

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 接口,你的监听器知道它应该对哪些控制器采取操作。通过在请求的 "attributes" 包中存储一个值,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 许可获得许可。
目录
    版本