📜 ⬆️ ⬇️

How to create your own Dependency Injection Container

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.

Source: https://habr.com/ru/post/278049/


All Articles