crell/tukio

一个健壮的、独立的PSR-14事件分发器实现。

资助包维护!
Crell

2.0.0 2024-04-14 18:52 UTC

README

Latest Version on Packagist Software License Total Downloads

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事件调用。有名字的函数将针对任何StuffHappenedSpecificStuffHappened事件调用。用户实际上并不关心哪个先发生(这是典型情况)。

监听器排序

然而,用户也可以对监听器触发的顺序进行选择。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的首选方式是通过属性。这里有四个相关的属性:ListenerListenerPriorityListenerBeforeListenerAfter。所有这些都可以使用顺序参数或命名参数。在大多数情况下,命名参数将更加易于理解。所有属性仅在函数和方法上有效。

  • Listener声明一个可调用对象为监听器,并可选地设置idtype:`#[Listener(id: 'a_listener', type: 'SomeClass')]。
  • ListenerPriority需要一个priority参数,以及可选的idtype:`#[ListenerPriority(5)]`或`#[ListenerPriority(priority: 3, id: "a_listener")]。
  • ListenerBefore需要一个before参数,以及可选的idtype:`#[ListenerBefore('other_listener')]`或`#[ListenerBefore(before: 'other_listener', id: "a_listener")]。
  • ListenerAfter需要一个after参数,以及可选的idtype:`#[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提供了编译提供者选项。

编译提供者包括三个部分:ProviderBuilderProviderCompiler和生成的提供者类。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

贡献

请参阅 CONTRIBUTINGCODE_OF_CONDUCT 了解详细信息。

安全

如果你发现任何与安全相关的问题,请使用 GitHub 安全报告表 而不是问题队列。

致谢

许可

较弱的 GPL 版本 3 或更高版本。请参阅 许可文件 了解更多信息。