Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce service extensions by type #44

Merged
merged 7 commits into from
May 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
Chrico marked this conversation as resolved.
Show resolved Hide resolved
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