От переводчика


Всем привет! Я продолжаю серию переводов, в которой мы по косточкам разбираем, что такое Dependency Injection.

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

В сегодняшнем переводе речь пойдет о том, что собой представляет DI-контейнер, его функциях, преимуществах использования и отличии от фабрик.


Серия включает в себя следующие статьи



  1. Dependency Injection
  2. Dependency Injection Containers
  3. Dependency Injection Benefits
  4. When to use Dependency Injection
  5. Is Dependency Injection Replacing the Factory Patterns?

Контейнеры внедрения зависимостей


Основные термины: контейнер, управление жизненным циклом компонента

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

Когда вы конфигурируете DI-контейнер, вы определяете, экземпляры каких компонентов он должен быть способен создать, и какие зависимости внедрить в каждый компонент. Также вы обычно можете настроить режим создания экземпляра для каждого компонента. Например, должен ли новый экземпляр создаваться каждый раз? Или один и тот же экземпляр компонента должен быть переиспользован (синглтон) везде, куда он внедряется?

Если некоторые компоненты настроены как синглтоны, то некоторые контейнеры имеют возможность вызывать методы синглтона тогда, когда контейнер выключается. Таким образом синглтон может освободить любые ресурсы, которые он использует, такие как подключение к БД или сетевое соединение. Это обычно называют «управлением жизненным циклом объекта». Это значит, что контейнер способен управлять компонентом на различных стадиях жизненного цикла компонента. Например, создание, конфигурирование и удаление.

Управление жизненным циклом — это одна из обязанностей, которую DI контейнеры принимают в дополнение к созданию экземпляров и их внедрению. Тот факт, что контейнер иногда сохраняет ссылку на компоненты после создания экземпляра, и есть та причина, по которой он называется «контейнером», а не фабрикой. DI-контейнеры обычно сохраняют ссылки на объекты, чьим жизненным циклом им предстоит управлять или которые будут переиспользованы для будущих внедрений, такие как синглтон или приспособленец. Когда контейнер настроен на создание новых экземпляров компонентов при каждом вызове, контейнер обычно «забывает» о созданных объектах. В противном случае у сборщика мусора будет горячая пора, когда придет время собирать все эти объекты.

На данный момент доступно несколько DI-контейнеров. Для Java существуют Butterfly Container, Spring, Pico Container (прим. ред. в его разработке уаствовал Мартин Фаулер), Guice (прим. ред. разработка Google) и другие (прим. ред. например, есть еще Dagger, также разработка Google. Jakob Jenkov, автор переводимой статьи, разработал Butterfly Container. Его исходный код доступен на github, а документация содержится в отдельной серии постов).

Выгоды от использования DI и DI-контейнеров

Основные термины: перенос зависимостей, коллабораторы

Существует несколько преимуществ от использования DI-контейнеров по сравнению с тем, что компонентам приходится самостоятельно разрешать свои зависимости (прим. ред. «самостоятельно разрешать зависимости» в данном контексте означает «создавать объекты, необходимые для работы компонента внутри самого компонента»).

Некоторые из этих преимуществ:

Меньше зависимостей
Меньше «перенос» зависимостей
Код проще переиспользовать
Код удобнее тестировать
Код удобнее читать

Эти преимущества более детально объяснены далее.

Меньше зависимостей


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

Код проще переиспользовать


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

Код удобнее тестировать


DI также повышает возможности тестирования компонентов. Когда зависимости могут быть внедрены в компонент, возможно также и внедрение mock-ов этих объектов. Mock-объекты используются для тестирования как замена настоящей имплементации. Поведение mock-объекта может быть сконфигурировано. Таким образом, все возможное поведение компонента при использовании mock-объекта может быть протестировано на корректность. Например, обработка ситуации, когда mock возвращает корректный объект, когда возвращает null и когда выбрасывается исключение. Кроме того, mock-объекты обычно записывают, какие их методы были вызваны и, таким образом, тест может проверить, что компонент, использующий mock, использовал их (прим. ред. методы) как ожидалось.

Код удобнее читать


DI переносит зависимости в интерфейс компонентов. Это делает нагляднее то, какие зависимости есть у компонента, делая код более удобным для чтения. Вам не придется просматривать весь код для того, чтобы увидеть то, какие зависимости вам нужно будет предоставить для данного компонента. Они все видны в интерфейсе.

Меньше «перенос» зависимостей


Еще один приятный бонус от DI — избавляет от того, что я называю «перенос зависимостей». Перенос зависимостей проявляется в том, что объект получает параметр в одном из своих методов, который сам по себе объекту не нужен, а нужен одному из объектов, которые он вызывает для своей работы. Это может звучать немного абстрактно, так что давайте приведем простой пример.

Компонент A загружает приложение и создает объект конфигурации, Config, который нужен какому-то из объектов приложения, но не всем компонентам в системе. Затем А вызывает B, B вызывает C, С вызывает D. Ни B, ни C не нуждаются в объекте типа Config, но D нуждается. Вот цепочка вызовов.

  A создает Config
  A --> B --> C --> D --> Config

Стрелки символизируют вызовы методов. Если A создает B, и B создает C, и C создает D, и D нуждается в Config, то объект Config должен быть передан через всю цепочку: от A к B, от D к C, и, наконец, от C к D. Тем не менее, ни C, ни D для выполнения работы, объект Config не нужен. Все, что они делают — это «переносят» Config к D, который и зависит от Config. Отсюда и название «перенос зависимостей».

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

Перенос зависимостей создает много «шума» в коде, делая труднее его чтение и поддержку. К тому же, это затрудняет тестирование компонентов. Если вызов метода компонента A требует некоторого объекта OX только потому, что он нужен его «коллаборатору» CY (прим. ред. в оригинале используется слово collaborator. По определению, коллабораторы — это классы, которые либо зависят от других, либо предоставляют что-либо другому классу. Получается, что категория «коллаборатор» объединяет в себе понятия «зависимый класс» и «зависимость», является их надмножеством), вам все равно нужно предоставить экземпляр OX при тестировании метода объекта A, даже если он его не использует. Даже если вы используете mock-реализацию коллаборатора CY, который может не использовать объект OX. Вы можете обойти это, передав null вместо OX, если в тестируемом методе нет проверки на null. Иногда в ходе теста может быть сложно создать объект OX. Если конструктор OX зависит от множества других объектов или значений, вашему тесту также придется передавать осмысленные объекты/значения для этих параметров. И если OX зависит от OY, который зависит от OZ, это становится настоящим безумием.

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

Общее решение для проблемы «переноса зависимостей» — сделать необходимые объекты статическими синглтонами. Таким образом любой компонент системы сможет получить доступ к синглтону через его статический фабричный метод (прим. ред. не путать с паттерном Фабричный Метод). К сожалению, статические синглтоны тянут за собой целый ворох других проблем, в которые я здесь не буду погружаться. Статические синглтоны — зло. Не используйте их, если вам удастся избежать этого.

Когда вы используете DI-контейнер, вы можете снизить «перенос зависимостей» и сократить использование статических синглтонов. Контейнер знает обо всех компонентах в приложении. Следовательно, он может идеально связать компоненты, без необходимости передавать зависимости одному компоненту через другой. Пример с компонентами при использовании контейнера будет выглядеть следующим образом:

Контейнер создает Config
Контейнер создает D и внедряет Config
Контейнер создает C и внедряет D
Контейнер создает B и внедряет C
Контейнер создает A и внедряет B

A --> B --> C --> D --> Config

Когда A вызывает B, ему не нужно передавать объект Config в B. D уже знает об объекте Config.

Однако, все еще могут быть ситуации, когда нельзя избежать переноса зависимостей. Например, если ваше приложение обрабатывает запросы, такое как веб-приложение или веб-сервис, и компоненты обрабатывающие запросы это — синглтоны. Тогда объект запроса может быть необходимо передавать вниз по цепочке вызовов каждый раз, когда доступ к нему понадобится компоненту более низкоуровневого слоя.

В следующей статье, «When to use dependency injection», Jakob Jenkov приводит практические примеры применения DI. Как написать код, если вам нужно: внедрить конфигурационную информацию в один или несколько компонентов, внедрить одну и ту же зависимость в один или несколько компонентов, внедрить разные реализации одной зависимости, внедрить одну и ту же реализацию в разных конфигурациях, получить какие-либо данные из контейнера. Также автор рассказывает о том, в каких случаях DI вам не понадобится. Stay tuned!

К началу

Комментарии (24)


  1. ewing
    08.03.2018 20:47
    +1

    Одна из проблем контейнера — он сам по себе внешняя зависимость (и большая), с этим приходится мириться.


  1. Kiselioff Автор
    08.03.2018 21:49

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

    Допустим, мы выбрали контейнер. И даже то, что мы получим зависимость от контейнера, будет плюсом. Даже двумя (как минимум). Мы получим: 1) единый «центр управления» — контейнер упростит создание экземпляров и их внедрение (это очевидное преимущество), 2) мы будем использовать «хорошую» зависимость.

    Под «хорошей» зависимостью я в данном случае понимаю то, что контейнер поставляется в виде внешней библиотеки. Код контейнера, очень вероятно, будет построен по принципам открытого ПО. Это точно верно для всех контейнеров, упомянутых в статье. Это значит, что этот код будет доступен и многократно перепроверен сообществом.


    1. ewing
      08.03.2018 22:19

      C контейнером сложнее реализовывать модульность.

      Например, если в коде есть несколько слоев, то можно легко их и непринужденно нарушать.

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


      1. Goldseeker
        09.03.2018 13:17
        +1

        Зависеть от контейнера должен только и только конечный проект, библиотеки просто пишутся с использованием DI и тогда нет проблемы их зарегистрировать на любой реализации контейнера


  1. Kiselioff Автор
    08.03.2018 22:29

    Например, если в коде есть несколько слоев, то можно легко их и непринужденно нарушать.
    Как? И зачем?

    для библиотек контейнер вреден
    согласен. библиотека не должна зависеть от фреймворков.


    1. VolCh
      09.03.2018 08:09
      +2

      Поскольку из контейнера можно получить любой объект, то, например, можно достать соединение с БД из слоя представления, хотя помещено оно туда было для слоя модели. В ситуации «нужно вчера» это может оказаться самым эффективным решением какой-то проблемы в краткосрочной перспективе.


      1. Kiselioff Автор
        09.03.2018 08:32

        И защиты от такого поведения в контейнере не может быть предусмотрено?


      1. dopusteam
        09.03.2018 12:20
        +1

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


        Но, например, в c# если подключение к БД и слой представления разнести по разным проектам, то можно ограничить область видимости и без хаков в виде рефлексии Вы на слое представления не сможете получить БД вообще никак


        1. Kiselioff Автор
          09.03.2018 12:31

          Такой подход мне нравится.


      1. Goldseeker
        09.03.2018 13:14
        +1

        Для этих целей в контейнерах реализованы Nested Scopes/Nested Lifetimes, которые могут в том числе и ограничивать область видимости отдельных регистраций.


  1. vanxant
    09.03.2018 01:12

    Способ изложения очень не нравится.
    Напоминает стиль, принятый в современном матане или там теоретической физике.
    Куча ненужных терминов, рассчитанных на людей, которые и так давно в теме. Примеры на тему «хочу зайти за умного». Если читатель уже набил шишек на синглтонах, ему не надо рассказывать про плюсы и минусы DI. Если нет — половина статьи для него набор баззвордов.
    В матане за таким стилем стоит хотя бы идея — формальная корректность от аксиом и вот это всё — то здесь тупо понты, «смотрите какие умные слова я знаю».


    1. Kiselioff Автор
      09.03.2018 08:08
      +2

      Способ изложения очень не нравится.
      Не нравится стиль статьи или моих комментариев?
      Куча ненужных терминов, рассчитанных на людей, которые и так давно в теме.
      Я немного удивлен. Думаю, здесь терминов не больше десятка. И избыточных из них может быть, разве «коллаборатор». Какие термины Вы считаете лишними?
      В матане за таким стилем стоит хотя бы идея — формальная корректность от аксиом и вот это всё — то здесь тупо понты, «смотрите какие умные слова я знаю».
      Ок, преамбулу Вы сделали. Давайте к конкретике.


  1. VolCh
    09.03.2018 08:29
    +2

    Как-то спутаны плюсы DI и DI-контейнера. DI-контейнер и зависмости от него — это цена, которую нужно платить за удобное использование DI, прежде всего за решение проблемы переноса зависимостей. Причём проблема решается лишь частично — сам контейнер становится зависимостью которую нужно пробрасывать вместо реальных зависимостей. При этом реальные зависимости становятся менее явными, особенно в языках с утиной динамической типизацией, где реальный тип может вообще не появляться в коде, только this.container.get('someservice').run()


    1. Goldseeker
      09.03.2018 08:49
      +1

      Контейнер, использумый таким образом является не DI-контейнером, а service-locator'ом. При нормальном использовании DI-container'а код проекта вообще не зависит от DI-container'а.


      1. alekciy
        09.03.2018 11:00

        Эм… А нормальный тогда вариант какой?


        1. Goldseeker
          09.03.2018 13:13
          +1

          Классы и их фабрики и всё-всё-всё регистрируются на контейнере в "main"(ну или другая точка входа в зависимости от языка и окружения), с контейнера resolve'ится рутовый объект приложения и у него вызывается метод. В примере я буду использовать c# и некий условный DI-container


          public void Main()
          {
              Container container = new Container();
              container.Register<B>(c => new B());
              container.Register<A>(c => new A(c.Resolve<B>()));
          
              using(Scope scope = Container.CreateScope())
              {
                   scope.Resolve<A>().ExecuteApplication();
              }
          }

          Вообще рекомендую для понимания принципов использования контейнеров почитать документацию к autofac — достаточно популярному DI-container'у для С#
          http://autofac.readthedocs.io/en/latest/getting-started/index.html
          и в частности:
          http://autofac.readthedocs.io/en/latest/best-practices/index.html
          там даже есть отдельный пункт с рекомендацией не использовать DI-container как service locator.


      1. VolCh
        10.03.2018 13:26
        +1

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


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


        А как код проекта может не зависеть от DI-контейнера я вообще не представляю. Вернее представляю, но только в случае широкого использования метапрограммирования, заменяющего, например, new UserManager на вызов Container::getInstance().get('UserManager'). Как-то нам нужно получать из контейнера то, что мы хотим хотя бы на уровне точки входа в приложение, если пробрасывание десятков зависимостей нас не страшит.


        1. Goldseeker
          10.03.2018 14:20
          +1

          А как код проекта может не зависеть от DI-контейнера я вообще не представляю.
          Код проекта зависит от кода DI-контенера, но только в файлах регистрации.

          Использовать DI-container как service-locator это антипаттерн(это не моё утверждение выше я уже давал ссылку на документацию к autofac, отговаривающую использовать контейнер таким образом), исходящий из неполного понимания зачем вообще городится огород. Для того чтобы пробросить зависимости не нужно тащить через весь стек 100500 зависимостей и не нужно тащить с собой service-locator — нужно использовать фабрики. Рассмотрим модельную ситуацию: класс A в процессе работы должен производить экземпляры класса B, но классу B для этого нужен инстанс класса С(всем общий), который классу A никак не нужен, тогда вместо того чтобы в класс A тащить инстанс класса С или контейнер, чтобы его передать в конструктор B, нужно передать в A функтор создания B, который уже знает как создать B и зарезловить для него зависимости на контейнере:
          (Снова С#, уж простите что я с ним лезу в тред с тегом Java)


          //Примитивный контейнер
          public interface IContainer
          {
              void Register<T>(Func<Container, T> a);
              void RegisterSingleton<T>(Func<Container, T> a);
              T Resolve<T>();
          }
          
          public class C
          {
              public void Do() { }
          }
          
          public class B
          {
              public B(C c)
              {
                  // сделаем что-нибудь с C в конструкторе, чтобы обозначить, что B зависит от C
                  c.Do();
              }
          
          }
          
          public class A
          {
              private readonly Func<B> _bFactory;
              private List<B> _bList;
              public A(Func<B> bFactory)
              {
                  _bFactory = bFactory;
              }
          
              public void DoStuff()
              {
                  //Создадим инстанс B с помощью фабрики и положим его в список
                  _bList.Add(_bFactory());
              }
          }
          
          public class App
          {
              public void Main()
              {
                  //Cоздадим контейнер для приложения
                  IContainer container = new Container();
                  //Регистрация классов на контейнере
          
                  //регистрируем фабрику С и сообщаем, что инстанс C должен быть один
                  container.RegisterSingleton<C>(c => new C());
          
                  //Регистрируем фабрику B на контейнере
                  //(заметь что инстанс С в конструктор разрешается с контейнера
                  container.Register<Func<B>>(c => () => new B(c.Resolve<C>()));
          
                  //Регистрируем А, разрешая фабрику В с контейнера   
                  container.Register<A>(c => new A(c.Resolve<Func<B>>()));
          
                  //Забираем А из контейнера и выполняем код приложения
                  A a = container.Resolve<A>();
          
                  a.DoStuff();
          
              }
          }

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


          Отмечу, что в примере использовался примитивный контейнер только с базовым функционалом, крутые контейнеры типо Autofac могут это всё сделать гораздо более лаконично.


          1. Tom910
            10.03.2018 17:07

            Наглядный пример получился и интересный подход, что все классы оборачиваются в функцию и прокидываются в таком виде в контейнер. Но возник вопрос, как это будет выглядеть, когда у нас 100, 200, 300 классов? Создаем множество таких входных точек?


            1. Goldseeker
              10.03.2018 17:37
              +1

              Используя контейнер из моего примера, для каждого класса будет соответствующая строчка в Main вида:
              container.Register<MyClass>(c => new MyClass());,
              а в серьезных контейнерах достаточно
              container.Register<MyClass>(),
              а иногда и вообще без этого можно обойтись, если использовать принцип Convention over Configuration.


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


    1. Kiselioff Автор
      09.03.2018 10:31

      Как-то спутаны плюсы DI и DI-контейнера.
      Серия идет в таком духе, что из статьи в статью переносятся основные мысли с добавлением нового. Да, автор снова перечисляет плюсы DI и добавляет к этому еще плюс от применения DI-контейнера (меньше перенос зависимостей). Думаю, такой подход оправдан и полезен для тех, кто с темой только начинает знакомиться.


  1. Goldseeker
    09.03.2018 13:34
    +1

    Среди приверженцев принципов DI, есть и альтернативный взгляд на DI-container'ы, который сводится к тому что DI-container это промежуточный шаг между Poor mans DI и Convention over configuration, который сам по себе не очень-то нужен
    Вот есть небольшая статья на эту тему:
    blog.ploeh.dk/2012/11/06/WhentouseaDIContainer


    1. amakhrov
      10.03.2018 05:05

      В случае с Convention over Configuration, описанном в этой статье, DI-container все еще присутствует, он никуда не делся. Просто объекты в нем регистрируются автоматически, а не вручную.
      Так что говорить, что DI-container это ненужный промежуточный шаг — некорректно.


      1. Goldseeker
        10.03.2018 13:44

        Согласен, сформулировал неправильно.