Рубрики
Uncategorized

Создание собственного контейнера для внедрения зависимостей в PHP

Внедрение зависимостей может быть сложной концепцией для понимания на ранних стадиях. Даже когда ты – ты… Помеченный php.

Внедрение зависимостей может быть сложной концепцией для понимания на ранних стадиях. Даже когда вы используете его постоянно, это часто может показаться волшебством. Однако на самом деле все не так сложно, как только вы действительно разберетесь в деталях, и создание собственного контейнера – хороший способ узнать больше о том, как он работает и как его использовать.

В этом уроке я расскажу вам о создании простого, минимального контейнера для внедрения зависимостей с использованием PHPSpec как части рабочего процесса TDD. Хотя конечный результат не обязательно является тем, что я был бы рад использовать в производственной среде, этого достаточно, чтобы понять основную концепцию и сделать ее менее похожей на черный ящик. Наш контейнер будет называться Эрни (если вы хотите знать, почему, это отсылка к видеоигре эпохи 90-х, в которой был персонаж, основанный на Эрике Кантоне, по имени Эрни Контейнер).

Первое, что нам нужно сделать, это настроить наши зависимости. Наш контейнер будет реализовывать PSR-11, поэтому нам нужно включить интерфейс, который определяет это. Мы также будем использовать PHP CodeSniffer для обеспечения качества кода и PHPSpec для тестирования. Ваш composer.json должен выглядеть примерно так:

{
    "name": "matthewbdaly/ernie",
    "description": "Simple DI container",
    "type": "library",
    "require-dev": {
        "squizlabs/php_codesniffer": "^3.3",
        "phpspec/phpspec": "^5.0",
        "psr/container": "^1.0"
    },
    "license": "MIT",
    "authors": [
        {
            "name": "Matthew Daly",
            "email": "450801+matthewbdaly@users.noreply.github.com"
        }
    ],
    "require": {},
    "autoload": {
        "psr-4": {
            "Matthewbdaly\\Ernie\\": "src/"
        }
    }
}

Нам также нужно поместить это в наш phpspec.yml файл:

suites:
    test_suite:
        namespace: Matthewbdaly\Ernie
        psr4_prefix: Matthewbdaly\Ernie

Сделав это, мы можем приступить к работе над нашей реализацией.

Спецификация PSR-11 определяет два интерфейса для исключений, которые мы реализуем, прежде чем перейти к самому контейнеру. Первым из них является Интерфейс Psr\Container\ContainerException . Выполните следующую команду, чтобы создать базовую спецификацию для исключения:

$ vendor/bin/phpspec desc Matthewbdaly/Ernie/Exceptions/ContainerException

Сгенерированная спецификация для него по адресу spec/Exceptions/ContainerExceptionSpec.php будет выглядеть примерно так:

shouldHaveType(ContainerException::class);
    }
}

Этого недостаточно для наших нужд. Наше исключение также должно реализовывать два интерфейса:

  • Бросаемый
  • Интерфейс Psr\Container\ContainerException

Первое может быть решено путем наследования от Исключения , в то время как второе не требует никаких дополнительных методов. Давайте расширим нашу спецификацию, чтобы проверить наличие этих:

shouldHaveType(ContainerException::class);
    }

    function it_implements_interface()
    {
        $this->shouldImplement('Psr\Container\ContainerExceptionInterface');
    }

    function it_implements_throwable()
    {
        $this->shouldImplement('Throwable');
    }
}

Теперь запустите спецификацию, и PHPSpec сгенерирует для вас шаблонное исключение:

$ vendor/bin/phpspec run
Matthewbdaly/Ernie/Exceptions/ContainerException                                
  11 - it is initializable
      class Matthewbdaly\Ernie\Exceptions\ContainerException does not exist.

Matthewbdaly/Ernie/Exceptions/ContainerException                                  
  16 - it implements interface
      class Matthewbdaly\Ernie\Exceptions\ContainerException does not exist.

Matthewbdaly/Ernie/Exceptions/ContainerException                                
  21 - it implements throwable
      class Matthewbdaly\Ernie\Exceptions\ContainerException does not exist.

                                      100% 3
1 specs
3 examples (3 broken)
23ms

  Do you want me to create `Matthewbdaly\Ernie\Exceptions\ContainerException`   
  for you?                                                                      
                                                                         [Y/n]
y
Class Matthewbdaly\Ernie\Exceptions\ContainerException created in /home/matthew/Projects/ernie-clone/src/Exceptions/ContainerException.php.

Matthewbdaly/Ernie/Exceptions/ContainerException                                
  16 - it implements interface
      expected an instance of Psr\Container\ContainerExceptionInterface, but got
      [obj:Matthewbdaly\Ernie\Exceptions\ContainerException].

Matthewbdaly/Ernie/Exceptions/ContainerException                                
  21 - it implements throwable
      expected an instance of Throwable, but got
      [obj:Matthewbdaly\Ernie\Exceptions\ContainerException].

            33% 66% 3
1 specs
3 examples (1 passed, 2 failed)
36ms

Это терпит неудачу, но мы ожидаем этого. Нам нужно обновить наше исключение, чтобы расширить базовое исключение PHP, и реализовать Psr\Контейнер\ContainerExceptionИнтерфейс . Давайте сделаем это сейчас:

Давайте повторим спецификацию:

$ vendor/bin/phpspec run
                                      100% 3
1 specs
3 examples (3 passed)
24ms

Второе исключение, которое нам нужно реализовать, – это Интерфейс Psr\Container\NotFoundException и это похожая история. Выполните следующую команду для создания спецификации:

$ vendor/bin/phpspec desc Matthewbdaly/Ernie/Exceptions/NotFoundException

Опять же, в спецификацию необходимо внести изменения, чтобы убедиться, что она доступна и реализует требуемый интерфейс:

shouldHaveType(NotFoundException::class);
    }

    function it_implements_interface()
    {
        $this->shouldImplement('Psr\Container\NotFoundExceptionInterface');
    }

    function it_implements_throwable()
    {
        $this->shouldImplement('Throwable');
    }
}

Для краткости я опустил вывод, но если вы запустите vendor/bin/phpspec, запустите вы увидите, что он завершится неудачей из-за того, что сгенерированный класс не реализует требуемые интерфейсы. Изменить src/Исключения/NotFoundException следующим образом:

Запуск vendor/bin/phpspec run теперь должен увидеть, что он прошел. Теперь давайте перейдем к классу контейнеров…

Выполните следующую команду, чтобы создать спецификацию контейнера:

$ vendor/bin/phpspec desc Matthewbdaly/Ernie/Container

Однако спецификации, сгенерированной по умолчанию, недостаточно. Нам нужно проверить, что он реализует необходимый интерфейс:

shouldHaveType(Container::class);
    }

    function it_implements_interface()
    {
        $this->shouldImplement('Psr\Container\ContainerInterface');
    }
}

Теперь, если мы запустим PHPSpec, мы создадим наш класс:

$ vendor/bin/phpspec run
Matthewbdaly/Ernie/Container                                                    
  11 - it is initializable
      class Matthewbdaly\Ernie\Container does not exist.

Matthewbdaly/Ernie/Container                                                      
  16 - it implements interface
      class Matthewbdaly\Ernie\Container does not exist.

                            75% 25% 8
3 specs
8 examples (6 passed, 2 broken)
404ms

  Do you want me to create `Matthewbdaly\Ernie\Container` for you?              
                                                                         [Y/n] 
y
Class Matthewbdaly\Ernie\Container created in /home/matthew/Projects/ernie-clone/src/Container.php.

Matthewbdaly/Ernie/Container                                                      
  16 - it implements interface
      expected an instance of Psr\Container\ContainerInterface, but got
      [obj:Matthewbdaly\Ernie\Container].

                                 87% 12% 8
3 specs
8 examples (7 passed, 1 failed)
40ms

Теперь, как мы видим, этот класс не реализует интерфейс. Давайте исправим это:

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

$ vendor/bin/phpspec run
✘ Fatal error happened while executing the following 
    it is initializable 
    Class Matthewbdaly\Ernie\Container contains 2 abstract methods and must therefore be declared abstract or implement the remaining methods (Psr\Container\ContainerInterface::get, Psr\Container\ContainerInterface::has) in /home/matthew/Projects/ernie-clone/src/Container.php on line 7 

Если вы используете редактор или среду разработки, которая позволяет автоматически реализовывать интерфейс, вы можете запустить ее, чтобы добавить необходимые методы. Я использую PHP Act или с Neovim и использовал опцию в меню Преобразования для реализации контракта:

Запуск vendor/bin/phpspec run теперь должен выполнить проверку спецификации, но методы на самом деле еще ничего не делают. Если вы прочтете спецификацию для PSR-11, вы увидите, что has() возвращает логическое значение, указывающее, может ли класс быть создан или нет, в то время как get() либо вернет экземпляр указанного класса, либо выдаст исключение. Мы добавим спецификации, которые проверяют, что встроенные классы могут быть возвращены обоими, а неизвестные классы отображают ожидаемое поведение. Мы сделаем и то, и другое одновременно, потому что в обоих случаях функциональность для фактического разрешения требуемого класса будет отложена до одного метода разрешителя, и в результате эти методы не будут выполнять все это:

    function it_has_simple_classes()
    {
        $this->has('DateTime')->shouldReturn(true);
    }

    function it_does_not_have_unknown_classes()
    {
        $this->has('UnknownClass')->shouldReturn(false);
    }

    function it_can_get_simple_classes()
    {
        $this->get('DateTime')->shouldReturnAnInstanceOf('DateTime');
    }

    function it_returns_not_found_exception_if_class_cannot_be_found()
    {
        $this->shouldThrow('Matthewbdaly\Ernie\Exceptions\NotFoundException')
            ->duringGet('UnknownClass');
    }

Эти тесты подтверждают, что:

  • has() возвращает истину при вызове с всегда присутствующим датой и временем классом
  • имеет() возвращает ложь для неопределенного Неизвестный класс
  • get() успешно создает экземпляр DateTime
  • get() выдает исключение, если вы пытаетесь создать экземпляр неопределенного Неизвестный класс

Выполнение спецификаций приведет к появлению ошибок:

$ vendor/bin/phpspec run
Matthewbdaly/Ernie/Container                                                      
  21 - it has simple classes
      expected true, but got null.

Matthewbdaly/Ernie/Container                                                    
  26 - it does not have unknown classes
      expected false, but got null.

Matthewbdaly/Ernie/Container                                                    
  31 - it can get simple classes
      expected an instance of DateTime, but got null.

Matthewbdaly/Ernie/Container                                                    
  36 - it returns not found exception if class cannot be found
      expected to get exception / throwable, none got.

                         66% 33% 12
3 specs
12 examples (8 passed, 4 failed)
98ms

Давайте заполним эти пустые методы:

resolve($id);
        return $this->getInstance($item);
    }

    /**
     * {@inheritDoc}
     */
    public function has($id)
    {
        try {
            $item = $this->resolve($id);
        } catch (NotFoundException $e) {
            return false;
        }
        return $item->isInstantiable();
    }

    private function resolve($id)
    {
        try {
            return (new ReflectionClass($id));
        } catch (ReflectionException $e) {
            throw new NotFoundException($e->getMessage(), $e->getCode(), $e);
        }
    }

    private function getInstance(ReflectionClass $item)
    {
        return $item->newInstance();
    }
}

Как вы можете видеть, оба имеют() и get() методы должны преобразовывать идентификатор строки в фактический класс, чтобы общая функциональность сохранялась в частном методе, называемом resolve() . При этом используется API отражения PHP для преобразования имени класса в реальный класс. Мы передаем идентификатор строки в конструктор ReflectionClass , и метод resolve() либо вернет созданный экземпляр ReflectionClass , либо выдаст исключение.

Для непосвященных ReflectionClass позволяет вам размышлять над объектом, полное имя класса которого передается конструктору, чтобы взаимодействовать с этим классом программно. Методы, которые мы будем использовать, включают:

  • isInstantiable – подтверждает, может ли класс быть создан (например, черты и абстрактные классы не могут)
  • newInstance – создает новый экземпляр рассматриваемого элемента, если у него нет зависимостей в конструкторе
  • newInstanceArgs – создает новый экземпляр, используя аргументы, переданные в
  • getConstructor – позволяет получить информацию о конструкторе

API отражения довольно всеобъемлющий, и я бы рекомендовал ознакомиться с документацией, приведенной выше, если вы хотите узнать больше.

Для метода has() мы проверяем, что разрешенный класс может быть создан, и возвращаем результат этого. Для метода get() мы используем getInstance() для создания экземпляра элемента и возврата его, создавая исключение, если это не удается.

В своем текущем состоянии контейнер не позволяет вам устанавливать элемент. Чтобы быть полезными, нам нужно иметь возможность указать, что интерфейс или строка должны быть разрешены для данного класса, или для случаев, когда нам нужно передать скалярные параметры, такие как объект базы данных, чтобы указать, как должен создаваться конкретный экземпляр этого класса. С этой целью мы создадим новый set() общедоступный метод, который позволит установить зависимость. Вот пересмотренные спецификации, включая это:

shouldHaveType(Container::class);
    }

    function it_implements_interface()
    {
        $this->shouldImplement('Psr\Container\ContainerInterface');
    }

    function it_has_simple_classes()
    {
        $this->has('DateTime')->shouldReturn(true);
    }

    function it_does_not_have_unknown_classes()
    {
        $this->has('UnknownClass')->shouldReturn(false);
    }

    function it_can_get_simple_classes()
    {
        $this->get('DateTime')->shouldReturnAnInstanceOf('DateTime');
    }

    function it_returns_not_found_exception_if_class_cannot_be_found()
    {
        $this->shouldThrow('Matthewbdaly\Ernie\Exceptions\NotFoundException')
            ->duringGet('UnknownClass');
    }

    function it_can_register_dependencies()
    {
        $toResolve = new class {
        };
        $this->set('Foo\Bar', $toResolve)->shouldReturn($this);
    }

    function it_can_resolve_registered_dependencies()
    {
        $toResolve = new class {
        };
        $this->set('Foo\Bar', $toResolve);
        $this->get('Foo\Bar')->shouldReturnAnInstanceOf($toResolve);
    }

    function it_can_resolve_registered_invokable()
    {
        $toResolve = new class {
            public function __invoke() {
                return new DateTime;
            }
        };
        $this->set('Foo\Bar', $toResolve);
        $this->get('Foo\Bar')->shouldReturnAnInstanceOf('DateTime');
    }

    function it_can_resolve_registered_callable()
    {
        $toResolve = function () {
            return new DateTime;
        };
        $this->set('Foo\Bar', $toResolve);
        $this->get('Foo\Bar')->shouldReturnAnInstanceOf('DateTime');
    }

    function it_can_resolve_if_registered_dependencies_instantiable()
    {
        $toResolve = new class {
        };
        $this->set('Foo\Bar', $toResolve);
        $this->has('Foo\Bar')->shouldReturn(true);
    }
}

Для этого необходимо обработать довольно много сценариев, поэтому у нас есть несколько тестов. Они подтверждают, что:

  • Метод set() возвращает экземпляр класса контейнера, чтобы разрешить цепочку методов
  • Когда установлена зависимость, вызов get() возвращает экземпляр этого класса
  • Когда конкретный класс, имеющий набор магических методов __invoke() , передается в set() , он вызывается и возвращается ответ.
  • Если переданное значение является обратным вызовом, обратный вызов разрешается и возвращается ответ
  • Когда установлена зависимость, вызов has() для нее возвращает правильное значение

Обратите внимание, что мы используем анонимные классы для тестирования – я писал об этом раньше, и они очень полезны в этом контексте, потому что они позволяют нам создать простой встроенный класс для целей тестирования.

Выполнение спецификаций должно привести к тому, что нам будет предложено сгенерировать метод set() , а затем выполнить сбой:

$ vendor/bin/phpspec run
Matthewbdaly/Ernie/Container                                                    
  42 - it can register dependencies
      method Matthewbdaly\Ernie\Container::set not found.

Matthewbdaly/Ernie/Container                                                    
  49 - it can resolve registered dependencies
      method Matthewbdaly\Ernie\Container::set not found.

Matthewbdaly/Ernie/Container                                                    
  57 - it can resolve registered invokable
      method Matthewbdaly\Ernie\Container::set not found.

Matthewbdaly/Ernie/Container                                                    
  68 - it can resolve registered callable
      method Matthewbdaly\Ernie\Container::set not found.

Matthewbdaly/Ernie/Container                                                    
  77 - it can resolve if registered dependencies instantiable
      method Matthewbdaly\Ernie\Container::set not found.

                          70% 29% 17
3 specs
17 examples (12 passed, 5 broken)
316ms

  Do you want me to create `Matthewbdaly\Ernie\Container::set()` for you?       
                                                                         [Y/n]
y
  Method Matthewbdaly\Ernie\Container::set() has been created.

Matthewbdaly/Ernie/Container                                                    
  42 - it can register dependencies
      expected [obj:Matthewbdaly\Ernie\Container], but got null.

Matthewbdaly/Ernie/Container                                                    
  49 - it can resolve registered dependencies
      exception [exc:Matthewbdaly\Ernie\Exceptions\NotFoundException("Class Foo\Bar does not exist")] has been thrown.

Matthewbdaly/Ernie/Container                                                    
  57 - it can resolve registered invokable
      exception [exc:Matthewbdaly\Ernie\Exceptions\NotFoundException("Class Foo\Bar does not exist")] has been thrown.

Matthewbdaly/Ernie/Container                                                    
  68 - it can resolve registered callable
      exception [exc:Matthewbdaly\Ernie\Exceptions\NotFoundException("Class Foo\Bar does not exist")] has been thrown.

Matthewbdaly/Ernie/Container                                                    
  77 - it can resolve if registered dependencies instantiable
      expected true, but got false.

                          70% 11% 17% 17
3 specs
17 examples (12 passed, 2 failed, 3 broken)
90ms

Во-первых, нам нужно правильно настроить метод set() и определить свойство, содержащее сохраненные службы:

    private $services = [];

    public function set(string $key, $value)
    {
        $this->services[$key] = $value;
        return $this;
    }

Это исправляет первую спецификацию, но распознаватель необходимо изменить, чтобы обрабатывать случаи, когда идентификатор устанавливается вручную:

    private function resolve($id)
    {
        try {
            $name = $id;
            if (isset($this->services[$id])) {
                $name = $this->services[$id];
                if (is_callable($name)) {
                    return $name();
                }
            }
            return (new ReflectionClass($name));
        } catch (ReflectionException $e) {
            throw new NotFoundException($e->getMessage(), $e->getCode(), $e);
        }
    }

Это позволит нам разрешить классы, установленные с помощью set() . Однако мы также хотим разрешить любые вызываемые объекты, такие как обратные вызовы или классы, которые реализуют магический метод __invoke() , что означает, что иногда resolve() возвращает результат вызываемого объекта вместо экземпляра ReflectionClass . При таких обстоятельствах мы должны вернуть товар напрямую:

    public function get($id)
    {
        $item = $this->resolve($id);
        if (!($item instanceof ReflectionClass)) {
            return $item;
        }
        return $this->getInstance($item);
    }

Обратите внимание, что, поскольку метод __invoke() автоматически вызывается в любом конкретном классе, указанном во втором аргументе set() , разрешить классы, определяющие метод __invoke() , можно только в том случае, если они передаются в виде строковых представлений. Следующий сеанс психотерапии должен прояснить, что это значит:

>>> use Matthewbdaly\Ernie\Container;
>>> $c = new Container;
=> Matthewbdaly\Ernie\Container {#2307}
>>> class TestClass { public function __invoke() { return "Called"; }}
>>> $c->get('TestClass');
=> TestClass {#2319}
>>> $c->set('Foo\Bar', 'TestClass');
=> Matthewbdaly\Ernie\Container {#2307}
>>> $c->get('Foo\Bar');
=> TestClass {#2309}
>>> $c->set('Foo\Bar', new TestClass);
=> Matthewbdaly\Ernie\Container {#2307}
>>> $c->get('Foo\Bar');
=> "Called"

Как вы можете видеть, если мы передадим полное имя класса класса, который определяет метод __invoke() , он может быть разрешен, как и ожидалось. Однако, если мы передадим конкретный его экземпляр в set() , он будет вызван и вернет ответ от этого. Возможно, это не то поведение, которое вы хотите для своего собственного контейнера.

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

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

Давайте добавим спецификацию для этого:

    function it_can_resolve_dependencies()
    {
        $toResolve = get_class(new class(new DateTime) {
            public $datetime;
            public function __construct(DateTime $datetime)
            {
                $this->datetime = $datetime;
            }
        });
        $this->set('Foo\Bar', $toResolve);
        $this->get('Foo\Bar')->shouldReturnAnInstanceOf($toResolve);
    }

Здесь мы должны быть немного хитрыми. Анонимные классы определяются и создаются одновременно, поэтому мы не можем передать их в качестве анонимного класса в тесте. Вместо этого мы вызываем анонимный класс и получаем его имя, затем устанавливаем его в качестве второго аргумента в set() . Затем мы можем убедиться, что возвращаемый объект является экземпляром того же класса.

Выполнение этого приводит к ошибке:

$ vendor/bin/phpspec run
Matthewbdaly/Ernie/Container                                                    
  86 - it can resolve dependencies
      exception [err:ArgumentCountError("Too few arguments to function class@anonymous::__construct(), 0 passed and exactly 1 expected")] has been thrown.

                                    94% 18
3 specs
18 examples (17 passed, 1 broken)
60ms

Это ожидаемо. Наш тестовый класс принимает экземпляр DateTime в конструкторе в качестве обязательной зависимости, поэтому его создание завершается неудачей. Нам нужно обновить метод getInstance() , чтобы он мог обрабатывать извлечение любых зависимостей:

    private function getInstance(ReflectionClass $item)
    {
        $constructor = $item->getConstructor();
        if (is_null($constructor) || $constructor->getNumberOfRequiredParameters() == 0) {
            return $item->newInstance();
        }
        $params = [];
        foreach ($constructor->getParameters() as $param) {
            if ($type = $param->getType()) {
                $params[] = $this->get($type->getName());
            }
        }
        return $item->newInstanceArgs($params);
    }

Здесь мы используем API отражения для получения конструктора. Если конструктора нет или у него нет требуемых параметров, мы просто возвращаем новый экземпляр отраженного класса, как и раньше.

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

Давайте еще раз проверим спецификации:

$ vendor/bin/phpspec run
                                      100% 18
3 specs
18 examples (18 passed)
51ms

Наш контейнер теперь готов. Мы можем:

  • Решайте простые классы из коробки
  • Задайте произвольные ключи для разрешения определенных классов или результатов вызываемых объектов, чтобы включить сопоставление интерфейсов с конкретными реализациями или разрешить классы, для которых требуются определенные параметры, не относящиеся к объекту, такие как PDO
  • Разрешать сложные классы с несколькими зависимостями

Не так уж плохо для чуть более 100 строк PHP…

Как я уже сказал, это довольно минимальный пример контейнера для внедрения зависимостей, и я бы не советовал использовать его в производстве, когда существует так много существующих зрелых решений. Я понятия не имею, как производительность будет сопоставляться с существующими решениями, или есть ли какие-либо проблемы с этим, и, откровенно говоря, это не имеет значения – это предназначено для обучения, чтобы понять, как работают контейнеры для внедрения зависимостей в целом, а не как фактическая полезная часть кода для производства. Если вам нужен готовый контейнер, я бы указал вам в направлении лига/контейнер , который сослужил мне хорошую службу.

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

Оригинал: “https://dev.to/matthewbdaly/creating-your-own-dependency-injection-container-in-php-342k”