PSR-14: Being a good Provider

in #php6 years ago (edited)

As mentioned back in part 1, PSR-14 splits the core mediator into two objects: The Dispatcher and the Provider. The Dispatcher is fairly straightforward and most implementations will be fairly simple and fairly similar.

Providers are the exact opposite; A Listener Provider has one requirement: It maps the Event object into an iterable of callables, in the order it chooses. How it does that is left up to the Provider to define, and there are dozens of possible ways.

Keeping order

The most common type of Provider is likely ordered explicit registration. In that model, a Provider object has methods that let users register callables with it. It can either explicitly specify the Event type it cares about or the provider can derive it from reflection as discussed in part 2.

During development of PSR-14, I built Tukio as an experimental platform, but it grew into a fully-functional PSR-14 implementation. ("Tukio" is the Swahili word for "event".) It offers multiple Providers, including OrderedListenerProvider, which, as the name implies, is all about order-control: It has methods to attach listeners by priority number, or relative to other listeners (before/after), via:

public function addListener(callable $listener, $priority = 0, string $id = null, string $type = null): string;

public function addListenerBefore(string $pivotId, callable $listener, string $id = null, string $type = null): string;

public function addListenerAfter(string $pivotId, callable $listener, string $id = null, string $type = null): string;

In the simple case, users can simply call addListener() with any callable and be done with it. Or they can optionally specify a priority (higher number comes first). All three methods also return an opaque ID for the listener, which can then be referenced as a "pivot point" (the $pivotId) by addListenerBefore() or addListenerAfter() to register a new listener to fire before/after a previous one. That's a level of control and flexibility that in Drupal we wanted for years, but never could figure out how to implement. (It's also possible to register for a specific type of Event that is more specific than the listener's type hint, but in practice I expect it to be rarely used as Tukio will auto-detect the Event type via reflection.)

Tukio's Provider also supports service-based listeners from any PSR-11-compatible Dependency Injection Container. They work almost exactly the same way, just with a service ID rather than a callable, and the type is required because there's no callable yet to reflect on. The service gets wrapped in an anonymous function so it won't be instantiated until it's about to be called:

public function addListenerService(string $serviceName, string $methodName, string $type, $priority = 0, string $id = null): string;

public function addListenerServiceBefore(string $pivotId, string $serviceName, string $methodName, string $type, string $id = null): string;

public function addListenerServiceAfter(string $pivotId, string $serviceName, string $methodName, string $type, string $id = null) : string;

Finally, it also supports subscriber classes, which are services that exist solely to hold Listeners. That's very similar to the Symfony EventDispatcher concept of the same name with (I think) a more self-documenting API that makes them somewhat closer to Zend Framework's "Listener Aggregates". In particular, any public method name that begins with "on" automatically becomes a Listener for... the Event it type hints. No extra logic needed, no extra naming requirements. If there's a need to make it more order-specific, or use a different event, etc. there's also an optional SubscriberInterface that allows more fine-grained control. It works like so:

class MySubscriber implements SubscriberInterface
{
    // This method will become a Listener for DocumentLoaded events with no further work.
    public function onDocumentLoad(DocumentLoaded $event) : void { /* ... */}


    // This method is registered explicitly below.
    public function afterDeletingADocument(DocumentDeleted $event) : void { /* ... */}


    public static function registerListeners(ListenerProxy $proxy): void
    {
        // Give this Listener a higher-than-default priority.
        $a = $proxy->addListener('afterDeletingADocument', 10);
    }
}

The ListenerProxy has all the same methods as the Provider itself, allowing a very robust but performant way to order Listeners any which way.

In all, the ordered Provider offers a very precise way to manually control what listeners are used and what their order is... if you care about that.

Compiling for fun and profit

If you care about that and want even better performance, Tukio also offers a CompiledProvider. Just like a compiled Dependency Injection Container, a compiled Provider takes all the same ordering information but then writes out a class to disk that has all of the listeners hard-coded into an array in the class, in order, for extra performance. There's no runtime overhead for sorting or reflection, just a single linear loop. (If I can figure out a way to make it even more performant, I will.) Naturally, of course, it cannot work with anonymous functions or object methods, but it works really well with services and subscribers (from, say, a compiled container).

At compile time, you would do something like this:

$builder = new ProviderBuilder();
$builder->addListenerService('my_service_id', 'listenerMethod', DocumentPublished::class);
// ...

$compiler = new ProviderCompiler();

// Write the generated compiler out to a file.
$filename = '/path/to/compiled/code/provider.php';
$out = fopen($filename, 'w');
$compiler->compile($builder, $out, 'CompiledProvider', 'My\\Application\\Name\\Space');
fclose($out);

Then at runtime you just load that class and use it:

// Or use an autoloader that is aligned with your class naming.
include($filename);

$provider = new My\Application\Name\Space\CompiledProvider($container);

$dispatcher = new WhateverDispatcher($provider);

Auto-registration

But wait, there's more! Benni Mack, another member of the PSR-14 Working Group, built another Provider implementation called Kart that uses automatic registration of listeners based on their Composer packages. Instead of an explicit step it looks for any functions or object methods in a specific directory and maps them to Listeners.

First, install Kart as a Composer package as normal. Then in any other package add this to composer.json:

"extra": {
    "psr-14": {
        "default": "src/Listeners"
    }
}

(Or some other path if you prefer.)

Now when Composer builds the autoloader Kart will scan the src/Listeners directory for functions or public class methods and register them for Events based on reflection. It will then build a generated class that will be hooked into the autoloader to make it available as needed.

At runtime, the developer need only instantiate Bmack\KartComposerPlugin\ComposerReflectionListenerProvider and pass that to a Dispatcher. All of those Listeners will now Just Work(tm).

Write your own

Of course, there are ample more ways to write a Provider. These are just the immediately obvious ones. In our next installment we'll look at some more exotic and interesting examples of Providers at work.

PSR-14: The series

Sort:  

Congratulations @crell! You have completed the following achievement on the Steem blockchain and have been rewarded with new badge(s) :

You published more than 20 posts. Your next target is to reach 30 posts.

You can view your badges on your Steem Board and compare to others on the Steem Ranking
If you no longer want to receive notifications, reply to this comment with the word STOP

To support your work, I also upvoted your post!

Do not miss the last post from @steemitboard:

3 years on Steem - The distribution of commemorative badges has begun!
Vote for @Steemitboard as a witness to get one more award and increased upvotes!