- Get link
- X
- Other Apps
- Get link
- X
- Other Apps
When a developer gets further in their career the developer will see patterns in how they should write code and try to make a generic library to avoid repeating itself. But the problem is that if a library is generic, an application should be able to modify its behaviour somehow. In the end you often end up with some 'plugin' or 'module' system. There are 5 possible solutions I know of (but I only use 4 of them).
Event hooks
The event hooks is the simplest solution to come up with. Most frameworks have a dispatcher service which can dispatch events. You can make listeners to listen to these events.
In most Symfony applications this Event object is often following the mediator design pattern to share information over multiple listeners.
Since a mediator is mutable the order of listeners could have effect how it is being executed, so it could be better to make an immutable event object instead. This choice however probably leads to more events to have control over your order of execution (so you end up with 3 events like 'pre-boot', 'boot' and 'post-boot' for 'booting' your library).
Often it is not clear to any developer which event is triggered in which order. I've seen cases a library has a 'init' and a 'boot' hook and I have to read the code or documentation to know which one is triggered first.
use Psr\EventDispatcher\EventDispatcherInterface;
class ServiceExample
{
public function __construct(private readonly DispatcherInterface $dispatcher)
{
}
public function veryDynamicExtensionEventExample()
{
$event = $this->dispatcher->dispatch(new PreExampleBuiltEvent());
$object = $event->getObject();
$event = $this->dispatcher->dispatch(new ExampleBuiltEvent($event, $object));
$this->dispatcher->dispatch(new PostExampleBuiltEvent($event, $object));
}
}
So some highlights/drawbacks for this solution:
- Very simple to understand.
- Library is in control what can be extended.
- Good integration possibilities with frameworks.
- Order of execution is hard to follow.
- You could end up with lots and lots of events.
- Your library could be tightly coupled to the event dispatcher, or even tightly coupled to a framework.
Library examples
Doctrine | Nuxt |
Convention over configuration
This involves in the library telling the developer that if they want to use your library you have to use their opinionated setup. For example the library assumes you put all extendible code in path src/Library/Extension and the code use this to extend the application. Sometimes it is possible to overwrite this behaviour, but in most cases this is discouraged.
A good example is the node_modules path in node.js applications. Even though you can override it with environment variables or hacky code with overriding global node.js functions, it is often discouraged as many other libraries assume this convention is followed.
The best feature is probably that the code becomes very predictable and it would be very easy for a new developer to figure out where to put files if they want to extend behaviour.
But it is also the downside for this 'magic' as starting a new application is where the developer have to figure out where to put files to extend.
So some highlights/drawbacks for this solution:
- very simple to understand in existing applications
- need scaffolding tools or very good documentation for developers to understand in new applications.
- Library decides how to structure your code. Never a discussion how you should structure the code.
- The 'magic' has some overlap with the 'autowiring' of service containers as found in existing frameworks.
- Your library code could be hard to read since it probably relies on file system, hardcoded paths or use of the Reflection API.
Macroable facades
Personally I never use this solution, but in many Laravel libraries this is the norm in being able to exend your library. In this case the library has often a 'facade' service that has a magic __call and __callStatic method. If a method does not exist it will call __call or __callStatic where we can dynamically call other services or callbacks. The service class has a ::macro() or ::extend() static method where you can add functions dynamically. This is often done by adding a macroable trait.
While any developer often figures out how this works (or does not care where the method call comes from), these classes have many problems. One of them is that ::macro() or ::extend() assume there can only be one instance of this class which makes it really hard to refactor if you need 2 instances at the same time. It could also give trouble with running multiple tests.
Another problem is that I can always do calls to ::macro() or ::extend() over the entire program flow. It is completely impossible for static code analysis without ignoring many rules or just run the actual code (which is what Larastan does for Laravel)
class MacroExample {
private array $extensions = [];
public function extend(object $extension)
{
$this->extensions = $extension;
}
public function __call(string $method, array $arguments)
{
foreach ($this->extensions as $extension) {
if (is_callable([$extension, $method])) {
return call_user_func_array([$extension, $method], $arguments);
}
}
throw new \LogicException(sprintf('Method %s does not exist!', $method));
}
So some highlights/drawbacks for this solution:
- Any developer understands the concept and since there is already a library for it, requires no skill and effort.
- The service class can only have one instance ever for this to make it work.
- Indirect dependencies are not always visible and could lead into confusing errors. For example an error like 'method getBanana not found!' does not give the developer any hints what library they need to add.
- You can run in name conflicts if 2 plugins try to extend with the same name.
- You can introduce things currently not possible in PHP, for example function overloading
- Static code analysis is impossible
- You can not not have an interface for magic methods.
- Behaviour can be altered very dynamically and in runtime.
- Very loose coupling means it's harder to find the actual code of your extension.
Library examples
Laravel |
Use of decorators
The decorator pattern is perfect for adding functionality without changing the function arguments. In this case we require an interface and add behaviour by wrapping an other class around it. Any class does it's own thing. The most inner class is often doing the basic actions/behaviour. For example we can add a caching layer with a decorator very easily or adding logging functionality. The API Platform uses it to extend the API Platform logic. Symfony uses it in particular for the webprofiler to log all actions done. The biggest downside is that adding/changing functionality requires altering all decorators as it requires a change in the interface.
use Psr\Cache\CacheItemPoolInterface;
use Psr\Log\LoggerInterface;
interface ServiceInterface
{
public function retrieveSomething(string $id): EntityInterface;
public function doSomething(EntityInterface $object): EntityInterface;
}
class Service implements ServiceInterface
{
// implementation of service
}
final class CachingLayer implements ServiceInterface
{
public function __construct(
private readonly ServiceInterface $internal,
private readonly CacheItemPoolInterface $cache
) {
}
public function retrieveSomething(string $id): EntityInterface
{
$cacheItem = $this->cache->getItem($id);
if (!$cacheItem->isHit()) {
$cacheItem->set($id, $this->internal->retriveSomething($id));
}
return $cacheItem->get();
}
public function doSomething(EntityInterface $object): EntityInterface
{
$result = $this->internal->doSomething($object);
$this->cache->getItem($object->getId())->set($result);
return $result;
}
}
final class LoggingLayer implements ServiceInterface
{
public function __construct(
private readonly ServiceInterface $internal,
private readonly LoggerInterface $logger
) {
}
public function retrieveSomething(string $id): EntityInterface
{
$this->logger->debug('retrieveSomething(' . $id . ')');
$result = $this->internal->retrieveSomething($id);
$this->logger->debug('retrieveSomething returned entity with id: ' . $result->getId());
return $result;
}
public function doSomething(EntityInterface $object): EntityInterface
{
$this->logger->debug('doSomething(' . $object->getId() . ')');
$result = $this->internal->doSomething($object);
$this->logger->debug('doSomething returned entity with id: ' . $result->getId());
return $result;
}
}
$service = new LoggingLayer(
new CachingLayer(
new LoggingLayer(
new Service(),
new MailLogger()),
new ArrayCache()
),
new FileLogger('/tmp/log.txt')
);
Normally the framework will do the decoration for you as in plain php the call to make the service looks quite intimidating. The cool thing is that we can even reuse the same class as a different logger is being called when a result is not cached in the caching layer.
So some highlight/drawbacks of this solution:
- The order of decorators determines how it is being used.
- Long decorator calls can look intimidating.
- Changing the interface requires lots of coding changes.
- Good integration with frameworks and dependency injection.
- Many small classes with a tight interface.
- Good reuse and good testability.
Library examples
Use of a chain of responsibility
The use of a chain of responsibility can be found a lot in Symfony applications. In a symfony application we often find this construction:
interface ChainInterface
{
public supports(mixed $input, array $options): bool;
public doSomething(mixed $input, array $options): mixed;
}
class ServiceExample
{
private array $chains;
public function __construct(ChainInterface... $chains)
{
$this->chains = $chains;
}
public function doSomething(mixed $input, array $options): mixed
{
foreach ($this->chains as $chain) {
if ($chain->supports($input, $options)) {
return $chain->doSomething($input, $options);
}
}
throw new UnsupportedException($input, $options);
}
}
You need to write this class yourself and you can have multiple variations how the chain is processed. For example the symfony serializer component caches the result
which Normalizer to pick when making more calls. In this example the first chain that returns true on the supports method is being used and any other chain that returns true on supports is not being called or checked. Sometimes you can also start with an initial object and let a chain modify the object.
Support for chain of responsibility classes is found in frameworks. Symfony has very extensive support for it, including control of the order of the chains. Laravel has tagged services, but it can only return service instances without any control on order.
One of the main advantages is the ease of testing your chain without any other outside effects as you can easily test your chain individually:
$testItem = new ServiceExample(new MyChainClass());
$this->assertEquals(42, $testItem->doSomething('6 * 7', []));
It also does not have the issue with decorators as a chain of responsibility can receive classes of multiple interfaces as well (and introduce backwards compatibility)
So some highlights/drawbacks for this solution:
- You need to make the service class yourself and come up with your own strategy how to handle multiple chains.
- Order of chains is important to know in case of order of execution.
- Ease of testing
- Easy to integrate with existing frameworks.
- Backwards compatibility is possible.
- Very loose coupling means it's harder to find the actual code of your extension.
Library examples
Symfony | Apie |
Conclusion
In the end it depends on the context which solution you pick. As long you do not pick the macro solution as too much magic will kill you. In some cases you can also combine it, so I hope to see someone using a chain of responsibility with event hooks and convention over configuration.
I have seen that sometimes picking the right solution feels like magic to other developers how the classes are linked together.
Comments
Post a Comment