crell / tukio
一个健壮的、独立的PSR-14事件分发器实现。
Requires
- php: ~8.1
- crell/attributeutils: ^1.1
- crell/ordered-collection: ~2.0
- fig/event-dispatcher-util: ^1.3
- psr/container: ^1.0 || ^2.0
- psr/event-dispatcher: ^1.0
- psr/log: ^1.0 || ^2.0 || ^3.0
Requires (Dev)
- phpbench/phpbench: ^1.2
- phpstan/phpstan: ^1.10
- phpunit/phpunit: ^10.3
Provides
README
Tukio是完全且健壮的PSR-14事件分发器规范实现。它支持常规和调试事件分发器,运行时和编译时提供者,复杂的监听器排序,以及PHP 8的基于属性的注册。
"Tukio"是斯瓦希里语中的"事件"。
安装
通过Composer
$ composer require crell/tukio
PSR-14用法
PSR-14由两个关键组件组成:分发器和提供者。分发器是客户端库将组成并传递事件对象的组件。提供者是框架将提供给分发器以匹配事件到监听器的组件。Tukio包含这两个组件的多个实现。
使用任何PSR-14兼容实现的通用结构是
$provider = new SomeProvider(); // Do some sort of setup on $provider so that it knows what Listeners it should match to what Events. // This could be pretty much anything depending on the implementation. $dispatcher = new SomeDispatcher($provider); // Pass $dispatcher to some client code somewhere, and then: $thingHappened = new ThingHappened($thing); $dispatcher->dispatch($thingHappened);
注意,dispatch()
还会返回$thingHappened
,因此如果监听器预期要向其添加数据,则可以在需要的情况下在其上链式调用方法。
$reactions = $dispatcher->dispatch(new ThingHappened($thing))->getReactions();
在实际应用中,这些大部分将通过依赖注入容器来处理,但没有任何要求必须这样做。
分发器
Tukio包含两个分发器:Dispatcher
是标准实现,应该95%的时间使用。它可以可选地接受一个PSR-3 Logger,在这种情况下,它将在监听器抛出任何异常时记录一条警告。
第二个是DebugEventDispatcher
,正如其名所示,它对调试非常有用。它记录传递的每个事件,然后委托给一个组合的分发器(如Dispatcher
)来实际分发事件。
例如
use Crell\Tukio\Dispatcher; use Crell\Tukio\DebugEventDispatcher; $provider = new SomeProvider(); $logger = new SomeLogger(); $dispatcher = new Dispatcher($provider, $logger); $debugDispatcher = new DebugEventDispatcher($dispatcher, $logger); // Now pass $debugDispatcher around and use it like any other dispatcher.
提供者
Tukio包括构建提供者的多个选项,并鼓励您将其与FIG提供的通用库fig/event-dispatcher-util
结合使用。
您使用哪个或哪些提供者取决于您的用例。所有这些都适用于某些用例。
有序监听器提供者
正如其名所示,OrderedListenerProvider
完全关乎排序。用户可以明确注册监听器,这些监听器将根据其类型与事件匹配。
如果没有指定顺序,那么返回监听器的顺序是未定义的,在实际应用中,用户应该期望顺序是稳定的,但不可预测的。这使得退化情况变得超级简单
use Crell\Tukio\OrderedListenerProvider; class StuffHappened {} class SpecificStuffHappened extends StuffHappened {} function handleStuff(StuffHappened $stuff) { ... } $provider = new OrderedListenerProvider(); $provider->listener(function(SpecificStuffHappened) { // ... }); $provider->listener('handleStuff');
这向提供者添加了两个监听器;一个匿名函数和一个命名函数。匿名函数将针对任何SpecificStuffHappened
事件被调用。命名函数将针对任何StuffHappened
或 SpecificStuffHappened
事件被调用。而用户并不真的关心哪个先发生(这是典型情况)。
监听器排序
然而,用户也可以对监听器将按什么顺序触发进行挑剔。Tukio支持两种排序机制:优先级排序和拓扑排序(之前/之后标记)。内部,Tukio将优先级排序转换为拓扑排序。
use Crell\Tukio\OrderedListenerProvider; $provider = new OrderedListenerProvider(); $provider->listener(function(SpecificStuffHappened) { // ... }, priority: 10); $provider->listener('handleStuff', priority: 20);
现在,命名函数Listener将在匿名函数之前被调用。(优先级数值越高越先调用,负数也是完全合法的。)如果两个Listener具有相同的优先级,它们之间的顺序是未定义的。
然而,有时你可能不知道另一个Listener的优先级,但重要的是你的Listener要在其之前或之后执行。为此,我们需要引入一个新概念:ID。每个Listener都有一个ID,可以在添加Listener时提供,如果没有提供,则会自动生成。自动生成的值是可预测的(函数名、对象方法的类-方法等),所以在大多数情况下,没有必要读取listener()
的返回值,尽管这样做稍微更健壮一些。
use Crell\Tukio\OrderedListenerProvider; $provider = new OrderedListenerProvider(); // The ID will be "handleStuff", unless there is such an ID already, //in which case it would be "handleStuff-1" or similar. $id = $provider->listener('handleStuff'); // This Listener will get called before handleStuff does. If you want to specify an ID // you can, since anonymous functions just get a random string as their generated ID. $provider->listener($id, function(SpecificStuffHappened) { // ... }, before: ['my_specifics']);
在这里,handleStuff
的优先级是未定义的;用户不在乎它何时被调用。然而,匿名函数,如果需要调用,总是会调用在handleStuff
之后。可能还有其他Listener在两者之间被调用,但一个总是会在另一个之后发生。
listener()
方法用于所有注册,可以接受一个优先级、一个新Listener必须在它之前调用的Listener ID列表,以及一个新Listener必须在它之后调用的Listener ID列表。它还支持指定自定义ID和自定义的$type
。
因为选项很多,所以强烈建议您除了Listener可调用自身之外的所有参数都使用命名参数。
public function listener( callable $listener, ?int $priority = null, array $before = [], array $after = [], ?string $id = null, ?string $type = null ): string;
listener()
方法将始终返回用于该Listener的ID。如果需要,可以通过$type
参数允许用户指定Listener针对的事件类型,如果与函数中的类型声明不同。例如,如果Listener没有类型声明或应该仅适用于其类型声明的一些父类。(这是一个罕见的情况,这就是为什么它是最后一个参数。)
服务Listener
通常,Listener本身是对象的方法,这些对象在需要时才实例化。这正是依赖注入容器允许的,而OrderedListenerProvider
完全支持这些,称为“服务Listener”。它们几乎以完全相同的方式工作,除了您需要指定服务和方法名。
public function listenerService( string $service, ?string $method = null, ?string $type = null, ?int $priority = null, array $before = [], array $after = [], ?string $id = null ): string;
$type
、$priority
、$before
、$after
和$id
参数与listener()
相同。$service
是任何将在按需从容器中检索的服务名称,而$method
是对象上的方法。
如果服务名称与找到的类相同(这在大多数现代约定中很典型),Tukio可以尝试从类中推导方法和方法类型。如果服务名称与定义的类不同,它不能这样做,并且需要$method
和$type
,如果缺失,将抛出异常。
如果没有指定$method
并且服务名称与类匹配,Tukio将尝试为您推导方法。如果类只有一个方法,该方法将自动选中。否则,如果有一个__invoke()
方法,该方法将自动选中。否则,自动检测失败,将抛出异常。
服务本身可以来自任何PSR-11兼容的容器。
use Crell\Tukio\OrderedListenerProvider; class SomeService { public function methodA(ThingHappened $event): void { ... } public function methodB(SpecificThingHappened $event): void { ... } } class MyListeners { public function methodC(WhatHappened $event): void { ... } public function somethingElse(string $beep): string { ... } } class EasyListening { public function __invoke(SpecificThingHappened $event): void { ... } } $container = new SomePsr11Container(); // Configure the container somehow. $container->register('some_service', SomeService::class); $container->register(MyListeners::class, MyListeners::class); $container->register(EasyListening::class, EasyListening::class); $provider = new OrderedListenerProvider($container); // Manually register two methods on the same service. $idA = $provider->listenerService('some_service', 'methodA', ThingHappened::class); $idB = $provider->listenerService('some_service', 'methodB', SpecificThingHappened::class); // Register a specific method on a derivable service class. // The type (WhatHappened) will be derived automatically. $idC = $provider->listenerService(MyListeners::class, 'methodC', after: 'some_service-methodB'); // Auto-everything! This is the easiest option. $provider->listenerService(EasyListening::class, before: $idC);
在本示例中,我们在三个不同的类中定义了监听器方法,这些方法都注册到了PSR-11容器中。在第一个代码块中,我们从一个服务名称与类名称不匹配的类中注册了两个监听器。在第二个代码块中,我们注册了一个类的方法,其服务名称与类名称匹配,因此可以通过反射推导出事件类型。在第三个代码块中,我们使用了单方法监听器类,这使得所有内容都可以推导出来!
值得注意的是,methodB
监听器通过一个明确的ID引用了 methodA
监听器。生成的ID是可预测的,因此在大多数情况下,你不需要使用返回值。尽管如此,返回值是一个更稳健、更可靠的选项,因为如果请求的ID已被使用,则会生成一个新的ID。
基于属性的注册
然而,配置Tukio的首选方式是通过属性。这里有四个相关的属性:Listener
、ListenerPriority
、ListenerBefore
和 ListenerAfter
。所有属性都可以与顺序参数或命名参数一起使用。在大多数情况下,命名参数将更有助于自我文档化。所有属性都仅适用于函数和方法。
Listener
声明了一个可调用的监听器,并可选地设置了id
和type
:`#[Listener(id: 'a_listener', type: 'SomeClass')]`。ListenerPriority
有一个必需的priority
参数,以及可选的id
和type
:`#[ListenerPriority(5)]` 或 `#[ListenerPriority(priority: 3, id: "a_listener")]`。ListenerBefore
有一个必需的before
参数,以及可选的id
和type
:`#[ListenerBefore('other_listener')]` 或 `#[ListenerBefore(before: 'other_listener', id: "a_listener")]`。ListenerAfter
有一个必需的after
参数,以及可选的id
和type
:`#[ListenerAfter('other_listener')]` 或 `#[ListenerAfter(after: ['other_listener'], id: "a_listener")]`。
$before
和 $after
参数可以接受单个字符串或字符串数组。
由于一个块中可能包含多个属性,这允许使用紧凑的语法,例如
#[Listener(id: 'a_listener'), ListenerBefore('other'), ListenerAfter('something', 'else')] function my_listener(SomeEvent $event): void { ... } // Or just use the one before/after you care about: #[ListenerAfter('something_early')] function other(SomeEvent $event): void { ... }
如果你将带有Listener属性的监听器传递给 listener()
或 listenerService()
,则将使用定义的属性配置。然而,如果你在方法签名中传递配置,则将覆盖从属性中获取的任何值。
订阅者
“订阅者”(一个名称公开且毫不犹豫地从Symfony借用的名称)是一个在其上具有多个监听器方法的类。Tukio允许你通过注册类来批量注册任何类似监听器的方法。
$provider->addSubscriber(SomeCollectionOfListeners::class, 'service_name');
与之前一样,如果服务名称与类的名称相同,则可以省略。如果方法上有任何 Listener*
属性,或者方法名称以 on
开头,则会注册该方法。
- 方法上。
- 方法名称以
on
开头。
例如
class SomeCollectionOfListeners { // Registers, with a custom ID. #[Listener(id: 'a')] public function onA(CollectingEvent $event) : void { $event->add('A'); } // Registers, with a custom priority. #[ListenerPriority(priority: 5)] public function onB(CollectingEvent $event) : void { $event->add('B'); } // Registers, before listener "a" above. #[ListenerBefore(before: 'a')] public function onC(CollectingEvent $event) : void { $event->add('C'); } // Registers, after listener "a" above. #[ListenerAfter(after: 'a')] public function onD(CollectingEvent $event) : void { $event->add('D'); } // This still self-registers because of the name. public function onE(CollectingEvent $event) : void { $event->add('E'); } // Registers, with a given priority despite its non-standard name. #[ListenerPriority(priority: -5)] public function notNormalName(CollectingEvent $event) : void { $event->add('F'); } // No attribute, non-standard name, this method is not registered. public function ignoredMethodThatDoesNothing() : void { throw new \Exception('What are you doing here?'); } }
监听器类
如上所述,结构化监听器最简单的方法是使其成为类上的唯一方法,尤其是如果它命名为 __invoke()
,并给服务赋予与类相同的名称。这样,它可以轻松注册,并通过属性推导出所有配置。由于监听器被注册两次的情况极为罕见(存在用例,但我们尚未了解),这不会引起名称冲突问题。
Tukio有两个额外的功能使其更加容易。一是,如果监听器方法是 __invoke()
,则监听器的ID将默认为类名称。二是,Listener属性也可以放在类上,而不是方法上,在这种情况下,类级别的设置将继承到每个方法。
结果是,定义监听器的最简单方法是将其作为单方法类,如下所示
class ListenerOne { public function __construct( private readonly DependencyA $depA, private readonly DependencyB $depB, ) {} public function __invoke(MyEvent $event): void { ... } } #[ListenerBefore(ListenerOne::class)] class ListenerTwo { public function __invoke(MyEvent $event): void { ... } } $provider->listenerService(ListenerOne::class); $provider->listenerService(ListenerTwo::class);
现在,API 调用本身非常简单。只需指定类名。在注册顺序无关的情况下,将先调用 ListenerTwo::__invoke()
,然后是 ListnerOne::__invoke()
。当从您的依赖注入容器中请求 ListenerOne
时,容器将自动填充其依赖项。
这是与 Tukio 一起使用监听器的推荐方式。
已弃用的功能
一些来自 Tukio 版本 1 的注册机制仍然存在,但已被明确弃用。它们将在未来的版本中被删除。请尽快迁移。
专用注册方法
以下方法仍然有效,但只是 listener()
或 listenerService()
调用的别名。与直接使用 listener()
相比,它们的能力较弱,因为 listener()
允许一次性指定优先级、before 和 after,包括多个 before/after 目标。以下方法都不支持这些。请迁移到 listener()
和 listenerService()
。
public function addListener(callable $listener, ?int $priority = null, ?string $id = null, ?string $type = null): string; public function addListenerBefore(string $before, callable $listener, ?string $id = null, ?string $type = null): string; public function addListenerAfter(string $after, callable $listener, ?string $id = null, ?string $type = null): string; public function addListenerService(string $service, string $method, string $type, ?int $priority = null, ?string $id = null): string; public function addListenerServiceBefore(string $before, string $service, string $method, string $type, ?string $id = null): string; public function addListenerServiceAfter(string $after, string $service, string $method, string $type, ?string $id = null): string;
订阅者接口
在 Tukio v1 中,有一个可选的 SubscriberInterface
,允许通过一个将各种 addListener*()
调用捆绑在一起的静态方法来自定义方法作为监听器的注册。然而,由于 PHP 8 中引入了属性,该功能已不再必要,因为属性可以完成与订阅者接口相同的工作,而且工作量更少。
SubscriberInterface
仍然受支持,但已弃用。它将在未来的版本中被删除。请迁移到属性。
基本案例是这样的
use Crell\Tukio\OrderedListenerProvider; use Crell\Tukio\SubscriberInterface; class Subscriber implements SubscriberInterface { public function onThingsHappening(ThingHappened $event) : void { ... } public function onSpecialEvent(SpecificThingHappened $event) : void { ... } public function somethingElse(ThingHappened $event) : void { ... } public static function registerListeners(ListenerProxy $proxy) : void { $id = $proxy->addListener('somethingElse', 10); $proxy->addListenerAfter($id, 'onSpecialEvent'); } } $container = new SomePsr11Container(); // Configure the container so that the service 'listeners' is an instance of Subscriber above. $provider = new OrderedListenerProvider($container); $provider->addSubscriber(Subscriber::class, 'listeners');
与之前一样,onThingsHappen()
将自动注册。但是,somethingElse()
也将作为一个优先级为 10 的监听器注册,而 onSpecialEvent()
将在它之后注册。
编译提供者
所有这些注册和排序逻辑都非常强大,并且在实践中非常快。然而,更快的是不必在每次请求时重新注册。为此,Tukio 提供了编译提供者选项。
编译提供者包括三个部分:ProviderBuilder
、ProviderCompiler
和生成的提供者类。如名称所示,ProviderBuilder
是一个对象,允许您构建一组将构成提供者的监听器。它们与 OrderedListenerProvider
上的一样工作,实际上它公开了相同的 OrderedProviderInterface
。
然后 ProviderCompiler
将一个构建器对象写入到一个提供的流(可能是磁盘上的文件)中,该流匹配构建器中的定义。构建的提供者是固定的;它不能被修改,也无法向其中添加新的监听器,但是所有排序和排序工作已经完成,这使得它明显更快(更不用说跳过注册过程本身)。
让我们看看它是如何工作的
use Crell\Tukio\ProviderBuilder; use Crell\Tukio\ProviderCompiler; $builder = new ProviderBuilder(); $builder->listener('listenerA', priority: 100); $builder->listener('listenerB', after: 'listenerA'); $builder->listener([Listen::class, 'listen']); $builder->listenerService(MyListener::class); $builder->addSubscriber('subscriberId', Subscriber::class); $compiler = new ProviderCompiler(); // Write the generated compiler out to a file. $filename = 'MyCompiledProvider.php'; $out = fopen($filename, 'w'); // Here's the magic: $compiler->compile($builder, $out, 'MyCompiledProvider', '\\Name\\Space\\Of\\My\\App'); fclose($out);
$builder
可以执行 OrderedListenerProvider
可以执行的所有操作,但只支持静态定义的监听器。这意味着它不支持匿名函数或对象的成员方法,但它仍然可以很好地处理函数、静态方法、服务和订阅者。在实践中,当使用编译容器时,您可能几乎完全想要使用服务监听器和订阅者,因为您很可能会与容器一起使用它。
这将在磁盘上生成一个名为 MyCompiledProvider.php
的文件,其中包含 Name\Space\Of\My\App\MyCompiledProvider
。 (根据您的逻辑命名。)在运行时,执行以下操作
// Configure the container such that it has a service "listeners" // and another named "subscriber". $container = new Psr11Container(); $container->addService('D', new ListenService()); include('MyCompiledProvider.php'); $provider = new Name\Space\Of\My\App\MyCompiledProvider($container);
然后!$provider
现在是一个完全功能的提供者,您可以将其传递给分发器。它将像任何其他提供者一样工作,但更快。
或者,编译器可以输出一个匿名类的文件。在这种情况下,类名或命名空间无关紧要。
// Write the generated compiler out to a file. $filename = 'MyCompiledProvider.php'; $out = fopen($filename, 'w'); $compiler->compileAnonymous($builder, $out); fclose($out);
由于编译后的容器将通过包含文件进行实例化,但需要容器实例才能运行,因此不能简单地使用 require()
。相反,请使用 ProviderCompiler
实例上的 loadAnonymous()
方法来加载它。(它不需要是创建它的同一个实例。)
$compiler = new ProviderCompiler(); $provider = $compiler->loadAnonymous($filename, $containerInstance);
但如果你想要大多数监听器预先注册,但在运行时条件性地添加一些监听器呢?请查看 FIG 的 AggregateProvider
,并将你的编译后 Provider 与 OrderedListenerProvider
实例结合使用。
编译器优化
ProviderBuilder
还有一个小技巧。如果你通过 optimizeEvent($class)
方法指定了一个或多个事件,编译器将基于其类型预计算应用到的监听器,包括其父类和接口。结果是这些事件具有常量时间的简单数组查找,也称为“几乎是瞬时的”。
use Crell\Tukio\ProviderBuilder; use Crell\Tukio\ProviderCompiler; $builder = new ProviderBuilder(); $builder->listener('listenerA', priority: 100); $builder->listener('listenerB', after: 'listenerA'); $builder->listener([Listen::class, 'listen']); $builder->listenerService(MyListener::class); $builder->addSubscriber('subscriberId', Subscriber::class); // Here's where you specify what events you know you will have. // Returning the listeners for these events will be near instant. $builder->optimizeEvent(EvenOne::class); $builder->optimizeEvent(EvenTwo::class); $compiler = new ProviderCompiler(); // Write the generated compiler out to a file. $filename = 'MyCompiledProvider.php'; $out = fopen($filename, 'w'); $compiler->compileAnonymous($builder, $out); fclose($out);
回调提供者
Tukio 提供的第三种选择是 CallbackProvider
,它采用了完全不同的方法。在这种情况下,Provider 仅在具有 CallbackEventInterface
的事件上工作。用例是为携带其他对象的 Event,该对象本身在特定时间应该调用其方法。例如,可以考虑领域对象的生存周期回调。
为了展示其功能,我们将使用直接来自 Tukio 测试套件的示例。
use Crell\Tukio\CallbackEventInterface; use Crell\Tukio\CallbackProvider; class LifecycleEvent implements CallbackEventInterface { protected $entity; public function __construct(FakeEntity $entity) { $this->entity = $entity; } public function getSubject() : object { return $this->entity; } } class LoadEvent extends LifecycleEvent {} class SaveEvent extends LifecycleEvent {} class FakeEntity { public function load(LoadEvent $event) : void { ... } public function save(SaveEvent $event) : void { ... } public function stuff(StuffEvent $event) : void { ... } public function all(LifecycleEvent $event) : void { ... } } $provider = new CallbackProvider(); $entity = new FakeEntity(); $provider->addCallbackMethod(LoadEvent::class, 'load'); $provider->addCallbackMethod(SaveEvent::class, 'save'); $provider->addCallbackMethod(LifecycleEvent::class, 'all'); $event = new LoadEvent($entity); $provider->getListenersForEvent($event);
在这个例子中,Provider 不是配置监听器,而是配置与事件对应的方法名称。这些方法是“主题”对象上的方法。当使用 LoadEvent
调用时,Provider 将现在返回对 [$entity, 'load']
和 [$entity, 'all']
的调用。这允许领域对象本身在其上具有在适当时间调用的监听器。
变更日志
请参阅 CHANGELOG 了解最近发生了什么。
测试
$ composer test
贡献
请参阅 CONTRIBUTING 和 CODE_OF_CONDUCT 了解详细信息。
安全性
如果你发现任何安全问题,请使用 GitHub 安全报告表单 而不是问题队列。
鸣谢
许可协议
小于 GPL 版本 3 或更高版本。请参阅 许可文件 了解更多信息。