PSR-14: Advanced Providers

in #php6 years ago (edited)

In part 3 of our series we looked at some common Provider patterns for PSR-14. But the flexibility and complexity of Providers is limited only by your imagination. Today we'll look at a few more interesting examples of Providers that are all equally valid but tailored to particular use cases.

Self-referential Providers

As we saw in Kart, there's no requirement that the process of registering Listeners with a Provider be manual. For that matter, there's no requirement that a Provider have a "registration" process at all. There are ample use cases for a Provider that extrapolates listeners from the Event itself.

Let's consider an ORM or similar data storage library. On load, save, and various other triggers the library wants to trigger an Event, but it also wants to privilege the object being saved. That is, if a NewsArticle is being saved, the NewsArticle object itself should be allowed to have Listeners that trigger only when that object is being saved. But it shouldn't be up to the user of the library to trigger those; it should be automatic.

To handle such cases, Tukio includes a third Provider, CallbackProvider. It works only on Events that implement a paired CallbackEventInterface, which has a getSubject() method. Anything else it just silently ignores. The "subject" is the object being acted upon, which could be anything. In our example the subject means the document (.e.g, NewsArticle).

The CallbackProvider gets configured with callback methods. That is, certain Event types (all of which implement CallbackEventInterface) get associated with a method name. When an applicable Event fires, the Provider calls getSubject() on it to get the subject of the event. If that object has the corresponding method, it gets returned as a Listener.

That's a bit wonky to explain, so here's an example:

class LifecycleEvent implements CallbackEventInterface
{
   protected $doc;

   public function __construct(Document $doc)
   {
       $this->doc = $doc;
   }

   public function getSubject() : object
   {
       return $this->doc;
   }
}

class DocumentSaved extends LifecycleEvent { }
class DocumentDeleted extends LifecycleEvent { }

class NewsArticle extends Document
{
   public function onSave(DocumentSaved $event) { /* ... */ }
}

$provider = new CallbackProvider();
$provider->addCallbackMethod(DocumentSaved::class, 'onSave');

$dispatcher = new Dispatcher($provider);

$dispatcher->dispatch(new DocumentSaved($someNewsArticle));

In this example, we configure a CallbackProvider to call the onSave() method of the subject of any DocumentSaved Event it receives. The LifecycleEvent class handles the boilerplate and lets us have any specific Events we need. Then when saving a document, we dispatch a DocumentSaved event that has the document that was saved as its subject. Then the document object itself (the NewsArticle class here) provides an onSave() method that becomes a listener for objects of that class when they get saved.

If this functionality looks familiar, it should. It's the same pattern used by the Doctrine ORM (with a different implementation, of course). But it can now be implemented independently for any library in a framework agnostic way that can slot into any framework's PSR-14 compatible Event system.

Dynamic sub-type Providers

While it's good that in most cases Events get explicitly defined as a class (it's self-documenting, better compile-time validation, etc.), sometimes you don't know all the information of an Event at code-time. An Event could be specific to a particular user object that is defined only in the database. Examples here include Listeners that are specific to a particular form, or to a workflow state. A Provider can handle that, too.

Providers are free to look at any part of the Event they want to determine what Listeners are going to be relevant. While the class type is the common obvious choice, as we saw in the CallbackProvider that's not the only option.

Suppose we have a system where users are able to create new, named objects. That could be user forms, or workflows , or workflow states, or "cities" in a given country, or whatever else. It's useful to be able to target a Listener at just one of them, but creating a new event for each one is either silly or impossible, in practice. What we need instead is a Provider that can select Listeners based on the Event type and the value of some method on the Event.

Such a Provider could look something like this:

class WorkflowEvent
{
    /** @var string */
    protected $name;

    public function __construct(string $name)
    {
        $this->name = $name;
    }

    public function workflowName(): string
    {
        return $this->name;
    }
}

class WorkflowStart extends WorkflowEvent {}
class WorkflowEnd extends WorkflowEvent {}

class WorkflowProvider implements ListenerProviderInterface
{
    // Provided by the event-dispatcher-util package.
   // See https://github.com/php-fig/event-dispatcher-util/blob/master/src/ParameterDeriverTrait.php
    use ParameterDriverTrait;

    protected $listeners = [];

    protected $all = [];

    public function addListener(callable $listener, string $workflow = '', string $type = null) : void
    {
        $type = $type ?? $this->getParameterType($listener);
        if ($workflow) {
            $this->listeners[$workflow][$type][] = $listener;
            return;
        }
        $this->all[$type][] = $listener;
    }

    public function getListenersForEvent(object $event) : iterable
    {
        if (!$event instanceof WorkflowEvent) {
            return [];
        }

        foreach ($this->listeners[$event->workflowName()] as $type => $listeners) {
            if ($event instanceof $type) {
                yield from $listeners;
            }
        }

        foreach ($this->all as $type => $listeners) {
            if ($event instanceof $type) {
                yield from $listeners;
            }
        }
    }
}

In this example, Listeners can register with the Provider and with a workflow name that they apply to. When the Dispatcher asks it for listeners, it will first return all of the Listeners registered for the Event's type and for its workflow name, then it will return those registered for any workflow. (You could just as easily order it the other way around if you preferred. A more robust implementation that supports ordering individual Listeners is left as an exercise to the reader.)

The same logic would apply for form Events, or anything else. In fact, so common a use case is this that the PSR-14 Working Group included a helper trait in the event-dispatcher-util package. There it is called a TaggedProvider, and includes most of the core logic of matching based on a particular method on an Event. It has a few abstract methods that need to be filled in, but does about half the work for you for this type of use case.

PSR-14: The series