事件系统

创建可维护的应用程序既是一门科学,也是一门艺术。众所周知,编写高质量代码的关键是同时使您的对象松耦合和强内聚。内聚意味着一个类的所有方法和属性都与该类本身密切相关,它不会尝试完成其他对象应该完成的工作,而松耦合则是衡量一个类与外部对象的“连接”程度,以及该类对它们的依赖程度。

在某些情况下,您需要干净地与应用程序的其他部分进行通信,而无需硬编码依赖项,从而失去内聚性并增加类耦合。使用观察者模式,允许对象通知其他对象和匿名监听器关于更改,这是一个实现此目标的有用模式。

观察者模式中的监听器可以订阅事件,并选择在这些事件与它们相关时采取行动。如果您使用过 JavaScript,那么您很有可能已经熟悉事件驱动的编程。

CakePHP 模拟了在 jQuery 等流行 JavaScript 库中触发和管理事件的几个方面。在 CakePHP 实现中,一个事件对象被分发给所有监听器。事件对象包含有关事件的信息,并提供在任何点停止事件传播的能力。监听器可以自己注册,也可以委托给其他对象完成此任务,并有机会为剩余的回调更改状态和事件本身。

事件子系统是模型、行为、控制器、视图和辅助方法回调的核心。如果您使用过它们中的任何一个,那么您已经对 CakePHP 中的事件有所了解。

事件使用示例

假设您正在构建一个购物车插件,并且您希望专注于处理订单逻辑。您并不真正想包含运输逻辑、向用户发送电子邮件或从库存中减少商品数量,但这些对使用您插件的人来说是重要的任务。如果您没有使用事件,您可能会尝试通过将行为附加到模型或将组件添加到控制器来实现这一点。这样做在大多数情况下都具有挑战性,因为您必须想出代码来外部加载这些行为或将钩子附加到您的插件控制器。

相反,您可以使用事件让您干净地分离代码的关注点,并允许其他关注点使用事件挂钩到您的插件。例如,在您的购物车插件中,您有一个处理创建订单的订单模型。您希望通知应用程序的其他部分已创建订单。为了使您的订单模型保持干净,您可以使用事件

// Cart/Model/Table/OrdersTable.php
namespace Cart\Model\Table;

use Cake\Event\Event;
use Cake\ORM\Table;

class OrdersTable extends Table
{
    public function place($order)
    {
        if ($this->save($order)) {
            $this->Cart->remove($order);
            $event = new Event('Order.afterPlace', $this, [
                'order' => $order
            ]);
            $this->getEventManager()->dispatch($event);

            return true;
        }

        return false;
    }
}

上面的代码允许您通知应用程序的其他部分已创建订单。然后,您可以在专注于这些关注点的单独对象中完成诸如发送电子邮件通知、更新库存、记录相关统计信息和其他任务。

访问事件管理器

在 CakePHP 中,事件针对事件管理器触发。事件管理器在每个使用 getEventManager() 的表、视图和控制器中都可用

$events = $this->getEventManager();

每个模型都有一个单独的事件管理器,而视图和控制器共享一个。这允许模型事件保持自包含,并允许组件或控制器在必要时对视图中创建的事件进行操作。

全局事件管理器

除了实例级事件管理器之外,CakePHP 还提供了一个全局事件管理器,它允许您监听应用程序中触发的任何事件。当将监听器附加到特定实例可能很麻烦或困难时,这很有用。全局管理器是 Cake\Event\EventManager 的一个单例实例。附加到全局调度程序的监听器将在相同优先级的实例监听器之前触发。您可以使用静态方法访问全局管理器

// In any configuration file or piece of code that executes before the event
use Cake\Event\EventManager;

EventManager::instance()->on(
    'Order.afterPlace',
    $aCallback
);

您应该考虑的一件重要的事情是,将触发具有相同名称但不同主题的事件,因此在全局附加的任何函数中检查事件对象通常是必需的,以防止出现一些错误。请记住,使用全局管理器的灵活性会带来一些额外的复杂性。

Cake\Event\EventManager::dispatch() 方法接受事件对象作为参数,并通知所有监听器和回调,并将此对象传递给它们。监听器将处理围绕 afterPlace 事件的所有额外逻辑,您可以在单独的对象中记录时间、发送电子邮件、更新用户统计信息,甚至在您需要时将它委托给离线任务。

跟踪事件

要保留在特定 EventManager 上触发的事件列表,您可以启用事件跟踪。为此,只需将一个 Cake\Event\EventList 附加到管理器

EventManager::instance()->setEventList(new EventList());

在管理器上触发事件后,您可以从事件列表中检索它

$eventsFired = EventManager::instance()->getEventList();
$firstEvent = $eventsFired[0];

可以通过删除事件列表或调用 Cake\Event\EventList::trackEvents(false) 来禁用跟踪。

核心事件

框架中有一些核心事件,您的应用程序可以监听这些事件。CakePHP 的每一层都发出您可以用在应用程序中的事件。

Server.terminate

Server.terminate 事件在响应已发送到客户端后触发。此事件对于执行应在响应发送后完成的任务很有用,例如记录或发送电子邮件。

您可以使用事件管理器实例监听此事件

use Cake\Event\EventManager;

EventManager::instance()->on('Server.terminate', function ($event) {
    // Perform tasks that should be done after the response has been
    // sent to the client.
});

或者使用您在 Application/Plugin 类中的 events 钩子

use Cake\Event\EventManagerInterface;

public function events(EventManagerInterface $eventManager): EventManagerInterface
{
    $eventManager->on('Server.terminate', function ($event) {
        // Perform tasks that should be done after the response has been
        // sent to the client.
    });

    return $eventManager;
}

提示

即使在请求期间抛出异常(例如在 404 页面上)也会调用此事件。

注意

Server.terminate 事件仅适用于支持 fastcgi_finish_request 函数的 PHP-FPM 实现。

注册监听器

监听器是注册事件回调的首选方式。这是通过在您希望注册一些回调的任何类中实现 Cake\Event\EventListenerInterface 接口来完成的。实现该接口的类需要提供 implementedEvents() 方法。此方法必须返回一个关联数组,其中包含该类将处理的所有事件名称。

为了继续我们之前的示例,假设我们有一个 UserStatistic 类负责计算用户的购买历史记录,并将其编译成全局网站统计信息。这是一个使用监听器类的绝佳位置。这样做可以让您将统计信息逻辑集中在一个地方,并在必要时对事件做出反应。我们的 UserStatistics 监听器可能像这样开始

namespace App\Event;

use Cake\Event\EventListenerInterface;

class UserStatistic implements EventListenerInterface
{
    public function implementedEvents(): array
    {
        return [
            // Custom event names let you design your application events
            // as required.
            'Order.afterPlace' => 'updateBuyStatistic',
        ];
    }

    public function updateBuyStatistic($event)
    {
        // Code to update statistics
    }
}

// From your controller, attach the UserStatistic object to the Order's event manager
$statistics = new UserStatistic();
$this->Orders->getEventManager()->on($statistics);

如您在上面的代码中看到的,on() 函数将接受 EventListener 接口的实例。在内部,事件管理器将使用 implementedEvents() 附加正确的回调。

Added in version 5.1.0: events 钩子添加到 BaseApplication 以及 BasePlugin 类中

从 CakePHP 5.1 开始,建议通过在您的应用程序或插件类中通过 events 钩子添加它们来注册事件监听器

namespace App;

use App\Event\UserStatistic;
use Cake\Event\EventManagerInterface;
use Cake\Http\BaseApplication;

class Application extends BaseApplication
{
    // The rest of your Application class

    public function events(EventManagerInterface $eventManager): EventManagerInterface
    {
        $statistics = new UserStatistic();
        $eventManager->on($statistics);

        return $eventManager;
    }
}

注册匿名监听器

虽然事件监听器对象通常是实现监听器的更好方法,但您也可以绑定任何 callable 作为事件监听器。例如,如果我们想将任何订单放入日志文件,我们可以使用一个简单的匿名函数来做到这一点

use Cake\Log\Log;

// From within a controller, or during application bootstrap.
$this->Orders->getEventManager()->on('Order.afterPlace', function ($event) {
    Log::write(
        'info',
        'A new order was placed with id: ' . $event->getSubject()->id
    );
});

除了匿名函数之外,您还可以使用 PHP 支持的任何其他可调用类型

$events = [
    'email-sending' => 'EmailSender::sendBuyEmail',
    'inventory' => [$this->InventoryManager, 'decrement'],
];
foreach ($events as $callable) {
    $eventManager->on('Order.afterPlace', $callable);
}

在处理不触发特定事件的插件时,您可以利用默认事件上的事件监听器。让我们举一个“UserFeedback”插件的例子,它处理来自用户的反馈表单。从您的应用程序中,您希望知道何时保存反馈记录并最终对其进行操作。您可以监听全局 Model.afterSave 事件。但是,您可以采取更直接的方法,只监听您真正需要的事件

// You can create the following before the
// save operation, ie. config/bootstrap.php
use Cake\Datasource\FactoryLocator;
// If sending emails
use Cake\Mailer\Email;

FactoryLocator::get('Table')->get('ThirdPartyPlugin.Feedbacks')
    ->getEventManager()
    ->on('Model.afterSave', function($event, $entity)
    {
        // For example we can send an email to the admin
        $email = new Email('default');
        $email->setFrom(['[email protected]' => 'Your Site'])
            ->setTo('[email protected]')
            ->setSubject('New Feedback - Your Site')
            ->send('Body of message');
    });

您可以使用相同的方法绑定监听器对象。

与现有监听器交互

假设已经注册了多个事件监听器,则特定事件模式的存在或不存在可以作为某些操作的基础。

// Attach listeners to EventManager.
$this->getEventManager()->on('User.Registration', [$this, 'userRegistration']);
$this->getEventManager()->on('User.Verification', [$this, 'userVerification']);
$this->getEventManager()->on('User.Authorization', [$this, 'userAuthorization']);

// Somewhere else in your application.
$events = $this->getEventManager()->matchingListeners('Verification');
if (!empty($events)) {
    // Perform logic related to presence of 'Verification' event listener.
    // For example removing the listener if present.
    $this->getEventManager()->off('User.Verification');
} else {
    // Perform logic related to absence of 'Verification' event listener
}

注意

传递给 matchingListeners 方法的模式区分大小写。

建立优先级

在某些情况下,您可能希望控制监听器调用的顺序。例如,如果我们回到用户统计示例。如果此监听器在堆栈的末尾被调用,那将是理想的。通过在监听器堆栈的末尾调用它,我们可以确保事件没有被取消,并且没有其他监听器引发异常。我们还可以获取在其他监听器修改了主题或事件对象的情况下对象的最终状态。

优先级在添加监听器时被定义为整数。数字越大,方法执行的越晚。所有监听器的默认优先级为 10。如果您需要您的方法更早执行,使用低于此默认值的任何值都可以。另一方面,如果您希望在其他方法之后运行回调,使用高于 10 的数字就可以了。

如果两个回调恰好具有相同的优先级值,它们将按照它们被附加的顺序执行。您可以使用 on() 方法设置回调的优先级,并在 implementedEvents() 函数中为事件监听器声明优先级。

// Setting priority for a callback
$callback = [$this, 'doSomething'];
$this->getEventManager()->on(
    'Order.afterPlace',
    ['priority' => 2],
    $callback
);

// Setting priority for a listener
class UserStatistic implements EventListenerInterface
{
    public function implementedEvents()
    {
        return [
            'Order.afterPlace' => [
                'callable' => 'updateBuyStatistic',
                'priority' => 100
            ],
        ];
    }
}

如您所见,EventListener 对象的主要区别在于您需要使用数组来指定可调用方法和优先级偏好。 callable 键是一个特殊的数组条目,管理器将读取该条目以了解应该调用类中的哪个函数。

获取事件数据作为函数参数

当事件在其构造函数中提供数据时,提供的数据将转换为监听器的参数。来自视图层的示例是 afterRender 回调。

$this->getEventManager()
    ->dispatch(new Event('View.afterRender', $this, ['view' => $viewFileName]));

View.afterRender 回调的监听器应该具有以下签名。

function (EventInterface $event, $viewFileName)

提供给事件构造函数的每个值都将转换为函数参数,它们在数据数组中出现的顺序决定了函数参数顺序。如果您使用的是关联数组,则 array_values 的结果将决定函数参数顺序。

注意

与 2.x 不同,将事件数据转换为监听器参数是默认行为,不能被禁用。

分派事件

获得事件管理器实例后,您可以使用 dispatch() 分派事件。此方法接受 Cake\Event\Event 类的实例。让我们看看如何分派事件。

// An event listener has to be instantiated before dispatching an event.
// Create a new event and dispatch it.
$event = new Event('Order.afterPlace', $this, [
    'order' => $order
]);
$this->getEventManager()->dispatch($event);

Cake\Event\Event 在其构造函数中接受 3 个参数。第一个是事件名称,您应该尽量使此名称尽可能唯一,同时保持可读性。我们建议使用以下约定: Layer.eventName 用于在层级发生的通用事件(例如, Controller.startupView.beforeRender)和 Layer.Class.eventName 用于在特定类上发生的层级事件,例如 Model.User.afterRegisterController.Courses.invalidAccess

第二个参数是 subject,表示与事件关联的对象,通常当它与触发关于自身事件的相同类相同时,使用 $this 将是最常见的情况。尽管一个组件也可以触发控制器事件。主题类很重要,因为监听器将立即访问该对象属性,并有机会立即检查或更改它们。

最后,第三个参数是任何额外的事件数据。这可以是您认为有用以传递给监听器以对其进行操作的任何数据。虽然这可以是任何类型的参数,但我们建议传递关联数组。

dispatch() 方法接受事件对象作为参数,并通知所有已订阅的监听器。

停止事件

与 DOM 事件类似,您可能希望停止事件以阻止通知其他监听器。您可以在模型回调(例如,beforeSave)中看到它的实际作用,在这种情况下,如果代码检测到它无法继续执行,则可以停止保存操作。

为了停止事件,您可以在回调中返回 false,或者在事件对象上调用 stopPropagation() 方法。

public function doSomething($event)
{
    // ...
    return false; // Stops the event
}

public function updateBuyStatistic($event)
{
    // ...
    $event->stopPropagation();
}

停止事件将阻止任何其他回调被调用。此外,触发事件的代码可能会根据事件是否停止而表现不同。通常,停止“after”事件没有意义,但停止“before”事件通常用于阻止整个操作发生。

要检查事件是否停止,您可以在事件对象中调用 isStopped() 方法。

public function place($order)
{
    $event = new Event('Order.beforePlace', $this, ['order' => $order]);
    $this->getEventManager()->dispatch($event);
    if ($event->isStopped()) {
        return false;
    }
    if ($this->Orders->save($order)) {
        // ...
    }
    // ...
}

在前面的示例中,如果事件在 beforePlace 过程中停止,则订单将不会被保存。

获取事件结果

每次回调返回一个非空非假的返回值时,它都会被存储在事件对象的 $result 属性中。当您希望允许回调修改事件执行时,这将非常有用。让我们再次使用我们的 beforePlace 示例,让回调修改 $order 数据。

事件结果可以通过直接使用事件对象结果属性,或者在回调本身中返回值来更改。

// A listener callback
public function doSomething($event)
{
    // ...
    $alteredData = $event->getData('order') + $moreData;

    return $alteredData;
}

// Another listener callback
public function doSomethingElse($event)
{
    // ...
    $event->setResult(['order' => $alteredData] + $this->result());
}

// Using the event result
public function place($order)
{
    $event = new Event('Order.beforePlace', $this, ['order' => $order]);
    $this->getEventManager()->dispatch($event);
    if (!empty($event->getResult()['order'])) {
        $order = $event->getResult()['order'];
    }
    if ($this->Orders->save($order)) {
        // ...
    }
    // ...
}

可以更改任何事件对象属性,并让新数据传递给下一个回调。在大多数情况下,提供对象作为事件数据或结果,并直接更改对象是最佳解决方案,因为引用保持不变,并且修改在所有回调调用之间共享。

删除回调和监听器

如果出于任何原因,您想从事件管理器中删除任何回调,只需使用与附加它时使用的前两个参数调用 Cake\Event\EventManager::off() 方法。

// Attaching a function
$this->getEventManager()->on('My.event', [$this, 'doSomething']);

// Detaching the function
$this->getEventManager()->off('My.event', [$this, 'doSomething']);

// Attaching an anonymous function.
$myFunction = function ($event) { ... };
$this->getEventManager()->on('My.event', $myFunction);

// Detaching the anonymous function
$this->getEventManager()->off('My.event', $myFunction);

// Adding a EventListener
$listener = new MyEventLister();
$this->getEventManager()->on($listener);

// Detaching a single event key from a listener
$this->getEventManager()->off('My.event', $listener);

// Detaching all callbacks implemented by a listener
$this->getEventManager()->off($listener);

事件是在您的应用程序中分离关注点并使类既具有内聚性又彼此解耦的好方法。事件可以用来解耦应用程序代码并创建可扩展的插件。

请记住,能力越大,责任越大。使用过多的事件可能会使调试更加困难,并需要额外的集成测试。

其他阅读