Как начать использовать DI

Многократно сталкивался с мнением, что DI это нечто сложное, громоздкое, медленное, подходящее только для «больших» проектов, а потому его использование конкретно на текущей задаче (500+ классов моделей, 300+ классов контроллеров) неоправданно. Отчасти это связано с тем, что DI однозначно ассоциируется с пакетами вроде Symfony «The Dependency Injection Component», заведомо с лихвой покрывающими все возможные варианты внедрения зависимостей.
Здесь я хочу привести некий функциональный минимум, который даст понимание самой концепции, дабы показать, что сама инверсия зависимостей может быть достаточно проста и лаконична.

Содержание


Реализация составляет 2 класса из 500 строк кода:
SimpleDi\ClassManager – предоставляет информацию о классах. Для полноценной работы ему необходим кэшер (мы используем Doctrine\Common\Cache\ApcCache), это позволит не создавать отражений при каждом вызове скрипта. Разбирает аннотации для последующей инъекции. Так же его возможно использовать в загрузчике, т.к. он хранит путь до файла класса.
SimpleDi\ServiceLocator – создает и инициализирует запрашиваемые у него сервисы. Именно этот класс производит инъекции.
1) В простейшем случае, когда для класса не заданы никакие настройки, SimpleDi\ServiceLocator работает аналогично паттерну multiton (он же Object Pool).
$service_locator->get('HelperTime');

2) Вариант внедрения через поле
class A
{
    /**
     * @Inject("HelperTime")
     * @var HelperTime
     */
   protected $helper_time;
}
$service_locator->get('A');

Такой вариант следует использовать исключительно в контроллерах, т.к. для внедрения будет создано отражение, что влияет на производительность в худшую сторону. Один класс на вызов скрипта с несколькими полями никак на время загрузки страницы не повлияют, но если это использовать повсеместно, потеря производительности будет вполне ощутима.
Здесь хочется сделать отступление в сторону Symfony. Там подобное внедрение допустимо:
  • в контроллерах для полей с любой видимостью (в том числе protected, private) и это объясняется именно незначительным влиянием на производительность, а кроме такого сам контроллер является контейнером сервисов (и имеет метод get() аналогичный нашему ServiceLocator::get());
  • в любых классах (сервисах) для public полей, т.к. в этом случае не будет создаваться отражения, и будет использоваться простое присвоение $service->field = $injected_service, что для private/protected полей приведет к исключению.

В нашей реализации отражение создается всегда, внедрение всегда будет заканчиваться успешно.
3) Внедрение через метод
class B 
{
    /**
     * @var HelperTime
     */
    protected $helper_time;

    /**
     * @Inject("HelperTime")
     * @param HelperTime $helper
     */
    public function setHelperTime($helper)
    {
        $this->helper_time = $helper;
    }
}
$service_locator->get('B');

Такой вариант наиболее приемлем и наравне с внедрением через поле следует использовать для установки зависимостей по умолчанию.
4) Внедрение через конфиг
$service_locator->setConfigs(array(
    'class_b_service' => array(
        'class' => 'B',
        'calls' => array(
            array('setHelperTime', array('@CustomHelperTime')),
        )
    )
));
$service_locator->get('class_b_service');

Это то, для чего и используется внедрение зависимостей. Теперь через настройки возможно подменить используемый в классе B хелпер, при этом сам класс B изменяться не будет.
5) Создание нового экземпляра класса. Когда необходимо иметь несколько объектов одного класса, возможно использование ServiceLocator в качестве фабрики
$users_factory = $service_locator;
$users_row = array(
    array('id' => 1, 'name' => 'admin'),
    array('id' => 2, 'name' => 'guest'),
);
$users = array();
foreach ($users_rows as $row) {
    $user = $users_factory->createService('User');
    $user->setData($row);
}


Пример


Возьмем произвольную полезную библиотеку и попробуем внедрить в наш проект. Допустим это github.com/yiisoft/yii/blob/master/framework/utils/CPasswordHelper.php
Оказывается, мы не можем это сделать, потому что класс жестко завязан на абстолютно ненужные нам классы Yii и CException.
class CPasswordHelper
{
    …
    public static function generateSalt($cost=13)
    {
        if(!is_numeric($cost))
            throw new CException(Yii::t('yii','{class}::$cost must be a number.',array('{class}'=>__CLASS__)));

        $cost=(int)$cost;
        if($cost<4 || $cost>31)
            throw new CException(Yii::t('yii','{class}::$cost must be between 4 and 31.',array('{class}'=>__CLASS__)));

        if(($random=Yii::app()->getSecurityManager()->generateRandomString(22,true))===false)
            if(($random=Yii::app()->getSecurityManager()->generateRandomString(22,false))===false)
                throw new CException(Yii::t('yii','Unable to generate random string.'));

        return sprintf('$2a$%02d$',$cost).strtr($random,array('_'=>'.','~'=>'/'));
    }
}

Для того, чтобы сделать класс доступным для любого проекта, достаточно было бы правильно описать зависимости:
class CPasswordHelper
{

    /**
     * Здесь я для краткости воспользуюсь public полями, вряд ли в данном случае это большее зло, 
     * чем вызов статических методов.
     * @Inject
     * @var \Yii\SecurityManager
     */ 
    public $securityManager;

    /**
     * Генератор ошибок
     * @Inject
     * @var \YiiExceptor
     */ 
    public $exceptor;

    …

    public function generateSalt($cost=13)
    {
        if(!is_numeric($cost))
            $this->exceptor->create('yii','{class}::$cost must be a number.',array('{class}'=>__CLASS__));

        $cost=(int)$cost;
        if($cost<4 || $cost>31)
            $this->exceptor->create('yii','{class}::$cost must be between 4 and 31.',array('{class}'=>__CLASS__));

        if(($random=$this->securityManager->generateRandomString(22,true))===false)
            if(($random=$this->securityManager()->generateRandomString(22,false))===false)
                this->exceptor->create('yii','Unable to generate random string.');

        return sprintf('$2a$%02d$',$cost).strtr($random,array('_'=>'.','~'=>'/'));
    }
}

И завести класс – генератор исключений
class YiiExceptor
{
    public function create($a, $b, $c = null)
    {
        throw new CException(Yii:t($a, $b, $c));
    }
}


Заключение


Использование DI позволяет не задумываться над тем, в каком контексте будет использоваться ваш модуль. Дает возможность переносить отдельный класс в другой проект без набора (часто иерархического) зависимостей. При использовании аннотаций вам не придётся заниматься явным созданием объектов и явной передачей параметров и сервисов в объект. И, конечно, такой класс в разы проще поддается тестированию, нежели завязанный на статические методы или явно создающий экземпляры класса, вместо использования фабрики.

Ссылки


Сам пример github.com/mthps/SimpleDi
Теория ru.wikipedia.org/wiki/Внедрение_зависимости
Одна из лучших реализаций symfony.com/doc/current/components/dependency_injection/index.html
Поделиться публикацией

Комментарии 17

    –1
    Если кому интересно, с месяц назад делал for fun свой простой DIC по образу и подобию симфониевского. Вроде, неплохо получился. github.com/NeonXP/DIC
      +2
      Использование DI позволяет не задумываться над тем, в каком контексте будет использоваться ваш модуль. Дает возможность переносить отдельный класс в другой проект без набора (часто иерархического) зависимостей. При использовании аннотаций вам не придётся заниматься явным созданием объектов и явной передачей параметров и сервисов в объект. И, конечно, такой класс в разы проще поддается тестированию, нежели завязанный на статические методы или явно создающий экземпляры класса, вместо использования фабрики.


      При чём тут DI?
      Каким образом вы избавитесь от зависимостей?
      Что мешает даже при использвании DI засунуть в класс статики?
      Почему класс созданный через фабрику легче подается тестированию?
        0
        Каким образом вы избавитесь от зависимостей?

        Подразумевается зависимость от конкретных классов. При описанном подходе во-первых возможно изменить используемый класс через настройки сервис локатора, во вторых явно задать реализацию сервиса (при наличии set- метода).

        Что мешает даже при использвании DI засунуть в класс статики?

        Использование DI ничему не мешает, оно позволяет этого не делать.

        Почему класс созданный через фабрику легче подается тестированию?

        Всё-таки фабрика создает экземпляры, а не классы. В приведенном абзаце подразумевается удобство тестирование именно порождающего объекты класса, а не порождаемого.

        Т.е. для тестирования класса, в котором имеется подобный код:
        $m = new Model();
        $m->setValue('a', 1);
        return $m;
        

        Необходимо существование класса «Model». В то же время убедиться в том, что тестирумый класс действительно выполнил setValue() не всегда возможно, для этого необходимо, чтобы класс Model уже работал.

        При использовании фабрики, код будет таким:
        $m = $this->modelFactory()->create();
        $m->setValue('a', 1);
        return $m;
        

        И в тесте, подменяя фабрику, мы можем через метод modelFactory()->create() вернуть mock объект, для которого проверим однократный вызов setValue() с ожидаемыми параметрами.
          –3
          Подразумевается зависимость от конкретных классов. При описанном подходе во-первых возможно изменить используемый класс через настройки сервис локатора, во вторых явно задать реализацию сервиса (при наличии set- метода).


          Когда класс не сферический в вакууме а зависит допустим от модели данных, от библиотек (тех же симфони компонентов, доктрины, etc), то внедряй не внедряй, а иерархию зависимостей потянешь. Если у вас где-то используется entity manager — перенести класс в проект без доктрины уже нельзя.

          — Почему класс созданный через фабрику легче подается тестированию?

          — И в тесте, подменяя фабрику, мы можем через метод modelFactory()->create() вернуть mock объект, для которого проверим однократный вызов setValue() с ожидаемыми параметрами.


          Согласен, речь идет о физической невозможности написать юнит тест. Хотя имхо, фабрика — излишняя абстракция, которая в первую очередь путает разработчика. Что выпрыгнет из черного ящика? А кто его знает. Нужно полезть в код фабрики, посмотреть. Нужно полезть в конфигурацию сервис локатора, посмотреть. Ой, не ту конфигурацию посмотрел. Не то вылезло.

          Если реально стоит вопрос тестирования, лучше заюзайте AspectMock.

          DI реально усложняет код. Если не планируется замена сервисов, а это случится только если вы пишите расширяемую либу, или фреймворк, то в большинстве случаев DI не нужно. Что точно — не нужен повесместно. Если внядряете DI только ради «тестирования», то нет смысла, продакшн код подгонять под ограничения средств тестирования.
            0
            Если у вас где-то используется entity manager — перенести класс в проект без доктрины уже нельзя.

            Почему же, достаточно определить свой entity manager. В симфони весьма гибкая система конфигов и сервис doctrine.entity_manager вполне возможно заменить. Конечно, если в целевом объекте явно не указан класс менеджера сущностей, а подставляемый сервис имеет соответствующий интерфейс.

            Если реально стоит вопрос тестирования, лучше заюзайте AspectMock.

            Чем же непереносимый код лучше подхода с использованием многократно описанных паттернов?

            Что выпрыгнет из черного ящика?

            Абсолютно не важно, главное чтобы оно имело соответствующий интерфейс.

            DI реально усложняет код.

            FooBar::getSome();
            $this->fooBar->getSome();
            Возможно, это всё-таки дело привычки и конкретной реализации.

            Если не планируется замена сервисов...

            То позже возникают проблемы в поддержке проекта.
              0
              Почему же, достаточно определить свой entity manager.

              Ага, можно имплементировать всё методы ентити менеджера, а в итоге написать свою доктрину. Вот только зачем?

              Я к тому, что не бывает универсального кода, который учтет абсолютно всё. Пусть использование DI помогает вводить некоторую абстракцию в код, это совершенно не значит, что проектируя с DI мы свободно сможем заменять любые компоненты. Ибо интерфейс ентити менеджера доктрины имплементирует только энтити менеджер доктрины. Если мы меняем ORM, нам недостаточно заменить один сервис на другой, нам нужно переписать большую часть кода.
                0
                Если переносимый сервис пользуется ограниченным набором методов, допустим одним лишь find(), вполне возможно не подключать в проект доктрину, а создать адаптер к имеющейся субд.
                Если вам нужна большая часть функционала доктрины, скорее всего вам нужна именно доктрина.

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

                На самом деле, означает. Целесообразность замены, конечно, зависит от конкретного случая.
            0
            $m = $this->modelFactory()->create();
            $m->setValue('a', 1);
            return $m;
            

            В данном куске кода нужно тестировать именно create. Так что я не понимаю каким образом замена new на create облегчает вам тестирование.
            А MockObject нужен не для того что бы тестировать, а для того что бы его подсунуть тестируемому классу.

            Использование DI ничему не мешает, оно позволяет этого не делать.

            Не использование DI тоже позволяет не использовать статики.

            Подразумевается зависимость от конкретных классов. При описанном подходе во-первых возможно изменить используемый класс через настройки сервис локатора, во вторых явно задать реализацию сервиса (при наличии set- метода).

            DI ради DI? Вы описали выгоды использования DI для DI.
            Зависимость от конкретных классов решается введением интерфейсов, при чём тут DI, я опять не вижу.
              0
              В данном куске кода нужно тестировать именно create.

              Метод create() протестирован в тесте фабрики. Метод __construct() тестируется в тесте класса, в котором он объявлен.
              В данном куске кода проверяется работа тестируемого сервиса, а не используемых в нем.

              Вы описали выгоды использования DI для DI.

              Мне кажется, сейчас вы путаете частный случай DI и сам принцип IoC. Вопрос сводится к обоснованию самой инверсии управления?
          0
          Причём тут DI?

          По ссылке из википедии:
          Используя же внедрение зависимости, объект просто предоставляет свойство, которое в состоянии хранить ссылку на нужный тип сервиса; и когда объект создается, ссылка на реализацию нужного типа сервиса автоматически вставляется в это свойство (поле), используя средства среды.
          Да, реализация примитивна, здесь вышеназванным средством среды выступает SimleDi\ServiceLocator, который занимается и созданием объектов и внедрением зависимостей, в одноименном паттерне (service locator) такого не предусмотрено. Да, это не его задача.
          0
          «500+ классов моделей, 300+ классов контроллеров»
          А это что, маленький проект?
            0
            Прошу прощения за сарказм в первом предложении поста, я действительно считаю этот проект большим и достойным единой продуманной концепции работы с объектами.
            0
            Зачем аннотации, если есть type hinting?
            Мне вот это решение нравится: github.com/Jasrags/Dice
            Минимум телодвижений, все работает без дополнительного вмешательства

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

            Самое читаемое