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提供了多种构建提供者的选项,并且建议您将它们与PHP-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
开头,则会注册该方法。
- 方法上有任何
Listener*
属性。 - 方法名以
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()
被调用。当从您的DI容器请求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中属性的添加,该功能不再必要,因为属性可以完成Subscriber接口能做的所有事情,而且工作量更小。
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);
CallbackProvider
Tukio 提供的第三种选择是 CallbackProvider
,它采用了完全不同的方法。在这种情况下,Provider 仅在具有 CallbackEventInterface
的事件上工作。用例是为携带其他对象的事件,该对象自身在特定时间应该调用其方法。例如,考虑域对象的生存周期回调。
为了演示其工作原理,我们将使用来自 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 或更高版本。请参阅 许可文件 了解更多信息。