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


В этой статье я расскажу об основах внедрения зависимостей (англ. Dependency Injection, DI) простым языком, а также расскажу о причинах использования этого подхода. Эта статья предназначена для тех, кто не знает, что такое внедрение зависимостей, или сомневается в необходимости использования этого приёма. Итак, начнём.


Что такое зависимость?


Давайте сначала изучим пример. У нас есть ClassA, ClassB и ClassC, как показано ниже:


class ClassA {
  var classB: ClassB
}

class ClassB {
  var classC: ClassC
}

class ClassC {
}

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


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


Как работать с зависимостями?


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


Первый способ: создавать зависимости в зависимом классе


Проще говоря, мы можем создавать объекты всякий раз, когда они нам нужны. Посмотрите на следующий пример:


class ClassA {
  var classB: ClassB
  fun someMethodOrConstructor() {
    classB = ClassB()
    classB.doSomething()
  }
}

Это очень просто! Мы создаем класс, когда нам это необходимо.


Преимущества


  • Это легко и просто.
  • Зависимый класс (ClassA в нашем случае) полностью контролирует, как и когда создавать зависимости.

Недостатки


  • ClassA и ClassB тесно связаны друг с другом. Поэтому всякий раз, когда нам нужно использовать ClassA, мы будем вынуждены использовать и ClassB, и заменить ClassB чем-то другим будет невозможно.
  • При любом изменении в инициализации класса ClassB потребуется корректировать код и внутри класса ClassA (и всех остальных зависимых от ClassB классов). Это усложняет процесс изменения зависимости.
  • ClassA невозможно протестировать. Если вам необходимо протестировать класс, а ведь это один из важнейших аспектов разработки ПО, то вам придётся проводить модульное тестирование каждого класса в отдельности. Это означает, что если вы захотите проверить корректность работы исключительно класса ClassA и создадите для его проверки несколько модульных тестов, то, как это было показано в примере, вы в любом случае создадите и экземпляр класса ClassB, даже когда он вас не интересует. Если во время тестирования возникает ошибка, то вы не сможете понять, где она находится — в ClassA или ClassB. Ведь есть вероятность, что часть кода в ClassB привела к ошибке, в то время как ClassA работает правильно. Другими словами, модульное тестирование невозможно, потому что модули (классы) не могут быть отделены друг от друга.
  • ClassA должен быть сконфигурирован таким образом, чтобы он мог внедрять зависимости. В нашем примере он должен знать, как создать ClassC и использовать его для создания ClassB. Лучше бы он ничего об этом не знал. Почему? Из-за принципа единой ответственности.

Каждый класс должен выполнять лишь свою работу.

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


Второй способ: внедрять зависимости через пользовательский класс


Итак, понимая, что внедрение зависимостей внутри зависимого класса — не самая лучшая идея, давайте изучим альтернативный способ. Здесь зависимый класс определяет все необходимые ему зависимости внутри конструктора и позволяет пользовательскому классу предоставлять их. Является ли такой способ решением нашей проблемы? Узнаем немного позже.


Посмотрите на пример кода ниже:


class ClassA {
    var classB: ClassB
    constructor(classB: ClassB){
        this.classB = classB
    }
}

class ClassB {
    var classC: ClassC
    constructor(classC: ClassC){
        this.classC = classC
    }
}

class ClassC {
    constructor(){
    }
}

class UserClass(){
    fun doSomething(){
        val classC = ClassC();
        val classB = ClassB(classC);
        val classA = ClassA(classB);
        classA.someMethod();
    }
}
view rawDI Example In Medium - 

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


Преимущества


  • ClassA и ClassB теперь слабо связаны, и мы можем заменить ClassB, не нарушая код внутри ClassA. Например, вместо передачи ClassB мы сможем передать AssumeClassB, который является подклассом ClassB, и наша программа будет исправно работать.
  • ClassA теперь можно протестировать. При написании модульного теста, мы можем создать нашу собственную версию ClassB (тестовый объект) и передать её в ClassA. Если возникает ошибка во время прохождения теста, то теперь мы точно знаем, что это определенно ошибка в ClassA.
  • ClassB освобожден от работы с зависимостями и может сосредоточиться на выполнении своих задач.

Недостатки


  • Этот способ напоминает цепной механизм, и в какой-то момент цепь должна прерваться. Другими словами, пользователь класса ClassA должен знать всё об инициализации ClassB, что в свою очередь требует знаний и об инициализации ClassC и т.д. Итак, вы видите, что любое изменение в конструкторе любого из этих классов может привести к изменению вызывающего класса, не говоря уже о том, что ClassA может иметь больше одного пользователя, поэтому логика создания объектов будет повторяться.
  • Несмотря на то, что наши зависимости ясны и просты для понимания, пользовательский код нетривиален и сложен в управлении. Поэтому всё не так просто. Кроме того, код нарушает принцип единой ответственности, поскольку отвечает не только за свою работу, но и за внедрение зависимостей в зависимые классы.

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


Что такое внедрение зависимостей?


Внедрение зависимостей — это способ обработки зависимостей вне зависимого класса, когда зависимому классу не нужно ничего делать.

Исходя из этого определения, наше первое решение явно не использует идею внедрения зависимостей, а второй способ заключается в том, что зависимый класс ничего не делает для предоставления зависимостей. Но мы все ещё считаем второе решение плохим. ПОЧЕМУ?!


Поскольку определение внедрения зависимости ничего не говорит о том, где должна происходить работа с зависимостями (кроме как вне зависимого класса), разработчик должен выбрать подходящее место для внедрения зависимостей. Как видно из второго примера, пользовательский класс является не совсем правильным местом.


Как же сделать лучше? Давайте рассмотрим третий способ обработки зависимостей.


Третий способ: пусть кто-нибудь ещё обрабатывает зависимости вместо нас


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


«Чистая» реализация внедрения зависимостей (по моему личному мнению)

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

Внедрение зависимостей — это не технология, фреймворк, библиотека или что-то подобное. Это просто идея. Идея работать с зависимостями вне зависимого класса (желательно в специально выделенной части). Вы можете применять данную идею, не используя какие-либо библиотеки или фреймворки. Тем не менее, мы обычно обращаемся к фреймворкам для внедрения зависимостей, потому что это упрощает работу и позволяет избежать написания шаблонного кода.


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


Во-первых, данные фреймворки предлагают способ определения полей (объектов), которые должны быть внедрены. Некоторые фреймворки осуществляют это посредством аннотирования поля или конструктора с помощью аннотации @Inject, но существуют и другие методы. Например, Koin использует встроенные языковые особенности Kotlin для определения внедрения. Под Inject подразумевается, что зависимость должна обрабатываться DI-фреймворком. Код будет выглядеть примерно так:


class ClassA {
  var classB: ClassB
  @Inject constructor(classB: ClassB){
    this.classB = classB
  }
}

class ClassB {
  var classC: ClassC
  @Inject constructor(classC: ClassC){
    this.classC = classC
  }
}

class ClassC {
  @Inject constructor(){
  }
}

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


class OurThirdPartyGuy {
  fun provideClassC(){
    return ClassC() //just creating an instance of the object and return it.
  }

  fun provideClassB(classC: ClassC){
    return ClassB(classC)
  }

  fun provideClassA(classB: ClassB){
    return ClassA(classB)
  }
}

Итак, как вы видите, каждая функция отвечает за обработку одной зависимости. Поэтому если нам где-то в приложении нужно использовать ClassA, то произойдет следующее: наш DI-фреймворк создаёт один экземпляр класса ClassC, вызвав provideClassC, передав его в provideClassB и получив экземпляр ClassB, который передаётся в provideClassA, и в результате создаётся ClassA. Это практически волшебство. Теперь давайте изучим преимущества и достоинства третьего способа.


Преимущества


  • Все максимально просто. И зависимый класс, и класс, предоставляющий зависимости, понятны и просты.
  • Классы слабо связаны и легко заменяемы другими классами. Допустим, мы хотим заменить ClassC на AssumeClassC, который является подклассом ClassC. Для этого нужно лишь изменить код провайдера следующим образом, и везде, где используется ClassC, теперь автоматически будет использоваться новая версия:

fun provideClassC(){
  return AssumeClassC()
}

Обратите внимание, никакой код внутри приложения не меняется, только метод провайдера. Кажется, что ничего не может быть ещё проще и гибче.


  • Невероятная тестируемость. Можно легко заменить зависимости тестовыми версиями во время тестирования. Фактически, внедрение зависимостей — ваш главный помощник, когда речь заходит о тестировании.
  • Улучшение структуры кода, т.к. в приложении есть отдельное место для обработки зависимостей. В результате остальные части приложения могут сосредоточиться на выполнении исключительно своих функций и не пересекаться с зависимостями.

Недостатки


  • У DI-фреймворков есть определенный порог вхождения, поэтому команда проекта должна потратить время и изучить его, прежде чем эффективно использовать.

Заключение


  • Обработка зависимостей без DI возможна, но это может привести к сбоям работы приложения.
  • DI — это просто эффективная идея, согласно которой возможно обрабатывать зависимости вне зависимого класса.
  • Эффективнее всего использовать DI в определенных частях приложения. Многие фреймворки этому способствуют.
  • Фреймворки и библиотеки не нужны для DI, но могут во многом помочь.

В этой статье я попытался объяснить основы работы с понятием внедрения зависимостей, а также перечислил причины необходимости использования этой идеи. Существует ещё множество ресурсов, которые вы можете изучить, чтобы больше узнать о применении DI в ваших собственных приложениях. Например, этой теме посвящён отдельный раздел в продвинутой части нашего курса Android-профессии.

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


  1. vasyan
    04.01.2019 14:44

    Почему в андройде многие используют унылый Dagger 2, пишут кучу бойлерплэйт кода, все эти Module для каждого фрагмента и т.п. Потом когда надо что-то менять, то начинают править все модули?
    Почему не использовать простой Koin? Это джавовская привычка всё усложнять или есть какой-то реальный профит от Dagger 2?


    1. Vacxe
      04.01.2019 09:49

      А) Первый релиз Koin был «on Jun 12, 2017 0.1.0», Dagger более проверенная библиотека. Использовать в коммерческом продакшене не проверенные библиотеки просто неразумно.

      В) Был у Вас проект чисто на Java с легаси в 4 года, что лучше? Поддерживать предыдущий вреймворк для DI или внедрить новый (чтобы ничего не раздолбать)?

      С) Все зависит от задач. Иногда лучше вложить в «болерплейт» но иметь более гибкий функционал. Если это домашний проект который может поддерживать 1-2 программиста и все свеженькое и только на Котлине — пожалуйста. Если у вас команда 40+ разработчиков и вы пилите монорепу то стоит задуматься.

      Все еще зависит от вашей зоны ответсвенности и как вы оцениваете долгосрочные (3+ года) перспективы приложения. Junior & Intermediate обычно пушат за фэнси фреймворки, Senior задумываются о целесообразности в Architecture старается сделать так, чтобы при любом условии шоколадная масса как можно дольше не растекалась.


      1. vasyan
        04.01.2019 11:30

        Я спрашиваю конкретно про Android-приложения, а вы про какой-то кровавый интерпрайз. Android-приложение часто пишет один-два девелопера, или команда из 7 человек и часто в условиях ужатых бюджетов.
        Потом все эти абстракции про хорошую архитектуру меня не сильно интересуют. Меня интересует:
        1. Скрость разработки. Как быстро я смогу сделать новую фичу.
        2. Простота сопровождения. Насколько долго нужно курить мануал, чтобы что-то сделать новому человеку на проекте. И чтобы было меньше способов выстрелить себе в ногу.
        3. Насколько просто писать тесты.

        Потому, например, мы на Android перешли на Kotlin и его синтаксический сахар очень упрощает нам жизнь.


        1. anegin
          05.01.2019 00:02

          Доводилось мне видеть некоторое дерь приложения, «архитектура которых не сильно интересовала разработчиков». Вот честно — лучше бы там была архитектура, чем новомодности и быстро-фреймворки. (Против котлина ничего против не имею)


    1. uncle_doc
      04.01.2019 10:03
      +1

      Профит точно такой же как и от Koin — зависимости внедряются!


  1. vyatsek
    04.01.2019 18:22
    +2

    Очень слабое обоснование, книжный пример.
    Нет ни слова про инстанциирование и RAII, есть масса примеров когда RAII имеет место быть в языках с управляемой памятью.
    В общем случае нет ничего плохого, что один класс инстанциирует внутри другой. Когда вы выносите зависимый класс в конструктор, то всего лишь меняет отношении с композиции на агрегацию и если в первом случае время жизни объектов гарантированно совпадает, то во втором уже — зависит.
    Ни слова не говорится о видимости объектов. Если класс A публичный, а B и С пакетные? Для чего мне надо выставлять еще один тип(класс B) в публичный интерфейс?

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

    Потом из-за таких статей видишь вагоны типов в публичном контракте либы и вызовы в глубинных классах container.Resolve(someType).
    Такое ощущение, что автор просто наращивает трафик к своему бложику по андроид девелопменту.


    1. shapovalex
      04.01.2019 20:07

      В общем случае нет ничего плохого, что один класс инстанциирует внутри другой.


      Тут можно поспорить. Использование оператора new порождает жесткую связанность.
      Жесткая связанность не имеет в себе ничего плохого, если мы говорим о моделях или об утилитарных классах, которые не обращаются к любому источнику данных (файлы, БД, веб-сервисы и т/д). Скажем, глупо передавать через конструктор StringBuilder.
      В остальных случаях жесткая связанность — это катастрофа, которая превращает написание юнит/интеграционных тестов в пытку.
      Предположим, у нас есть класс, который использует сервис, обращающийся к внешнему АПИ.
      И если мы инстанцируем его внутри класса, то это всегда будет реальное АПИ и мы не можем его замокать без хаков.
      Да, есть PowerMock, рефлексия и так далее, но это уже жесткие хаки, которых хотелось бы избежать.


      1. vyatsek
        04.01.2019 01:57

        Предположим, у нас есть класс, который использует сервис, обращающийся к внешнему АПИ.
        И если мы инстанцируем его внутри класса, то это всегда будет реальное АПИ и мы не можем его замокать без хаков.
        В этом случае конечно да, но даже в вашем случае, намного важнее иметь четко обозначенный контракт сервиса, нежели фактическое место его инстанциирования. Если такой контракт есть, то вытащить контруктор в контейнер проблем не составит. А ведь могут написать и так, что в интерфейсе будет куча методов, которые нужны только конкретному клиенту. Первое что приходит в голову:
        if (!service.isInitialized()){
        service.init(/* some parameters или например this*/);
        // А текущйи тип что-нить реализует типа IParameterSource
        }
        И вроде DI и все за интерфейсами с контейнером, а в результате сложно трассируемая дичь. Хотя тестами покрыть такую муть не проблема.

        Код вроде такого, заменить на использование контейнера сильно проще.
        new Service(AppContext.param1(), AppContext.patam2())
        Лаконичный простой и понятный код легче модифицировать, чем пытаться что-то реализовать на будущее.

        Т.е. я к тому, что DI далеко не панацея, если пишут г-но делы, то они и с DI такого понаделают.


    1. Vi3heim6
      04.01.2019 10:03

      Хабр давно уже скатился в лютое говно, где каждый только пиарится, создавая бесмыссленные статейки для SEO и набивания трафика.


  1. johnfound
    04.01.2019 20:48
    -1

    Не знаю, но термин "dependency injection" мне звучит как законная цель антивируса. :D


  1. Throwable
    04.01.2019 20:58

    Третий способ: пусть кто-нибудь ещё обрабатывает зависимости вместо нас

    Недостатки

    Неа. Основной недостаток всех DI-фреймворков — неявное динамическое связывание в рантайме. Это затрудняет анализ и рефакторинг кода, ослабляет контроль над выполнением, и делает возможным компиляцию и запуск кода с невалидными зависимостями. Хотя есть фреймворки типа Dagger, которые делают связывание при компиляции.


    Для простого DI вообще не нужно никакого фреймворка. Делаете класс-контекст ручками так, как вам это заблагорассудится, и определяете в нем все зависимости. Например так:


    // лучше назвать его ApplicationContext
    class OurThirdPartyGuy {
      fun provideClassC(){
        return ClassC() //just creating an instance of the object and return it.
      }
    
      // каждый метод должен возвращать уже полностью настроенный объект и не содержать параметров
      fun provideClassB(){
        return ClassB(provideClassC())
      }
    
      fun provideClassA(){
        return ClassA(provideClassB())
      }
    }

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


    1. shapovalex
      04.01.2019 22:41

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

      По пунктам:
      Анализ и рефакторинг кода — если понимать как работает фреймворк — то ни капельки не затрудняет. Даже наоборот.
      Ослабляет контроль над выполнением — каким образом?
      Компиляцию и запуск кода с невалидными зависимостями — для этого есть тесты. Напортачили с зависимостями — тесты упали. Если тестов нет — это обнаружится при стартапе приложения. Деплоить сразу в прод никто же не станет?)

      Но при необходимости все это так же не составит труда сделать ручками.

      Зачем? Просто зачем писать руками то, что написано давно за нас?


      1. Throwable
        04.01.2019 11:09

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

        Очень даже. Возьмите чужой код, и попробуйте сразу разобраться, как он работает. Для анализа так или иначе вам придется использовать функции поиска вашей IDE типа "Find Usages...". При использовании фреймворка, где объекты не создаются въявную в коде и не содержат статических ссылок, такой анализ станет более трудоемким.


        Ослабляет контроль над выполнением — каким образом?

        Последовательность инициализации бинов не определена (бывают случаи, где это важно, например инициализация сервисов при стартапе), еще хуже обстоят дела с финализацией — не определено когда стопаются объекты и в какой последовательности. Концепция "областей живучести" (scopes — не знаю как перевести) работает не всегда так, как ожидается, например если вы инъектируете прототайп-бин в синглтон, то он такаже становится синглтонам, что критично для не thread-safe объектов. Использование AOP позволяет динамически модифицировать поведение объекта, что иногда запутывает (некоторые умудряются засандалить в AOP бизнес-логику).


        Компиляцию и запуск кода с невалидными зависимостями — для этого есть тесты.

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


        Зачем? Просто зачем писать руками то, что написано давно за нас?

        А что такого супер магического написано-то? Как я уже сказал, типичный DI-фреймворк автоматически, то есть неявно, создает контекст приложения на основании большого числа факторов: модулей, правил, файлов конфигурации, аннотаций объектов, сканирования classpath… Поэтому на этапе чтения кода очень трудно определить как именно будет построен контекст. И это есть явный антипаттерн. А если убрать автоконфигурацию, то от вашего фреймворка ничего и не останется.


        P.S. я не против DI-фреймворков, сам давно использую Guice. Но меня жутко бесит, когда для связи трех бинов сразу тащат в проект какой-нибудь Spring.