PSR-14: Example - plugin registration

in #php5 years ago (edited)

In Content Management Systems and similar highly-configurable applications, a common pattern is to have a registration mechanism of some sort. That is, some part of the system asks other parts of the system "give me a list of your Things!", and then modules/extensions/plugins (whatever the system calls them) can incrementally build up that list of Things, which the caller then does something with. Those Things can be defined by the extension, or they can be defined by user-configuration and turned into a Thing definition by the module. Both are valid and useful, and can be mixed and matched.

This pattern lends itself very well to an Event system like PSR-14, and in fact the "give me a list of Things" pattern was one of the explicit use cases the Working Group considered. Today let's look at how one could easily implement such a mechanism.

The use case

As our example, let's consider a mechanism for pluggable Response handlers for a PSR-7 Request. Depending on the Accept header of the Request a different formatter should handle turning some domain object into a Response, and the list of such formatters is not hard-coded because it should be extensible. How can we do that with PSR-14?

The approach may vary quite a bit depending on the details of the system, how these different Formatters are defined, if they should be registered ahead of time or just-in-time, etc. To keep the example simple (since we just want to demonstrate the PSR-14 usage, not how to build a full plugin system) I'm going to show a lazy, just-in-time, container-aware approach. A more robust system would pre-register formatters and avoid being container-aware.

Again, we're going to use Tukio as our example here but any PSR-14 implementation will do.

Event design

The first question is what the Event itself looks like. Many such legacy systems use an array that can be passed around by reference and modified in place, but that has a huge number of problems; arrays are not self-documenting, error-prone, and memory-inefficient. You also can't pass them to a PSR-14 Dispatcher. So what shall we do instead?

Since Events can be any PHP object, let's just purpose-build an object to collect information exactly the way we want, and purpose-build an object for the definition of each Formatter. The latter can look something like this:

class FormatterDefinition
{
   /** @var int */
   public $priority = 0;

   /** @var string */
   public $permission = '';

   /** @var string */
   public $serviceName = '';

   /** @var string */
   public $mimeTypes = [];

   /** @var array */
   public $userSettings = [];
}

In this case we're just using a "struct class", which is a class with all public properties. It works almost the same way as an associative array but is more self-documenting, slightly faster, and uses half as much memory. If you prefer to have private properties and have methods to setup the definition that's also totally OK, and probably better in most cases. I'm just keeping it simple for now.

The particular properties here are mostly made up; in this case, Formatters can have a priority of their own, can be permission-restricted, have the mime types to which they can apply, and a blob of user-settings (which should really be another defined object but that's out of scope for now). Because we're setting defaults, though, they are already more robust than an otherwise identical associative array. The Formatter priority is used to determine the order in which the formatting system will try the formatter that each definition represents.

We're also referencing the actual formatter logic by a name in a Dependency Injection Container. If you wanted to reference it some other way that would work, too, but I find that these sort of registration mechanisms are a place where container-awareness is at least not-terrible, even if not ideal.

Then we need an object that serves as a collection of FormatterDefinition objects. Since we want array-ish behavior, let's go ahead and implement \ArrayAccess and \IteratorAggregate for it to create, basically, an array-ish collection of FormatterDefinition objects.

class FormatRegistration implements ArrayAccess, IteratorAggregate
{
   /** @var array */
   protected $formatters = [];

   public function offsetExists($key)
   {
       return array_key_exists($key, $this->formatters);
   }

   public function offsetGet($key)
   {
       if (!array_key_exists($this->formatters[$key])) {
           $this->formatters[$key] = new FormatDefinition();
       }
       return $this->formatters[$key];
   }

   public function offsetSet($key, $formatter)
   {
       if (! $formatter instanceof FormatterDefinition) {
           throw new \InvalidArgumentException("That's not a formatter.");
       }
       $this->formatters[$key] = $formatter;
   }

   public function offsetUnset($mimeType)
   {
       unset($this->formatters[$mimeType]);
   }

   public function getIterator()
   {
       usort($this->formatters, function (FormatterDefinition $a, FormatterDefinition $b) {
           return !($a->priority <=> $b->priority);
       });

       return $this->formatters;
   }
}

Most of that is boilerplate \ArrayAccess behavior so needs no further explanation. A few things though are worthy of note:

  • The key for each Formatter is an arbitrary string. Alternatively you could use the mime type as the key and not make it part of the definition object. Your choice.
  • The offsetSet() method actively type checks and blocks invalid values. That means we're guaranteed to only have FormatterDefinition objects in the collection, simplifying later logic.
  • The offsetGet() method creates new definitions on the fly if a user tries to access one that's not defined. Whether you want that or not depends on your use case; I included it here to show that you can. If the FormatterDefinition class had more useful methods on it to set multiple values at once it would be even more useful.
  • getIterator() returns the collection of Formatter definitions, but ensures that they're sorted by priority first. Note that by default the TIE Interceptor operator (<=>) as used here will sort in ascending order of the priority property. Since we want higher priorities to win we sort the other way.

So now we have a definition object, and we have a definition collection object. Now we need the Event. Except no, we don't, because the collection object is the Event! It's already doing everything we need our Event to do: It lets Listeners add/remove/modify a list of definitions. The Event itself is nothing more than a collection of FormatterDefinition objects that the emitter will be collecting. Score.

What then do Listeners look like? Quite boring, really (which is good):

$provider->addListener(function(FormatRegistration $formatters) {
   $formatter = new FormatterDefinition();
   $formatter->priority = 2;
   $formatter->serviceName = 'json_formatter';
   $formatter->permission = 'use api';
   $formatter->mimeTypes = ['application/json'];
   $formatters['json'] = $formatter;
});

$provider->addListener(function(FormatRegistration $formatters) {
   $formatter = new FormatterDefinition();
   $formatter->priority = 5;
   $formatter->serviceName = 'default_formatter';
   $formatter->permission = '*';
   $formatter->mimeTypes = ['text/html'];
   $formatter->userSettings = [ /* Something here */ ];
   $formatters['default'] = $formatter;
});

$provider->addListener(function(FormatRegistration $formatters) {
   if (isset($formatters['json'])) {
       $formatters['json']->userSettings['flip'] = true;
   }
});

Each Listener (which in practice would probably be in a Subscriber) receives a FormatRegistration object and does "stuff" to it. The first defines a JSON Formatter. Since it doesn't specify any userSettings that's left at the default empty array. The second defines a text/html Formatter and does include some user settings (which are left as an exercise for the reader). And the third modifies the user settings of the JSON formatter if it's defined.

So that's it. We have all of our Event logic set up nicely, in a way that is compatible with any PSR-14 compliant Event Dispatcher. How would we use it? Here's a simple, naive way:

$accept = $request->getHeader('Accept');

$formatters = $dispatcher->dispatch(new FormatRegistration());

foreach ($formatters as $definition) {
   if (! $currentUser->hasPermission($definition->permission)) {
       continue;
   }
   $formatter = $container->get($definition->serviceName);
   if ($formatter->accepts($accept)) {
       return $formatter->format($domainObject, $definition->userSettings);
   }
}

The Dispatcher sends off an empty FormatRegistration object, which comes back populated by the Listeners. Because it implements \IteratorAggregate it can be foreach()ed directly and it will do its own sorting for us. Then it's just a matter of using that data to implement our business logic. Again, a more robust implementation would probably pre-load all of that data and possibly even build a generated version of this routine. Such an implementation is left as an exercise for the reader.

Change the world

The astute reader will probably have noticed one subtle issue in the previous example. Since none of the Listeners are ordered, will the 3rd Listener get called before or after the 1st? If it gets called before, then its isset() check will fail even though the json Formatter it's looking for will be defined eventually. There's two possible solutions to that problem.

  1. Rely on Listener priority to ensure that "alter" Listeners alway run after the Listener whose work they intend to modify. A Provider with a robust ordering mechanism like Tukio makes that straightforward but it's not always practical, and not all Providers will have an explicit ordering.
  2. Have a two-pass approach where the list gets built once and then passed into a second Event, so Listeners can listen to the second "alter" Event and know that they're running in the second half. That does have a limitation though where a Listener may want to modify the modifications of another second-pass Listener, in which case it needs to fall back to option 1.

Option 2 is the approach taken by systems like Drupal that relies very heavily on "alter hooks", which are essentially just a two-pass Event dispatch. How could we implement such a system here? The first gut-reaction is to just pass the FormatterRegstration object back into the Dispatcher a second time, but that would just trigger the same Listeners over again. It needs to be a separate Event, and not one in the same inheritance hierarchy as that would still end up triggering the same Listeners twice.

However, those two Events can be siblings of each other! Or use a trait. Both work, but I'm going to demonstrate using a Trait as that makes it impossible to accidentally listen to both Events. First, let's make a new Trait that takes the guts out of FormatRegistration:

trait FormatRegistrationTrait 
{
    /* The same code from above, but omitted to save space. */
}

Now we define two Event classes that use that trait: One for the first pass, one for the second pass.

class FormatRegistration implements \ArrayAccess, \IteratorAggregate
{
   use FormatRegistrationTrait;

   public function getAlterEvent() : FormatRegistrationAlter
   {
       return new FormatRegistrationAlter($this->formatters);
   }
}

class FormatRegistrationAlter implements \ArrayAccess, \IteratorAggregate
{
   use FormatRegistrationTrait;

   public function __construct(array $formatters)
   {
       $this->formatters = $formatters;
   }
}

See what's going on there? Both Events use the same trait, so the API for them is identical. However, the second-pass Event is initialized with a set of formatters already; the first-pass Event has a method to create that second-pass Event from its own data. Then leveraging it in practice becomes super trivial:

$formatters = $dispatcher->dispatch(new FormatRegistration());
$formatters = $dispatcher->dispatch($formatters->getAlterEvent());
foreach ($formatters as $formatter) { ... }

The upshot

In practice, it's debatable if there's much benefit to a framework or CMS using this mechanism for its own first-party extensions. It certainly can, but the main benefits are "consistent API" and "because it's there", not that it enables anything especially new.

However, the benefit to free-standing libraries is huge. Consider the API surfaces in question:

  • The only place an Emitting library interfaces with a framework or application is through the Dispatcher: a standard, decoupled API that is compatible with any PSR-14-friendly framework.
  • The library's interface with its own extensions is FormatterDefinition, FormatRegistration, and optionally FormatRegistrationAlter. All of which it controls as they're part of its own codebase.
  • Listeners may or may not be dependent on a particular Provider, but there's no reason they have to be. And just as with the previous example it's entirely A-OK for the library to include its own purpose-built way to add format-registering Listeners on its own Provider, which can then be tossed into the mix of any framework by composing ListenerProviderInterface objects.

To continue our format registration example, consider a library that is designed to produce PSR-7 Response objects but wants to be fully decoupled from any framework, yet allow anyone to plug in additional Accept-header-based Formatters. It can now offer that registration ability through any PSR-14-capable Dispatcher, in any framework or application, without worrying about coupling itself to any one implementation. Its only surface area is the Dispatcher and potentially a custom Provider that it includes.

But what about those first-party extensions in a framework or application? Well... I lied. There is a benefit. Those subsystems can then become fully free-standing and not bound to the framework at all, since they don't need to rely on the framework for its extension mechanism.

They may have dependencies on other parts of the framework, which is fine; but when it comes to making themselves extensible by administrator-supplied add-ons, as they saying goes "There's a Standard for That."

PSR-14: The series