Skip to content

Commit

Permalink
implement class and annotation scanner as a container config
Browse files Browse the repository at this point in the history
  • Loading branch information
frederikbosch committed May 22, 2024
1 parent 80e4914 commit c5c7176
Show file tree
Hide file tree
Showing 22 changed files with 709 additions and 87 deletions.
6 changes: 5 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@
"require-dev": {
"producer/producer": "^2.3",
"phpunit/phpunit": "^9.0 || ^10.0",
"phpstan/phpstan": "^1.11.1"
"phpstan/phpstan": "^1.11.1",
"composer/class-map-generator": "^1.1"
},
"suggest": {
"composer/class-map-generator": "Required for detecting annotations"
},
"autoload-dev": {
"psr-4": {
Expand Down
81 changes: 72 additions & 9 deletions docs/attributes.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
# Injection via attributes
# Injection and container modification via attributes

Rather than defining injection via `$di->params`, `$di->setters`, `$di->values` or `$di->types`, you can also annotate
parameters using the [native attributes available from PHP 8.0](https://www.php.net/manual/en/language.attributes.overview.php).
For now, only constructor parameters can be annotated.

The advantage would be that you have the injection decisions included in the class itself, keeping it all
together. Moreover, when your application grows, the length of your code constructing the container might grow to such size
that maintaining it becomes a problem. This might even be the case when you have separated it with `ContainerConfigInterface` instances.
With attributes such a problem does not exist.

For now, only constructor parameters can be annotated.
Moreover, you can define attributes that modify the container. These will be discussed in the end of this chapter.

## Service Attribute
## Inject with the Service Attribute

If a parameter is annotated with the `#[Service]` attribute, you define that a service should be injected.

Expand All @@ -36,7 +37,7 @@ The _Container_ will inject `foo.service` for `$foo`. It is basically the same a
$di->params['Example']['foo'] = $di->lazyGet('foo.service');
```

## Instance Attribute
## Inject with the Instance Attribute

If the parameter is annotated with the `#[Instance]` attribute, you define a new instance of a class that should be injected.

Expand All @@ -62,9 +63,9 @@ The _Container_ will inject a new instance `Foo::class` for `$foo`. It is basica
$di->params['Example']['foo'] = $di->lazyNew(Foo::class);
```

## Value Attribute
## Inject with the Value Attribute

If the parameter is annotated with the `#[Value]` attribute, you define a new value that should be injected.
If a parameter is annotated with the `#[Value]` attribute, you define that a value should be injected.

For example, look at the following class; the `$foo` constructor parameter has such an annotation:

Expand All @@ -88,7 +89,7 @@ The _Container_ will inject the value `foo.value` for `$foo`. It is basically th
$di->params['Example']['foo'] = $di->lazyValue('foo.value');
```

## Custom Attributes
## Inject with Custom Attributes

It is also possible to create your own custom attribute. All you have to do is create a class using [the native PHP 8.0 attribute
syntax](https://www.php.net/manual/en/language.attributes.syntax.php). On top, it has to implement the `Aura\Di\Attribute\AnnotatedInjectInterface` class.
Expand Down Expand Up @@ -169,7 +170,7 @@ It is basically the same as if you would write the following:
$di->params['Example']['foo'] = $di->lazyGetCall('config', 'get', 'foo');
```

## Overwriting attributes
## Overwriting injection attributes

When you define both an annotation and an injection via code, the injection via code has precedence over the annotation.

Expand All @@ -179,4 +180,66 @@ the `$foo` constructor parameter, and hence inject the value `"bravo"` and not `
```php
$di->set('config', new ConfigBag(['foo' => 'bar', 'alpha' => 'bravo']));
$di->params['Example']['foo'] = $di->lazyGetCall('config', 'get', 'alpha');
```
```

## Modify the Container using attributes

Modifying the container with attributes requires building the container with the
[`AnnotatedResolverFactory`](config.md#containerbuilder-and-annotations). When done so, the builder will scan the
passed directories for classes and annotations. Every attribute that implements the `DefineInterface` can modify the
container.

Suppose you create a `#[Route]` attribute and define it inside a `Controller` class.

```php
#[\Attribute]
use Aura\Di\AttributeConfigInterface;
use Aura\Di\Injection\Lazy;
use Aura\Di\Injection\LazyLazy;
use Aura\Di\Injection\LazyNew;
use Aura\Di\Resolver\Resolver;

class Route implements AttributeConfigInterface {
public function __construct(private string $method, private string $uri) {
}

public function define(Resolver $resolver, \Reflector $reflector): void
{
if ($reflector instanceof \ReflectionMethod) {
// considering the routes key is a lazy array, defined like this
// $resolver->values['routes'] = $container->lazyArray([]);
$resolver->values['routes']->append(
new RealRoute(
$this->method,
$this->uri,
new InvokableHandler(new LazyLazy(
$resolver,
new Lazy([
LazyNew::fromClassName($reflector->getDeclaringClass()),
$reflector->getName()
])
))
)
);
}
}
}

class Controller {
#[Route('GET', '/method1')]
public function method1() {}

#[Route('GET', '/method2')]
public function method2() {}
}

class RouterFactory {
public function __construct(
#[Value('routes')]
private array $routes
) {
}
}
```

The `$routes` parameter in the RouterFactory would result in an array of `RealRoute` objects being injected.
164 changes: 162 additions & 2 deletions docs/config.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# Container Builder and Config Classes

The _ContainerBuilder_ also builds fully-configured _Container_ objects using _ContainerConfig_ classes. It works using a [two-stage configuration system](http://auraphp.com/blog/2014/04/07/two-stage-config).
## The ContainerBuilder

The _ContainerBuilder_ also builds fully-configured _Container_ objects using _ContainerConfig_ classes. It works
using a [two-stage configuration system](http://auraphp.com/blog/2014/04/07/two-stage-config).

The two stages are "define" and "modify". In the "define" stage, the _ContainerConfig_ object defines constructor parameter values, setter method values, services, and so on. The _ContainerBuilder_ then locks the _Container_ so that these definitions cannot be changed, and begins the "modify" stage. In the "modify" stage, we may `get()` services from the _Container_ and modify them programmatically if needed.

Expand All @@ -20,7 +23,11 @@ $di = $container_builder->newConfiguredInstance([
]);
```

**Note:** As with the `newInstance` method of the `ContainerBuilder`, you will have to pass `$container_builder::AUTO_RESOLVE` to `newConfiguredInstance` (as the second parameter) if you want to enable auto-resolution.
**Note:** As with the `newInstance` method of the `ContainerBuilder`, you will have to
pass `$container_builder::AUTO_RESOLVE` to `newConfiguredInstance` (as the second parameter) if you want to enable
auto-resolution.

## Container Config classes

A configuration class looks like the following:

Expand Down Expand Up @@ -102,3 +109,156 @@ the `define` and `modify` methods of each of the other ContainerConfigs.
$di = $container_builder->newConfiguredInstance([\My\App\Config::class])
```

## Compiling and serializing the container

With the _ContainerBuilder_, you can also create a compiled container that is ready for serialization. A compiled
container does all the class metadata collection and creates a `Blueprint` class for every class that can be instantiated.
The methodology for creating a compiled container is similar to creating a configured instance.

```php
use Aura\Di\ContainerBuilder;

$config_classes = [
new Aura\Router\_Config\Common(),
];

$container_builder = new ContainerBuilder();

$di = $container_builder->newCompiledInstance(
$config_classes,
ContainerBuilder::AUTO_RESOLVE
);
```

We can then serialize and unserialize the container:

```php
$serialized = serialize($di);
$di = unserialize($serialized);
```

This serialized container might be saved to a file, as cache layer before continuing. Finally, we must configure the
compiled instance.

```php
$di = $builder->configureCompiledInstance($di, $config_classes);

$fakeService = $di->get('fake');
```

Please note, serializing won't work with closures. Serializing a container with following throws an exception.

```php
$di->params[VendorClass::class] = [
'param' => $di->lazy(
function () {
return new VendorParamClass();
}
),
];
```

## Scan for classes and annotations

The `ContainerConfigClassScanner` scans the passed directories for classes and annotations. You will need that if you
want to [modify the container using attributes](attributes.md#modify-the-container-using-attributes). The classes inside
the passed namespaces will be compiled into blueprints, making sure all the required meta-data is there to create an
instance of the class.

```php
$builder = new ContainerBuilder();
$config_classes = [
new \MyApp\Config1,
new \MyApp\Config2,
new ContainerConfigClassScanner(
[$rootDir . '/app/src'], // these directories should be scanned for classes and annotations
['MyApp\\'], // classes inside these namespaces should be compiled
)
];

$di = $builder->newCompiledInstance($config_classes);
```

When using the `ContainerConfigClassScanner`, make sure to serialize and cache the container output. If you do
not do that, directories will be scanned every instance of the container.

If your attribute cannot implement the `AttributeConfigInterface`, e.g. the attribute is defined in an external package,
you can pass a map with the class name of the attribute and an implementation of `AttributeConfigInterface`.

```php
use Aura\Di\AttributeConfigInterface;

new ContainerConfigClassScanner(
[$rootDir . '/app/src'], // these directories should be scanned for classes and annotations
['MyApp\\'], // classes inside these namespaces should be compiled,
[
Symfony\Component\Routing\Attribute\Route::class => new SymfonyRouteAttributeConfig()
]
)

class SymfonyRouteAttributeConfig implements AttributeConfigInterface
{
public function define(Container $di, \ReflectionAttribute $attribute, \Reflector $annotatedTo): void
{
if ($annotatedTo instanceof ReflectionMethod) {
$route = $attribute->newInstance();
$di->values['routes'][] = new Symfony\Component\Routing\Route(
// ...
);
}
}
}
```

## Compiled objects inside the container

There might be other objects that you want to compile before serializing the container. A good example might be a
router. All routes might be compiled into a performant route dispatcher, if your routing package supports this.

When creating a compiled instance you can pass config classes that implement the `ContainerCompileInterface` to the
`newCompiledInstance` and `configureCompiledInstance` methods of the _ContainerBuilder_. This
interface is an extension of the `ContainerConfigInterface` and has a single method `compile(Container $di): void`.
That method is executed after the `define(Container $di): void` method from the `ContainerConfigInterface` and just
before the container is compiled.

An implementation might look as follows:

```php
use Aura\Di\Attribute\Value;
use Aura\Di\ContainerCompileInterface;

class RouterContainerConfig implements ContainerCompileInterface
{
public function define(Container $di): void
{
$container->set('router.factory', $container->lazyNew(MyRouterFactory::class));
}

public function compile(Container $di): void
{
$container->set('router', $container->get('router.factory')->compile());
}

public function modify(Container $di): void
{
}
}

class MyRouterFactory {
public function __construct(
#[Value('routes')]
private array $routes
) {
}

public function compile(): Router
{
$router = new Router();
foreach ($this->routes as $route) {
$router->addRoute($route);
}
$router->compile();
return $router;
}
}
```
36 changes: 36 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,39 @@ $object = $di->newInstance('Vendor\Package\ClassName');
> N.b.: The _Container_ locks itself once a new instance is produced; this ensures that the _Container_ configuration cannot be modified once objects have been created.
However, this is a relatively naive way to create objects with the _Container_. It is better to specify the various constructor parameters, setter methods, and so on, and let the _Container_ inject those values for us only when the object is used as a dependency for something else.

## Full-featured instantiation

A full-featured container can use [attributes](attributes.md) for injection and container modification. Moreover, for
maximum performance, we would have to compile the container, serialize it and save it to a cache layer like the filesystem.
Subsequent processes unserialize the

```php
use Aura\Di\ContainerBuilder;
use Aura\Di\ContainerConfigClassScanner;
use Aura\Di\Resolver\ResolverFactory;

$serializedContainerFile = '/var/compiled.ser';
$config_classes = [
new \MyApp\Config1,
new \MyApp\Config2,
new ContainerConfigClassScanner(
[$rootDir . '/app/src'], // these directories should be scanned for classes and annotations
['MyApp\\'], // classes inside these namespaces should be compiled
)
];

if (file_exists($serializedContainerFile)) {
$di = \unserialize(file_get_contents($serializedContainerFile));
} else {
$builder = new ContainerBuilder();
$di = $builder->newCompiledInstance($config_classes);

$serialized = \serialize($di);
file_put_contents($serializedContainerFile, $serialized); // atomic for concurrency
}

$di = $builder->configureCompiledInstance($di, $config_classes);
```

From this point on you can call `newInstance` or `get` the container.
Loading

0 comments on commit c5c7176

Please sign in to comment.