Skip to content

Commit

Permalink
Introduce service extensions by type (#44)
Browse files Browse the repository at this point in the history
* Introduce extensions by type

ExtendingModules can now declare extensions not only by specific ID but also by type.

Services' extension handling has been moved to a separate class (`Container\ServiceExtensions`) to facilitate and decouple the handling of the new, more advanced behavior.


* Implement ServiceExtensions with backward compatibility
* Fix PHP 7.2/7.3 compatibilit in tests
* Update docs

----

Signed-off-by: Giuseppe Mazzapica <[email protected]>
Co-authored-by: Christian Leucht <[email protected]>
Co-authored-by: Thorsten Frommen <[email protected]>
  • Loading branch information
3 people authored May 10, 2024
1 parent 7538809 commit feaa4d0
Show file tree
Hide file tree
Showing 7 changed files with 711 additions and 58 deletions.
119 changes: 119 additions & 0 deletions docs/Modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,125 @@ class ModuleWhichProvidesExtensions implements ExtendingModule
}
```

### Extending by type

Sometimes it is desirable to extend a service by its type. Extending modules can do that as well:

```php
use Inpsyde\Modularity\Module\ExtendingModule;
use Psr\Log\{LoggerInterface, LoggerAwareInterface};

class LoggerAwareExtensionModule implements ExtendingModule
{
public function extensions() : array
{
return [
'@instanceof<Psr\Log\LoggerAwareInterface>' => static function(
LoggerAwareInterface $service,
ContainerInterface $c
): ExtendedService {

if ($c->has(LoggerInterface::class)) {
$service->setLogger($c->get(LoggerInterface::class));
}
return $service;
}
];
}
}
```

#### Types and subtypes

The `@instanceof<T>` syntax works with class and interface names, targeting the given type and any
of its subtypes.

For example, assuming the following objects:

```php
interface Animal {}
class Dog implements Animal {}
class BullDog extends Dog {}
```

and the following module:

```php
class AnimalsExtensionModule implements ExtendingModule
{
public function extensions() : array
{
return [
'@instanceof<Animal>' => fn(Animal $animal) => $animal,
'@instanceof<Dog>' => fn(Dog $dog) => $dog,
'@instanceof<BullDog>' => fn(BullDog $bullDog) => $bullDog,
];
}
}
```

A service of type `BullDog` would go through all the 3 extensions.

Note how extending callbacks can always safely declare the parameter type using in the signature
the type they have in `@instanceof<T>`.

#### Precedence

The precedence of extensions-by-type resolution goes as follows:

1. Extensions added to exact class
2. Extensions added to any parent class
3. Extensions added to any implemented interface

Inside each of the three "groups", extensions are processed in _FIFO_ mode: the first added are the
first processed.

#### Name helper

The syntax `"@instanceof<T>"` is an hardcoded string that might be error prone to type.

The method `use Inpsyde\Modularity\Container\ServiceExtensions::typeId()` might be used to avoid
using hardcode strings. For example:

```php
use npsyde\Modularity\Container\ServiceExtensions;

class AnimalsExtensionModule implements ExtendingModule
{
public function extensions() : array
{
return [
ServiceExtensions::typeId(Animal::class) => fn(Animal $animal) => $animal,
ServiceExtensions::typeId(Dog::class) => fn(Dog $dog) => $dog,
ServiceExtensions::typeId(BullDog::class) => fn(BullDog $bullDog) => $bullDog,
];
}
}
```

#### Only for objects

Extensions-by-type only work for objects. Any usage of `@instanceof<T>` syntax with a string that is
a class/interface name will be ignored.
That means it is not possible to extend by type scalar/array services nor pseudo-types like
`iterable` or `callable`.

#### Possibly recursive

Extensions by type might be recursive. For example, an extension for type `A` that returns an
instance of `B` will prevent further extensions to type `A` to execute (unless `B` is a child of `A`)
and will trigger execution of extensions for type `B`.
**Infinite recursion is prevented**. So if extensions for `A` return `B` and extensions for `B`
return `A` that's where it stops, returning an `A` instance.

#### Use carefully

**Please note**: extensions-by-type have a performance impact especially when type extensions are
used to return a different type, because of possible recursions.
As a reference, it was measured that resolving 10000 objects in the container, each having 9
extensions-by-type callbacks, on a very fast server, on PHP 8, for one concurrent user, takes
between 80 and 90 milliseconds.

## ExecutableModule
If there is functionality that needs to be executed, you can make the Module executable like following:

Expand Down
15 changes: 6 additions & 9 deletions src/Container/ContainerConfigurator.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ class ContainerConfigurator
private $factoryIds = [];

/**
* @var array<string, array<callable(mixed $service, ContainerInterface $container):mixed>>
* @var ServiceExtensions
*/
private $extensions = [];
private $extensions;

/**
* @var ContainerInterface[]
Expand All @@ -38,9 +38,10 @@ class ContainerConfigurator
*
* @param ContainerInterface[] $containers
*/
public function __construct(array $containers = [])
public function __construct(array $containers = [], ?ServiceExtensions $extensions = null)
{
array_map([$this, 'addContainer'], $containers);
$this->extensions = $extensions ?? new ServiceExtensions();
}

/**
Expand Down Expand Up @@ -115,11 +116,7 @@ public function hasService(string $id): bool
*/
public function addExtension(string $id, callable $extender): void
{
if (!isset($this->extensions[$id])) {
$this->extensions[$id] = [];
}

$this->extensions[$id][] = $extender;
$this->extensions->add($id, $extender);
}

/**
Expand All @@ -129,7 +126,7 @@ public function addExtension(string $id, callable $extender): void
*/
public function hasExtension(string $id): bool
{
return isset($this->extensions[$id]);
return $this->extensions->has($id);
}

/**
Expand Down
51 changes: 36 additions & 15 deletions src/Container/ReadOnlyContainer.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class ReadOnlyContainer implements ContainerInterface
private $factoryIds;

/**
* @var array<string, array<callable(mixed, ContainerInterface $container):mixed>>
* @var ServiceExtensions
*/
private $extensions;

Expand All @@ -41,18 +41,18 @@ class ReadOnlyContainer implements ContainerInterface
*
* @param array<string, callable(ContainerInterface $container):mixed> $services
* @param array<string, bool> $factoryIds
* @param array<string, array<callable(mixed, ContainerInterface $container):mixed>> $extensions
* @param ServiceExtensions|array $extensions
* @param ContainerInterface[] $containers
*/
public function __construct(
array $services,
array $factoryIds,
array $extensions,
$extensions,
array $containers
) {
$this->services = $services;
$this->factoryIds = $factoryIds;
$this->extensions = $extensions;
$this->extensions = $this->configureServiceExtensions($extensions);
$this->containers = $containers;
}

Expand All @@ -69,7 +69,7 @@ public function get(string $id)

if (array_key_exists($id, $this->services)) {
$service = $this->services[$id]($this);
$resolved = $this->resolveExtensions($id, $service);
$resolved = $this->extensions->resolve($service, $id, $this);

if (!isset($this->factoryIds[$id])) {
$this->resolvedServices[$id] = $resolved;
Expand All @@ -83,7 +83,7 @@ public function get(string $id)
if ($container->has($id)) {
$service = $container->get($id);

return $this->resolveExtensions($id, $service);
return $this->extensions->resolve($service, $id, $this);
}
}

Expand Down Expand Up @@ -118,21 +118,42 @@ public function has(string $id): bool
}

/**
* @param string $id
* @param mixed $service
* Support extensions as array or ServiceExtensions instance for backward compatibility.
*
* @return mixed
* With PHP 8+ we could use an actual union type, but when we bump to PHP 8 as min supported
* version, we will probably bump major version as well, so we can just get rid of support
* for array.
*
* @param mixed $extensions
* @return ServiceExtensions
*/
private function resolveExtensions(string $id, $service)
private function configureServiceExtensions($extensions): ServiceExtensions
{
if (!isset($this->extensions[$id])) {
return $service;
if ($extensions instanceof ServiceExtensions) {
return $extensions;
}

if (!is_array($extensions)) {
throw new \TypeError(
sprintf(
'%s::%s(): Argument #3 ($extensions) must be of type %s|array, %s given',
__CLASS__,
'__construct',
ServiceExtensions::class,
gettype($extensions)
)
);
}

foreach ($this->extensions[$id] as $extender) {
$service = $extender($service, $this);
$servicesExtensions = new ServiceExtensions();
foreach ($extensions as $id => $callback) {
/**
* @var string $id
* @var callable(mixed,ContainerInterface):mixed $callback
*/
$servicesExtensions->add($id, $callback);
}

return $service;
return $servicesExtensions;
}
}
Loading

0 comments on commit feaa4d0

Please sign in to comment.