PSR-14: Compound Providers
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.