别把后续动作全塞进控制器:Symfony 事件监听器与订阅者实战

别把后续动作全塞进控制器:Symfony 事件监听器与订阅者实战

用户注册成功后,系统可能还要发欢迎邮件、记录审计日志、赠送积分、通知其他模块。如果这些动作都堆在注册控制器里,控制器会越来越长,注册逻辑也会和邮件、积分、通知绑在一起。

Symfony EventDispatcher 可以把"某件事已经发生"广播出去,再由独立的监听器处理后续动作。注册服务只负责注册用户并派发事件,不需要知道后面有哪些监听器。

本文以 Symfony 7.4 为例,用"注册完成后记录日志并发送欢迎邮件"搭建一个完整 Demo,同时说明事件、监听器、订阅者、优先级、内核事件,以及 Symfony 事件和 Doctrine 生命周期事件的区别。

事件机制解决什么问题?

没有事件时,注册代码可能逐渐变成这样:

php 复制代码
$user = $userRepository->create($email, $password);
$mailer->sendWelcomeEmail($user);
$points->giveSignupPoints($user);
$auditLogger->recordSignup($user);
$adminNotifier->notifyNewUser($user);

注册服务因此知道了太多外围工作。增加短信通知或新积分规则时,还得回头修改注册流程。

改为事件后,注册服务只表达一件事:用户已经注册。

text 复制代码
RegisterUserService
        │
        │ dispatch(UserRegisteredEvent)
        ▼
   EventDispatcher
      ├── SendWelcomeEmailListener
      ├── GrantSignupPointsListener
      └── RecordSignupListener

发布事件的代码不需要依赖监听器。监听器之间也可以各自维护职责,这就是事件机制带来的解耦。

事件派发默认是同步 的:dispatch() 会依次调用所有监听器,等它们执行完才继续往下走。如果监听器发邮件很慢,当前 HTTP 请求仍然会变慢。需要异步处理时,可以让监听器投递 Symfony Messenger 消息,由 Worker 后台执行。

Event、Listener、Subscriber 分别是什么?

  • Event(事件):一个对象,描述发生了什么,并携带处理所需的数据。
  • Dispatcher(派发器):根据事件名称找到对应监听器并调用。
  • Listener(监听器) :处理一个或少量事件,常用 #[AsEventListener] 注册。
  • Subscriber(订阅者) :一个类集中声明自己关心的多个事件,实现 EventSubscriberInterface。

Symfony 事件名可以使用事件类的完整类名。调用 dispatch($event) 时,如果没有显式传入名称,派发器默认使用 $event::class。

自定义事件可以是普通 PHP 对象。若需要调用 stopPropagation() 阻止后续监听器执行,可以继承 Symfony\Contracts\EventDispatcher\Event。

准备项目

完整 Symfony 项目通常已经安装 EventDispatcher。若是精简项目,可安装:

bash 复制代码
composer require symfony/event-dispatcher

下面的 Demo 假设项目已安装 Doctrine ORM、Symfony Mailer 和 MakerBundle:

bash 复制代码
composer require symfony/orm-pack symfony/mailer
composer require --dev symfony/maker-bundle

Symfony Flex 的标准 config/services.yaml 通常开启自动装配和自动配置:

yaml 复制代码
services:
    _defaults:
        autowire: true
        autoconfigure: true

    App\:
        resource: '../src/'
        exclude:
            - '../src/DependencyInjection/'
            - '../src/Entity/'
            - '../src/Kernel.php'

这项配置让 Symfony 自动发现 src/ 下的服务,并识别事件监听器属性和订阅者接口。若项目使用了不同的服务配置,需要确认监听器类已注册为服务。

第一步:定义用户注册事件

创建 src/Event/UserRegisteredEvent.php:

php 复制代码
<?php

namespace App\Event;

use App\Entity\User;

final class UserRegisteredEvent
{
    public function __construct(
        private readonly User $user,
        private readonly \DateTimeImmutable $registeredAt,
    ) {
    }

    public function getUser(): User
    {
        return $this->user;
    }

    public function getRegisteredAt(): \DateTimeImmutable
    {
        return $this->registeredAt;
    }
}

事件类尽量表述已经发生的事实,比如 UserRegisteredEvent,而不是命令式的 RegisterUserEvent。通常用不可变属性保存事件发生时需要的数据,避免监听器收到事件后再依赖外部状态拼出事实。

第二步:在业务服务里派发事件

创建 src/Service/RegisterUserService.php:

php 复制代码
<?php

namespace App\Service;

use App\Entity\User;
use App\Event\UserRegisteredEvent;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;

final class RegisterUserService
{
    public function __construct(
        private EntityManagerInterface $entityManager,
        private UserPasswordHasherInterface $passwordHasher,
        private EventDispatcherInterface $eventDispatcher,
    ) {
    }

    public function register(string $email, string $plainPassword): User
    {
        $user = new User();
        $user->setEmail($email);
        $user->setPassword(
            $this->passwordHasher->hashPassword($user, $plainPassword)
        );

        $this->entityManager->persist($user);
        $this->entityManager->flush();

        $this->eventDispatcher->dispatch(
            new UserRegisteredEvent($user, new \DateTimeImmutable())
        );

        return $user;
    }
}

这里先 flush() 再派发事件,表示事件代表"用户已经写入数据库"。这样监听器执行失败时,不会让事件听起来像注册成功但数据库还没有用户。

这不代表数据库写入和邮件发送天然处于同一个事务。同步监听器抛异常时,接口可能报错,但已经执行的数据库提交不会自动回滚。需要保证数据库事务和消息发送一致时,可考虑 Transactional Outbox 等模式。

第三步:监听事件并发送邮件

创建 src/EventListener/SendWelcomeEmailListener.php:

php 复制代码
<?php

namespace App\EventListener;

use App\Event\UserRegisteredEvent;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;

#[AsEventListener(event: UserRegisteredEvent::class)]
final class SendWelcomeEmailListener
{
    public function __construct(private MailerInterface $mailer)
    {
    }

    public function __invoke(UserRegisteredEvent $event): void
    {
        $user = $event->getUser();

        $email = (new Email())
            ->from('no-reply@example.com')
            ->to($user->getEmail())
            ->subject('欢迎注册')
            ->text(sprintf(
                '账号 %s 已于 %s 注册成功。',
                $user->getEmail(),
                $event->getRegisteredAt()->format('Y-m-d H:i:s'),
            ));

        $this->mailer->send($email);
    }
}

#[AsEventListener] 将这个类标记为事件监听器。没有指定 method 时,Symfony 会调用 __invoke();显式指定方法名也可以:

php 复制代码
#[AsEventListener(event: UserRegisteredEvent::class, method: 'send')]
final class SendWelcomeEmailListener
{
    public function send(UserRegisteredEvent $event): void
    {
        // 处理事件
    }
}

属性方式从 Symfony 6.1 起可用。属性注册依赖服务自动配置;若没有启用 autoconfigure,可在 services.yaml 手工添加 kernel.event_listener 标签。

第四步:监听同一个事件做另一件事

再加一个日志监听器 src/EventListener/RecordUserRegisteredListener.php:

php 复制代码
<?php

namespace App\EventListener;

use App\Event\UserRegisteredEvent;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;

#[AsEventListener(event: UserRegisteredEvent::class)]
final class RecordUserRegisteredListener
{
    public function __construct(private LoggerInterface $logger)
    {
    }

    public function __invoke(UserRegisteredEvent $event): void
    {
        $this->logger->info('User registered', [
            'email' => $event->getUser()->getEmail(),
            'registered_at' => $event->getRegisteredAt()->format(DATE_ATOM),
        ]);
    }
}

注册服务派发一次 UserRegisteredEvent,所有注册到该事件的监听器都会收到它。增加日志、通知或积分处理时,通常只需新增监听器,不必继续往注册服务里塞代码。

另一种注册方式:Event Subscriber

一个类需要集中监听多个相关事件时,可以实现 EventSubscriberInterface:

php 复制代码
<?php

namespace App\EventSubscriber;

use App\Event\UserRegisteredEvent;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

final class UserAuditSubscriber implements EventSubscriberInterface
{
    public function __construct(private LoggerInterface $logger)
    {
    }

    public static function getSubscribedEvents(): array
    {
        return [
            UserRegisteredEvent::class => 'onUserRegistered',
        ];
    }

    public function onUserRegistered(UserRegisteredEvent $event): void
    {
        $this->logger->notice('注册审计事件', [
            'user_id' => $event->getUser()->getId(),
        ]);
    }
}

getSubscribedEvents() 返回"事件名 => 方法名"的映射。订阅多个事件时可以集中写在同一个数组里,也可以给处理方法设置优先级:

php 复制代码
public static function getSubscribedEvents(): array
{
    return [
        UserRegisteredEvent::class => [
            ['validateRegistration', 20],
            ['recordRegistration', 0],
        ],
    ];
}

Listener 和 Subscriber 怎么选?

  • 一个类只负责监听一个事件:#[AsEventListener] 清楚直接。
  • 一个类围绕同一职责处理多个事件:Subscriber 便于集中查看订阅关系。
  • 两者底层都由 EventDispatcher 调用,不存在"Subscriber 一定更快"或"Listener 功能更强"的区别。

项目里优先保持职责清晰,避免一个 Subscriber 最后变成装着所有业务逻辑的大类。

优先级:谁先执行?

多个监听器监听同一事件时,可以设置优先级。数字越大,越早执行;默认优先级是 0。

php 复制代码
#[AsEventListener(event: UserRegisteredEvent::class, priority: 20)]
final class ValidateRegisteredUserListener
{
    public function __invoke(UserRegisteredEvent $event): void
    {
        // 优先级较高,先执行
    }
}

Subscriber 也可以设置优先级:

php 复制代码
return [
    UserRegisteredEvent::class => ['onUserRegistered', 20],
];

高优先级先运行。相同优先级下,执行顺序取决于注册顺序,不建议把业务正确性建立在未明确约定的同优先级顺序上。

停止事件传播

普通事件对象没有 stopPropagation()。需要在某个监听器决定"不再通知后面的监听器"时,让事件继承 Event:

php 复制代码
<?php

namespace App\Event;

use Symfony\Contracts\EventDispatcher\Event;

final class PaymentApprovedEvent extends Event
{
    public function __construct(private readonly string $orderNo)
    {
    }

    public function getOrderNo(): string
    {
        return $this->orderNo;
    }
}

监听器中调用:

php 复制代码
$event->stopPropagation();

之后尚未执行的低优先级监听器会被跳过。这个能力会改变其他监听器是否收到事件,使用前应确认确实需要中止整个事件流程。它不是异常处理机制,也不等于撤销已经完成的操作。

Symfony 内置的 Kernel 事件

Symfony 在处理 HTTP 请求时会派发一系列内核事件,常见用途包括给请求补充信息、统一修改响应头、处理未捕获异常。

事件 发生时机 常见用途
kernel.request 请求处理早期 设置 Locale、拦截请求、补充 Request 属性
kernel.controller 控制器确定后、执行前 检查或包装控制器
kernel.controller_arguments 控制器参数解析后 检查解析好的参数
kernel.view 控制器没有返回 Response 时 把控制器结果转换成 Response
kernel.response 响应即将返回时 添加响应头、Cookie、修改响应内容
kernel.exception 请求处理中抛出异常时 记录异常或自定义错误响应
kernel.terminate 响应发送后 执行不影响响应耗时的收尾工作

例如统一给响应加一个追踪头:

php 复制代码
<?php

namespace App\EventListener;

use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;

#[AsEventListener(event: KernelEvents::RESPONSE, method: 'onKernelResponse')]
final class ResponseHeaderListener
{
    public function onKernelResponse(ResponseEvent $event): void
    {
        if (!$event->isMainRequest()) {
            return;
        }

        $event->getResponse()->headers->set('X-App-Name', 'SymfonyDemo');
    }
}

Kernel 事件可能在主请求和子请求中触发。只处理真正的浏览器主请求时,使用 isMainRequest() 过滤,避免子请求(例如片段渲染)重复执行逻辑。

Symfony EventDispatcher 和 Doctrine 事件不是一回事

Symfony 项目里还经常见到 Doctrine 的实体生命周期事件,例如 prePersist、postUpdate。它们和 Symfony EventDispatcher 的自定义业务事件属于两套机制:

类型 触发方 例子 适合场景
Symfony EventDispatcher 事件 应用代码或 HttpKernel UserRegisteredEvent、kernel.response 业务流程解耦、请求/响应扩展
Doctrine ORM 生命周期事件 Doctrine ORM prePersist、postUpdate 实体持久化过程中的局部处理

仅给单个实体设置默认值,Doctrine Lifecycle Callback 通常更简单;需要注入服务并仅关注一个实体时,可用 Entity Listener;跨实体的 ORM 生命周期工作适合 Doctrine Lifecycle Listener。邮件通知等业务后续动作通常应由业务服务派发明确的领域事件,而不是全部塞进 postPersist。

尤其要避免在 Doctrine flush() 期间的生命周期回调里随意再次 flush() 或启动复杂业务流程,这容易造成变更追踪和事务边界问题。

需要异步执行时:把事件处理放进 Messenger

EventDispatcher 本身是同步机制。邮件网关慢、第三方接口超时,都会拖住当前请求。一个常见升级方向是由事件监听器投递 Messenger 消息,再由 Worker 后台发送邮件:

php 复制代码
use Symfony\Component\Messenger\MessageBusInterface;

final class QueueWelcomeEmailListener
{
    public function __construct(private MessageBusInterface $bus)
    {
    }

    public function __invoke(UserRegisteredEvent $event): void
    {
        $this->bus->dispatch(new SendWelcomeEmailMessage(
            $event->getUser()->getId()
        ));
    }
}

随后为 SendWelcomeEmailMessage 配置 Messenger transport,由 Worker 消费。消息通常只携带用户 ID 等必要数据,后台处理时再从数据库加载最新用户资料。

异步后需要考虑失败重试、幂等和消息最终一致性。事件派发和消息入队之间若必须保证不丢,可采用 Outbox 等事务消息模式。

查看监听器是否注册成功

常用调试命令:

bash 复制代码
# 查看某个事件的监听器及优先级
php bin/console debug:event-dispatcher 'App\Event\UserRegisteredEvent'

# 查看内核事件监听器
php bin/console debug:event-dispatcher kernel.response

# 检查服务容器配置
php bin/console lint:container

如果找不到监听器,先检查:类是否在 src/ 服务资源范围内,autoconfigure 是否开启,属性引用的事件名称是否与派发时一致。

常见误区

  1. 把事件当作异步队列。 dispatch() 默认同步运行所有监听器。
  2. 在控制器里到处直接派发框架事件。 业务事件最好由业务服务在明确的业务节点派发。
  3. 让监听器承担核心业务决策。 事件适合解耦后续动作,关键状态变更应由清晰的应用服务和事务负责。
  4. 事件数据只传一个数据库 ID,却不说明语义。 事件应准确描述发生的事实;若需要历史状态或当时数据,应把必要值作为事件字段保存。
  5. 忽略事件的同步异常。 监听器抛出的异常可能沿调用链返回,影响原请求。
  6. 混淆 Doctrine Listener 和 Symfony Listener。 两者触发时机、注册标签和参数对象完全不同。

小结

Symfony 事件监听器适合把"发生某件事之后要做的工作"从主流程中拆出来。事件对象说明发生了什么,EventDispatcher 负责找到监听器,Listener 或 Subscriber 执行后续逻辑。

一个事件监听器类只关注一项后续动作;一组相关事件可以放进 Subscriber;高优先级先执行;需要后台处理时接入 Messenger;涉及 Doctrine 持久化阶段的逻辑则要和 ORM 生命周期事件区分开。

参考资料

相关推荐
新鲜势力呀2 小时前
PHP 消息队列实战:从同步接口阻塞到 RabbitMQ + Redis队列 + 异步任务架构完整优化方案
redis·rabbitmq·php
新鲜势力呀4 小时前
PHP 日志系统实战:从排查线上故障困难到 ELK日志分析 + 链路追踪 + 实时监控完整架构方案
elk·架构·php
事圆则缓8 小时前
Kotlin Flow、StateFlow、SharedFlow 全解析:冷流、热流与 Android 状态管理
android·kotlin·php
汤米粥8 小时前
后端开发主流技术方案
java·python·golang·php·nodejs·后端开发
在猴站学算法9 小时前
(实战)PHP文件包含漏洞
网络安全·php
谢亮_vipxieliang10 小时前
PHP 8 新特性:构造器属性提升与实战
开发语言·后端·php
Highcharts.js10 小时前
动态数据可视化架构:Highcharts + PHP + MySQL 从数据库到数据渲染
数据库·mysql·php·开发文档·实时数据·highcharts·图表开发
专业程序开发源10 小时前
springboot校园志愿者管理系统28562-计算机课程设计、毕业设计
java·spring boot·后端·python·django·php·课程设计
Days20501 天前
全网去水印1.0.0源码发布
php