Строим свой маленький DIC(Dependency Injection Container)

TLDR

Суть контейнера это некий обьект, который для создания и резолвинга зависимостей использует Reflection API

Проблема управления многими зависимостями

Откуда взялся этот контейнер, этот DI ? Современные программы построены как лего. Большие компоненты состоят из маленьких,те еще из более маленьких. И управлять этой стуктурой неудобно. Тут на сцену выходят DI и DI контейнер. Что такое DI? Посмотрим вики

Внедрение зависимости (англ. Dependency injection, DI) — процесс предоставления внешней зависимости программному компоненту

Что такое DIC? Соответсвенно это контейнер, где лежат эти зависимости, и этот контейнер както помогает управлять зависимостями. Вроде бы все понятно,но возникает куча вопросов Какие зависимости? Для чего? Как происходит это управление?

Небольшой пример, есть у нас магазин с товарами.У этого магазина есть пользователи и этим пользователям в конце месяца приходит статистика их покупок.Т.е. гдето в приложении, у нас будет класс ReportService, который будет генерировать этот репорт, и отправлять по email. Этот класс зависит от 3 классов UserRepository, ProductRepository ,EmailSender т.е. надо написать

class ReportService{
    __construct(UserRepository $userRepository, ProductRepository $productRepository, EmailSender $emailSender){
        $this->userRepository = $userRepository;
        $this->productRepository = $productRepository;
        $this->emailSender = $emailSender;
    }

}
$reportService = new ReportService(
    new UserRepository(),
    new ProductRepository(),
    new EmailSender()));
$reportService->send($userId);

Как видим код создания ReportService достаточно громоздкий и неудобный. Вручную с этим разбираться сложно, какие куда зависимости, что в каком порядке передавать. Пусть за нас это сделает некий обьект.

$report = $container->make(ReportService::class)
// создаст экземпляр ReportService и подставит зависимости

Также было бы неплохо дать возможность регистрировать классы в контейнере, для этого нам нужен метод register

$container->register(ReportService::class);

Container

Весь код приведенный в статье можно скачать с github tinydi

git clone git@github.com:R11baka/tinydi.git
cd tinydi;composer install
git checkout first_iteration // переключаемся на тег

Слона надо перевозить в лифте по частям. Поэтому начнем с чегото очень простого, а именно с создания класса без аргументов.В этом нам помогут юнит тесты.Исходный код тестов можно увидеть ContainerTest.Поэтому, для начала напишем тест

class ContainerTest extends TestCase
{
    /**
     * @test
     */
    public function register_simple_class()
    {
        $container = new Container();
        $container->register(FooController::class);
        $controller = $container->make(FooController::class);
        $this->assertInstanceOf(FooController::class, $controller);
    }
}

Тест конечно же работать не будет, поэтому создаем класс Container, добавляем в него методы register и make А вот и код этого контейнера

class Container
{
    private array $services = [];

    public function register($className)
    {
        $this->services[$className] = $className;
    }

    public function make($className)
    {
        if (isset($this->services[$className])) {
            return new $className();
        }
        throw new \InvalidArgumentException("$className not found");
    }
}

Запускаем unit тесты

./vendor/bin/phpunit

OK (2 tests, 2 assertions)

и видим тесты пройдены, класс создан Пока, что абсолютно ничего сложного и абсолютно бесполезно, так как метод make, может создавать только простые классы, без аргументов.Надо както его научить создавать классы с аргументами.И поможет нам в этом Reflection API

Reflection API

Для начала научимся создавать классы без аргументов с помощью Reflection, а потом попробуем создать класс с типизированными аргументами.

Конструктор без аргументов

В Reflection API, будем использовать

  • ReflectionClass дающий информацию о классе
  • Метод isInstantiable класса ReflectionClass определяющий можно ли создать класс.
  • Метод getConstructor ReflectionClass возвращающий конструктор класса.
  • Метод getNumberOfParameters возвращающий количество параметров функции

Пример использования reflection api на php sandbox

class Controller{};
class ComplexController{
    private $config;
    public function __construct($config){
        $this->config = $config;
    }
}

$simpleControllerReflect = new \ReflectionClass(Controller::class);
var_dump("Simple controller constructor =>",$simpleControllerReflect->getConstructor());
/* 
string(32) "Simple controller constructot =>"
NULL
*/
$complexControllerReflect = new \ReflectionClass(ComplexController::class);
var_dump("Complex controller constructor =>",$complexControllerReflect->getConstructor());
/*
string(33) "Complex controller constructor =>"
object(ReflectionMethod)#3 (2) {
  ["name"]=>
  string(11) "__construct"
  ["class"]=>
  string(17) "ComplexController"
}

*/

Исходный код можно глянуть здесь

git checkout base_reflection_api

Обновленная версия контейнера.

<?php
namespace App;

class Container
{
    private array $services = [];

    public function register($itemName)
    {
        $this->services[$itemName] = $itemName;
    }

    public function make($itemName)
    {
        if (isset($this->services[$itemName])) {
            $className = $this->services[$itemName];
            return $this->resolveClass($className);
        }
        throw new \InvalidArgumentException("Item with $itemName not found");
    }

    private function resolveClass($className)
    {
        $reflectionClass = new \ReflectionClass($className);
        if ($reflectionClass->isInstantiable() === false) {
            throw new \InvalidArgumentException("Can't create $className.Not instantiable");
        }
        $constructorReflection = $reflectionClass->getConstructor();
        if ($constructorReflection === null) {
            return new $className;
        }
        if ($constructorReflection->getNumberOfParameters() === 0) {
            return new $className;
        }
        throw  new \InvalidArgumentException("Can't create $className");
    }
}

Добавился метод resolveClass, который получает на вход имя класса, и пытается его создать. Из интересного есть метод

$constructorReflection = $reflectionClass->getConstructor();

если $constructor null или у него нет параметров, то можем спокойно создать через new. В тестах появился кейс конструктора без аргументов.

    /**
     * @test
     */
    public function make_class_with_zero_arguments()
    {
        $this->container->register(FooControllerEmptyConstructor::class);
        $controller = $this->container->make(FooControllerEmptyConstructor::class);
        $this->assertInstanceOf(FooControllerEmptyConstructor::class, $controller);
    }

Конструктор с аргументами

С созданием класса без аргументов разобрались, по сути мы вызываем new $className и все.Теперь надо понять как создать класс с аргументами. Для этого нам поможет

Исходный код лежит здесь

git checkout reflection_api 

Пишем сначала простой тест, который естественно ломается

     /**
     * @test
     */
    public function make_class_with_arguments()
    {
        $this->container->register(ClassWithArgumentsInConstructor::class);
        $controller = $this->container->make(ClassWithArgumentsInConstructor::class);
        $this->assertInstanceOf(ClassWithArgumentsInConstructor::class, $controller);
    }

    /**
     * @test
     */
    public function make_class_with_untyped_args()
    {
        $this->expectException(InvalidArgumentException::class);
        $this->container->register(ClassWithUntypedArgs::class);
        $this->container->make(ClassWithUntypedArgs::class);
    }

ClassWithArgumentsInConstructor выглядит так

class ClassWithArgumentsInConstructor
{
    private FooController $controller;

    /**
     * ClassWithArgumentsInConstructor constructor.
     */
    public function __construct(FooController $controller)
    {
        $this->controller = $controller;
    }
}

А потом и обновленный код resolveClass

 private function resolveClass($className)
    {
        $reflectionClass = new \ReflectionClass($className);
        if ($reflectionClass->isInstantiable() === false) {
            throw new \InvalidArgumentException("Can't create $className.Not instantiable");
        }
        $constructorReflection = $reflectionClass->getConstructor();
        if ($constructorReflection === null) {
            return new $className;
        }
        if ($constructorReflection->getNumberOfParameters() === 0) {
            return new $className;
        }
        $params = $constructorReflection->getParameters();
        $constructorArgs = [];
        foreach ($params as $v) {
            $paramClass = $v->getClass();
            if ($paramClass !== null) {
                $this->register($paramClass->name);
                $constructorArgs [] = $this->make($paramClass->name);
            } else {
                throw new \InvalidArgumentException("Can't resolve parameter for $className");
            }
        }
        return $reflectionClass->newInstanceArgs($constructorArgs);
    }

Здесь мы получаем аргументы конструктора, извлекаем подсказу типа с помощью getClass, регистрируем в контейнере и рекурсивно создаем.

Добавление интерфейсов в DI контейнер

Исходный код лежит здесь

git checkout 4_add_impl

Теперь вернемся к классу ReportService вначале.Этот класс зависит от конкретных реализаций, не от абстракций. Поэтому хотелось чтоб DI контейнер, както понимал,что если от него хотят например EmailSenderInterface, то он должен подсунуть экземпляр класса EmailSender.И класс будет выглядеть так

class ReportService{
    __construct(UserRepositoryInterface $userRepository,ProductRepositoryInterface $productRepository,EmailSenderInterface $emailSender){
        $this->userRepository = userRepository;
        $this->productRepository = productRepository;
        $this->emailSender = $emailSender;
    }

}

Чтобы это реализовать, нам надо внести небольшие изменения в метод register, он принимал вторым параметром реализацию класса.

public function register_interface()
{
    $this->container->register(TestInterface::class, Test::class);
    $instanceOfTest = $this->container->make(TestInterface::class);
    $this->assertInstanceOf(Test::class, $instanceOfTest);
}

и модифицированный метод register

 public function register($abstraction, $implementation = null)
    {
        if ($implementation === null) {
            $implementation = $abstraction;
        }
        $this->services[$abstraction] = $implementation;
    }

Наконец-то ReportService

Исходный код лежит здесь

git checkout 6_register_report_service

Теперь попробуем написать тест с созданием класса ReportService

     /**
     * @test
     */
    public function test_report_service()
    {
        $this->container->register(ReportService::class);
        $class = $this->container->make(ReportService::class);
        $this->assertInstanceOf(ReportService::class, $class);
    }

Запускаем и тест падает с

InvalidArgumentException : Can't create App\Tests\TestClasses\UserRepositoryInterface.Not instantiable

Не зарегистрировали в контейнере соответствие между интерфейсом и реализацией Обновляем тесты

    /**
     * @test
     */
    public function test_report_service()
    {
        $this->container->register(ReportService::class);
        $this->container->register(UserRepositoryInterface::class, UserRepository::class);
        $this->container->register(ProductRepositoryInterface::class, ProductRepository::class);
        $this->container->register(EmailSenderInterface::class, EmailSender::class);
        $class = $this->container->make(ReportService::class);
        $this->assertInstanceOf(ReportService::class, $class);
    }

Запускаем phpunit,опять падает c той-же ошибкой.

InvalidArgumentException : Can't create App\Tests\TestClasses\UserRepositoryInterface.Not instantiable

Метод register перетирает соответсвие между абстракцией и реализацией. Добавляем метод registerIfNotExists. Запускаем тесты и все проходит. В результате получилось написать маленький DI контейнер. Еще можно добавить кеширование,строковые параметры в констукторы, поддержку замыканий.

References