PSR-14: Compound Providers

in #php5 years ago (edited)

In part 3 of this series we looked at the more common patterns of Providers that may be used with a PSR-14 Event Dispatcher. In part 4 we looked at some more complex cases of Providers. Today, we'll bring them all together: Literally.

Recall that a Provider is responsible only for receiving an Event and returning a list of callables that it believes should be invoked on it, in the order it decides (if it cares). How it does that is up to the implementation. In fact, it's not even required to do so itself at all. A Provider can defer that decision to another Provider if it wishes, or, critically, to multiple Providers.

Aggregate Providers

Consider a getListenersForEvent() implementation that simply iterated other Providers and chained them together:

public function getListenersForEvent(object $event): iterable
{
    foreach ($this->providers as $provider) {
        yield from $provider->getListenersForEvent($event);
    }
}

That's entirely legal, yet very powerful. It allows an integrator to chain together multiple Providers and return all of their Listeners, which get returned to the Dispatcher. The Dispatcher doesn't need to care. In fact, that exact logic is included in the AggregateProvider in the event-dispatcher-util package.

The most obvious use case for AggregateProvider (and the one for which it was first developed) was chaining together a fast, compiled Provider such as Tukio's compiled provider or Kart with a provider that supports runtime registration, such as Tukio's OrderedListenerProvider. With the compiled Provider coming first, most Listeners can be registered in advance at compile time, just like a compiled Dependency Injection Container. However, Listeners that need to be registered conditionally at runtime can still be added to the second Provider at any time and will still get called, for that request only.

Delegating Providers

Of course, one potential downside of putting all Listeners into a single Provider is that if there's a whole heck of a lot of them (technical term), it may slow down the process of finding all of them. In most cases it will be a linear search, which isn’t too bad, but what if we could optimize it even further?

For instance, if we know that a WorkflowEvent (as seen in part 4) is going to be handled by a TaggedProvider, there's no need to even try to look for Listeners in other Providers. Or perhaps we know that we only want to use Tukio's CallbackProvider for certain Events. Is there a way to "fan out" Listener lookups so that pre-sorted lists get used?

There is, in fact. The event-dispatcher-util library includes a DelegatingProvider that allows specific Event types to be passed on ("delegated") to other Providers. If there are no specialty Providers listed then a default Provider gets used.

Bring it all together

How can we use all of that? Consider this example:

$compiledProvider = new MyCompiledProvider();
$composerProvider = new Bmack\KartComposerPlugin\ComposerReflectionListenerProvider();

$orderedProvider = new Crell\Tukio\OrderedListenerProvider();

$defaultProvider = new Fig\EventDispatcher\AggregateProvider();

$defaultProvider->addProvider($compiledProvider);
$defaultProvider->addProvider($composerProvider);
$defaultProvider->addProvider($orderedProvider);


$lifecycleProvider = (new Crell\Tukio\CallbackProvider())
    ->addCallbackMethod(DocumentLoad::class, 'load')
    ->addCallbackMethod(DocumentUpdate::class, 'update')
    ->addCallbackMethod(DocumentCreate::class, 'create')
    ->addCallbackMethod(DocumentDelete::class, 'delete');

$workflowProvider = new SomeTaggedProvider();

$delegatingProvider = new Fig\EventDispatcher\DelegatingProvider($defaultProvider);

$delegatingProvider->addProvider($lifecycleProvider, [DocumentEventInterface::class]);
$delegatingProvider->addProvider($workflowProvider, [WorkflowEventInterface::class]);

$dispatcher = new Crell\Tukio\Dispatcher($delegatingProvider);

This example includes no less than seven Providers from four different vendors (Tukio, Kart, FIG, and whoever wrote SomeTaggedProvider); everything we've discussed up to this point. We'll start at the bottom with $delegatingProvider, which is the object that the Dispatcher knows about. It wraps three other Providers: $lifecycleProvider, $workflowProvider, and $defaultProvider.

When the Dispatcher passes $delegatingProvider an Event, the Provider will first check to see if it is a DocumentEventInterface Event. (We're assuming here that the Document* events all implement that interface.) If it does, then the Event will be passed to $lifecycleProvider which will return whatever Listeners it feels like, and that's the end of it. No other Providers will be called. If not, $delegatingProvider will check if it's a WorkflowEventInterface Event. If so, it will let $workflowProvider handle it exclusively. Any other Events will get delegated to $defaultProvider.

$lifecycleProvider is a CallbackProvider like we saw back in part 4, and $workflowProvider is an instance of SomeTaggedProvider, which presumably uses the Util package's TaggedProviderTrait. We covered those already so we won't go into detail here. (Note that, presumably, SomeTaggedProvider has already been configured with Listeners elsewhere.)

Now consider $defaultProvider. It's an AggregateProvider, which means it just returns the Listeners from its child Providers one after another. It also has three Providers: Two are pre-compiled (one from Composer, one from manual registration) and one is usable at runtime. The result is that any Event that is not a Document or Workflow Event will get passed to all three of those Providers, with the fast/compiled ones coming first and then any straggling runtime registered Listeners.

All of that is made possible by the low profile of ListenerProviderInterface and its separation from the Dispatcher. If, for example, you wanted to allow any arbitrary Listener to apply to Document Events, no code inside any Provider needs to change. Instead, you'd add the $lifecycleProvider to $defaultProvider instead of $delegatingProvider, like so:

// Add this line
$defaultProvider->addProvider($lifecycleProvider);

// And then remove this line:
$delegatingProvider->addProvider($workflowProvider, [WorkflowEventInterface::class]);

Then it would still trigger callbacks on the object carried by the Event but Listeners could also be included in either compiled Provider for the same Event.

In practice all of that wiring would most likely happen in a Dependency Injection Container, but the above code is what it would boil down to.

Custom Providers

The advantage of this design, combined with a few standard compound Providers, is that it's possible for libraries to even ship their own Listeners hard coded into a Provider. That Provider can then be easily mixed in with others.

Imagine a library that explicitly knows that order won't matter for its use case, but the logic for which Listeners should apply to which Event is going to be far more complex than just type matching. That library can define an Event, or an Event hierarchy, as well as an interface for its own Listeners as classes. Those Listener classes could have one method that is specified as the actual Listener callback (which could be __invoke() or something else, it doesn't matter), plus a bunch of other methods that make sense only in that library's domain context.

The library then also defines a Provider that knows how to register and manipulate those Listener classes.

  • Perhaps there's a user-defined value involved (as in the tagged case);
  • plus some time-sensitive logic (Listeners are only applicable if some other Event has happened recently);
  • plus a cap on how many can run (a good use case for StoppableEventInterface).

Or whatever other wonky use case you have. (You know you do, admit it.)

The library can define all of that... and then both the library itself and the Provider can be plugged into any framework or application with a PSR-14-compatible Dispatcher in it. Nifty. :-)

We'll see some examples of this pattern in later installments.

So what do we do with it?

The upshot of all of this is that:

  • Libraries can emit Events of their own design at any time using just DispatcherInterface, and don't need to care in the slightest about how the Event gets shepherded to Listeners. That is, they can emit Events without any framework or library dependency whatsoever, just a dependency on PSR-14.
  • Frameworks can provide their own Dispatchers, or use one off-the-shelf with equal ease. Both approaches are totally legit.
  • Application developers and integrators can pick from a variety of Providers off the shelf, or write their own. Either way will be compatible with any Dispatcher, and with any library that is emitting Events. They can event mix multiple together in any way they want.
  • Libraries that want to extend some other library via its Events can either offer a stand-alone Listener and let an integrator wire it into their system however they want, or in complex cases ship their own Provider for an integrator to include.

That provides an incredible degree of interoperability between different libraries, frameworks, and applications. Which is, you know, kind of the point of all of this.

PSR-14: The series