Hello to all!
This is a free translation of the article
How to Build Your Own Dependency Injection Container .
Since This is my first translation for Habr, please indicate errors, inaccuracies.
How to create your own Dependency Injection Container.
The search for “dependency injection container” on
packagist currently yields over 95 result pages. It is safe to say that this particular “wheel” has already been invented.
However, not a single chef learned to cook using only prepared food. Also, no developer has ever learned how to program using only ready-made code.
')
In this article, we are going to learn how to make a simple
dependency injection container package. All the code in the article plus PHPDoc annotations and unit tests with 100% coverage are available on
GitHub . All this is also added to
Packagist .
Planning our Dependency Injection Container
Let us begin by planning what we want our container to do.
A good start is to divide the
“Dependency Injection Container” into two roles -
“Dependency Injection” and
“Container” .
The two most common methods for performing dependency
injection through
constructor injection or
setter injection . This is the transmission of the dependency class through the constructor as an argument or method call. If our container will be able to create an instance and contain services, it will be able to implement both of these methods.
To be a container, it must be able to store and retrieve instances of services. This is a rather trivial task compared to creating a service, but it still requires some consideration. The
container-interop package provides a set of interfaces that a container can implement. The main interface is
ContainerInterface , which defines two methods — one to get the service, the other to check if the service is defined.
interface ContainerInterface { public function get($id); public function has($id); }
Experience Other Dependency Injection Containers
Symfony Dependency Injection Container allows us to define services in various ways. In
YAML , the configuration for a container might look like this:
parameters: # ... mailer.transport: sendmail services: mailer: class: Mailer arguments: ["%mailer.transport%"] newsletter_manager: class: NewsletterManager calls: - [setMailer, ["@mailer"]]
Symfony's way of breaking a container's configuration into a configuration of parameters and services is very useful. This allows secret application data, such as API keys, encryption keys, and authorization tokens, to be stored parameters in files that are excluded from the source code of the repository.
In PHP, the same configuration of the
Symfony Dependency Injection component would look like this:
use Symfony\Component\DependencyInjection\Reference; // ... $container->setParameter('mailer.transport', 'sendmail'); $container ->register('mailer', 'Mailer') ->addArgument('%mailer.transport%'); $container ->register('newsletter_manager', 'NewsletterManager') ->addMethodCall('setMailer', array(new Reference('mailer')));
Using the
Reference object in a call to the
setMailer method, the dependency injection logic can detect that this value should not be passed directly, and replace it with a service referenced in the container. This allows for both PHP values ​​and other services to be easily embedded in the service without confusion.
Beginning of work
The first thing we need to do is create a directory and
composer.json file that
Composer will use for our class autoloader to work. This entire file is currently a
SitePoint \ Container namespace map to the
src directory.
{ "autoload": { "psr-4": { "SitePoint\\Container\\": "src/" } }, }
Further, as we are going to do, our container will implement the
container-interop interface, we need the composer to load them and add them to our
composer.json file:
composer require container-interop/container-interop
Along with the main interface
ContainerInterface , the
container-interop package also defines two interfaces for exceptions. The first is for general exceptions when creating a service and the other for exceptions when the requested service was not found. We will also add another exception to this list, for those situations when the requested parameter was not found.
Since we do not need to add any functionality beyond the scope of the proposed PHP
Exception core class, these classes are pretty simple. While they may seem meaningless, such a partition makes it easier for us to catch and process them separately.
Create a folder
src and in it the following files
src / Exception / ContainerException.php ,
src / Exception / ServiceNotFoundException.php and
src / Exception / ParameterNotFoundException.php respectively:
<?php namespace SitePoint\Container\Exception; use Interop\Container\Exception\ContainerException as InteropContainerException; class ContainerException extends \Exception implements InteropContainerException {}
<?php namespace SitePoint\Container\Exception; use Interop\Container\Exception\NotFoundException as InteropNotFoundException; class ServiceNotFoundException extends \Exception implements InteropNotFoundException {}
<?php namespace SitePoint\Container\Exception; class ParameterNotFoundException extends \Exception {}
Container links
The symfony
Reference class, discussed earlier, allowed the library to distinguish PHP values ​​for immediate use and arguments that need to be replaced by other services in the container.
Let's borrow this idea and create two classes for references to parameters and services. Since both of these classes will store the values ​​of objects simply by the name of the resource to which they refer, it makes sense to use the base abstract class. Thus, we should not write the same code twice.
Create the following files
src / Reference / AbstractReference.php ,
src / Reference / ServiceReference.php and
src / Reference / ParameterReference.php respectively:
<?php namespace SitePoint\Container\Reference; abstract class AbstractReference { private $name; public function __construct($name) { $this->name = $name; } public function getName() { return $this->name; } }
<?php namespace SitePoint\Container\Reference; class ServiceReference extends AbstractReference {}
<?php namespace SitePoint\Container\Reference; class ParameterReference extends AbstractReference {}
Container class
It's time to create our container. We are going to start with the basic schema of our container class, and we will add methods to it as we go.
The main idea is to take two arrays in the constructor of our container. The first array should contain the service definition, and the second parameter definition.
In
src / Container.php put the following code:
<?php namespace SitePoint\Container; use Interop\Container\ContainerInterface as InteropContainerInterface; class Container implements InteropContainerInterface { private $services; private $parameters; private $serviceStore; public function __construct(array $services = [], array $parameters = []) { $this->services = $services; $this->parameters = $parameters; $this->serviceStore = []; } }
All we have done here is to implement the
ContainerInterface interface from
container-interop and load the definitions into properties that can be accessed later. We also created the
serviceStore property and initialized it with an empty array. When the container is asked to create services, we will store them in this array so that they can be restored later without the need to re-create them.
Now let us start writing the methods declared in
container-interop . Let's start with
get ($ name) , add the following method to the class:
use SitePoint\Container\Exception\ServiceNotFoundException; // ... public function get($name) { if (!$this->has($name)) { throw new ServiceNotFoundException('Service not found: '.$name); } if (!isset($this->serviceStore[$name])) { $this->serviceStore[$name] = $this->createService($name); } return $this->serviceStore[$name]; } // ...
Be sure to add
use at the beginning of the file. Our
get ($ name) method is a simple check for the presence of a service in the container. If it is not, the
ServiceNotFoundException that we created earlier will be thrown. If it is, returns it. Creates it and saves it to the repository if it has not already been created.
While we are in it, we should create a method to retrieve the parameters from the container. Accepting the parameters committed to the constructor from an n-dimensional associative array, we need a way to cleanly access any element within the array using a single line. A simple way to do this is to use the dot as a separator, so that the string
foo.bar refers to the key
bar in the key
foo from the root of the parameter array.
use SitePoint\Container\Exception\ParameterNotFoundException; // ... public function getParameter($name) { $tokens = explode('.', $name); $context = $this->parameters; while (null !== ($token = array_shift($tokens))) { if (!isset($context[$token])) { throw new ParameterNotFoundException('Parameter not found: '.$name); } $context = $context[$token]; } return $context; } // ...
Now we used a couple of methods that we haven’t written yet. The first of them is
has ($ name) , which is declared in
container-interop . This is a fairly simple method and it just needs to check if the definition array provided to the constructor contains the
$ name service.
// ... public function has($name) { return isset($this->services[$name]); } // ...
Another method we need to write is
createService ($ name) . This method will use the definitions provided to create the service. Since we do not want this method to be called outside the container, we will make it private.
The first thing to do in this method is some reasonable checks.
For each declared service, we require an array containing the
class key and the optional
arguments and
calls keys. They will be used to implement the designer and setter, respectively. We can also add anti-looping protection by checking if we have already tried to create a service.
If the
arguments key exists, we want to convert the array of argument definitions into a PHP array of values ​​that can be passed to the constructor. In order to do this, we need to convert the directory of objects that we previously declared into the values ​​referenced in the container. At the moment we will use this logic in the method
resolveArguments ($ name, array $ argumentDefinitons) .
We use the
ReflectionClass :: newInstanceArgs () method to create a service using the arguments array. This is the introduction of the designer.
If the key
calls exists, we want to use an array of
call definitions and apply it to the service we just created. Again, we will use the logic in a separate method, defined as
initializeService ($ service, $ name, array $ callDefinitions) . This is the introduction of the setter.
use SitePoint\Container\Exception\ContainerException; // ... private function createService($name) { $entry = &$this->services[$name]; if (!is_array($entry) || !isset($entry['class'])) { throw new ContainerException($name.' service entry must be an array containing a \'class\' key'); } elseif (!class_exists($entry['class'])) { throw new ContainerException($name.' service class does not exist: '.$entry['class']); } elseif (isset($entry['lock'])) { throw new ContainerException($name.' service contains a circular reference'); } $entry['lock'] = true; $arguments = isset($entry['arguments']) ? $this->resolveArguments($name, $entry['arguments']) : []; $reflector = new \ReflectionClass($entry['class']); $service = $reflector->newInstanceArgs($arguments); if (isset($entry['calls'])) { $this->initializeService($service, $name, $entry['calls']); } return $service; } // ...
It remains for us to create two final methods. The first is to convert the declaration arguments array to a PHP array of values. To do this, you need to replace the
ParameterReference and
ServiceReference objects
with the corresponding parameters and services from the container.
use SitePoint\Container\Reference\ParameterReference; use SitePoint\Container\Reference\ServiceReference; // ... private function resolveArguments($name, array $argumentDefinitions) { $arguments = []; foreach ($argumentDefinitions as $argumentDefinition) { if ($argumentDefinition instanceof ServiceReference) { $argumentServiceName = $argumentDefinition->getName(); $arguments[] = $this->get($argumentServiceName); } elseif ($argumentDefinition instanceof ParameterReference) { $argumentParameterName = $argumentDefinition->getName(); $arguments[] = $this->getParameter($argumentParameterName); } else { $arguments[] = $argumentDefinition; } } return $arguments; }
The latter method injects the setter into an instance of the service object. To do this, you must iterate through the array of definitions when calling the method. The
method key is used to specify a method, and the optional
arguments key can be used to provide arguments to this method. We can use the method we just wrote to translate its arguments to PHP values.
private function initializeService($service, $name, array $callDefinitions) { foreach ($callDefinitions as $callDefinition) { if (!is_array($callDefinition) || !isset($callDefinition['method'])) { throw new ContainerException($name.' service calls must be arrays containing a \'method\' key'); } elseif (!is_callable([$service, $callDefinition['method']])) { throw new ContainerException($name.' service asks for call to uncallable method: '.$callDefinition['method']); } $arguments = isset($callDefinition['arguments']) ? $this->resolveArguments($name, $callDefinition['arguments']) : []; call_user_func_array([$service, $callDefinition['method']], $arguments); } } }
And now we have a ready
dependency injection container ! To see usage examples, check out the
repository on GitHub .
Conclusion
We've learned how to create a simple dependency
injection container , but there are a lot of containers with great features that we don’t have yet.
Some dependency injection containers, such as
PHP-DI and
Aura.DI, provide a feature called automatic connection. This is where the container guesses which services from the container should be injected into others. To do this, they use the reflection API to search for information about the parameters of the designer.