Программирование
Что такое Внедрение зависимостей (Dependency Injection) и как это использовать в разработке?
Внедрение зависимостей (DI) — это метод, широко используемый в программировании и хорошо подходящий для разработки приложений. Следуя принципам DI, вы закладываете основу для хорошей архитектуры приложения.
Что такое Внедрение зависимостей (Dependency injection, DI)? Согласно Википедии:
Внедрение зависимости — процесс предоставления внешней зависимости программному компоненту. Является специфичной формой «инверсии управления» (Inversion of control, IoC), когда она применяется к управлению зависимостями. В полном соответствии с принципом единственной обязанности объект отдаёт заботу о построении требуемых ему зависимостей внешнему, специально предназначенному для этого общему механизму.
Внедрение зависимостей
Внедрение зависимостей (DI) — это метод, широко используемый в программировании и хорошо подходящий для разработки приложений. Следуя принципам DI, вы закладываете основу для хорошей архитектуры приложения.
Внедрение зависимостей дает вам следующие преимущества:
- Возможность повторного использования кода
- Легкость рефакторинга
- Легкость тестирования
Основы внедрения зависимостей
Прежде чем конкретно рассматривать внедрение зависимостей в той или иной платформе, давайте поймем, как работает внедрение зависимостей в общем смысле.
Классы часто требуют ссылок на другие классы. Например, классу Car может потребоваться ссылка на класс Engine. Эти обязательные классы называются зависимостями, и в этом примере класс Car зависит от наличия экземпляра класса Engine для запуска.
У класса есть три способа получить нужный объект:
- Класс конструирует нужную ему зависимость. В приведенном выше примере Car создаст и инициализирует собственный экземпляр Engine.
- Перехватит его откуда-то еще. Некоторые Android API, такие как методы получения Context и getSystemService(), работают таким образом.
- Укажет его как параметр. Приложение может предоставить эти зависимости при создании класса или передать их функциям, которым нужна каждая зависимость. В приведенном выше примере конструктор Car получит Engine в качестве параметра.
Третий вариант — это и есть внедрение зависимостей! При таком подходе вы берете зависимости класса и предоставляете их, а не позволяете экземпляру класса получать их самому.
Вот пример. Без внедрения зависимостей представление Car, которое создает свою собственную зависимость Engine в коде, выглядит следующим образом:
class Car { private val engine = Engine() fun start() { engine.start() } } fun main(args: Array) { val car = Car() car.start() }
Это не пример внедрения зависимостей, потому что класс Car создает свой собственный Engine. Это может быть проблематично, потому что:
- Car и Engine тесно связаны — экземпляр Car использует один тип Engine, и подклассы или альтернативные реализации использовать уже сложно. Если бы Car конструировал собственный Engine, вам пришлось бы создать два типа автомобилей вместо того, чтобы просто повторно использовать один и тот же автомобиль для двигателей типа Gas и Electric.
- Жесткая зависимость от Engine затрудняет тестирование. Car использует реальный экземпляр Engine, что не позволяет вам использовать тестовый двойник для изменения Engine в различных тестовых случаях.
Как выглядит код с внедрением зависимостей? Вместо того, чтобы каждый экземпляр Car конструировал свой собственный объект Engine при инициализации, он получает объект Engine в качестве параметра в своем конструкторе:
class Car(private val engine: Engine) { fun start() { engine.start() } } fun main(args: Array) { val engine = Engine() val car = Car(engine) car.start() }
Функция main использует Car. Поскольку Car зависит от Engine, приложение создает экземпляр Engine, а затем использует его для создания экземпляра Car.
Преимущества этого подхода на основе DI:
- Возможность повторного использования Car. Вы можете перейти от Engine к Car. Например, вы можете определить новый подкласс Engine под названием ElectricEngine, который вы хотите использовать в Car. Если вы используете DI, все, что вам нужно сделать, это передать экземпляр обновленного подкласса ElectricEngine, и Car по-прежнему будет работать без каких-либо дальнейших изменений.
- Простое тестирование Car. Вы можете передать тестовые двойники, чтобы проверить свои различные сценарии. Например, вы можете создать тестовый двойник Engine под названием FakeEngine и настроить его для различных тестов.
Есть два основных способа внедрения зависимостей в Android:
- Constructor Injection (инъекция конструктора). Это способ, описанный выше. Вы передаете зависимости класса его конструктору.
- Field Injection (или Setter Injection, полевая инъекция). Некоторые экземпляры определенных классов платформы Android, таких как активити или фрагменты, создает сама система, поэтому внедрение конструктора невозможно. При полевой инъекции зависимости создаются после создания класса. Код будет выглядеть так:
class Car { lateinit var engine: Engine fun start() { engine.start() } } fun main(args: Array) { val car = Car() car.engine = Engine() car.start() }
Автоматическая инъекция зависимостей
В предыдущем примере мы сами создавали, предоставляли и управляли зависимостями различных классов, не полагаясь на библиотеку. Это называется внедрением зависимостей вручную или ручным внедрением зависимостей. В примере Car была только одна зависимость, но большее количество зависимостей и классов может сделать ручную инъекцию зависимостей сложной. Внедрение зависимостей вручную также создает несколько проблем:
- В случае больших приложений для правильного использования всех зависимостей и их правильного подключения может потребоваться большой объем стандартного кода. В многоуровневой архитектуре, чтобы создать объект для верхнего уровня, вы должны предоставить все зависимости нижележащих слоев. Например, чтобы построить настоящий автомобиль, вам могут понадобиться двигатель, трансмиссия, шасси и другие детали; а двигателю, в свою очередь, нужны цилиндры и свечи зажигания.
- Когда вы не можете построить зависимости перед их передачей — например, при использовании ленивых инициализаций или при привязке объектов к потокам вашего приложения — вам необходимо написать и поддерживать настраиваемый контейнер (или граф зависимостей), который управляет временем жизни зависимостей в памяти.
Существуют библиотеки, которые решают эту проблему, автоматизируя процесс создания и предоставления зависимостей. Их можно разделить на две категории:
- Решения на основе отражения, которые связывают зависимости во время выполнения.
- Статические решения, которые генерируют код для подключения зависимостей во время компиляции.
Dagger — это популярная библиотека внедрения зависимостей для Java, Kotlin и Android, поддерживаемая Google. Dagger упрощает использование DI в вашем приложении, создавая и управляя графом зависимостей для вас. Он обеспечивает полностью статические зависимости и зависимости во время компиляции, решая многие проблемы разработки и производительности решений на основе отражения, таких как Guice.
Альтернативы внедрению зависимостей
Альтернативой внедрению зависимостей является использование локатора служб. Шаблон проектирования локатора служб также улучшает отделение классов от конкретных зависимостей. Вы создаете класс, известный как локатор сервисов, который создает и хранит зависимости, а затем предоставляет эти зависимости по запросу.
object ServiceLocator { fun getEngine(): Engine = Engine() } class Car { private val engine = ServiceLocator.getEngine() fun start() { engine.start() } } fun main(args: Array) { val car = Car() car.start() }
Локатор сервисов отличается от внедрения зависимостей способом потребления элементов. С локатора сервисов классы получают контроль и запрашивают объекты для внедрения а с внедрением зависимостей приложение получает контроль и активно внедряет необходимые объекты.
По сравнению с внедрением зависимостей:
- Набор зависимостей, требуемый локатору сервисов, затрудняет тестирование кода, поскольку все тесты должны взаимодействовать с одним и тем же глобальным локатором сервисов.
- Зависимости кодируются в реализации класса, а не на поверхности API. В результате извне сложнее узнать, что нужно классу. В результате изменения в Car или зависимостях, доступных в локаторе служб, могут привести к сбоям во время выполнения или тестирования, вызывая сбои ссылок.
- Управлять временем жизни объектов сложнее, если вы хотите ограничиться чем-либо, кроме времени жизни всего приложения.
Заключение
Внедрение зависимостей дает вашему приложению следующие преимущества:
- Возможность повторного использования классов и разделение зависимостей: проще поменять местами реализации зависимости. Повторное использование кода улучшено благодаря инверсии управления, и классы больше не контролируют создание своих зависимостей, а вместо этого работают с любой конфигурацией.
- Легкость рефакторинга: зависимости становятся проверяемой частью поверхности API, поэтому их можно проверять во время создания объекта или во время компиляции, а не скрывать как детали реализации.
- Легкость тестирования: класс не управляет своими зависимостями, поэтому, когда вы его тестируете, вы можете передавать различные реализации для тестирования всех ваших различных случаев.
Что еще почитать про внедрение зависимостей
- Внедрение зависимости и реализация единицы работы с помощью Castle Windsor и NHibernate
- Знакомимся с Needle, системой внедрения зависимостей на Swift
- Dagger 2 для начинающих Android разработчиков. Внедрение зависимостей. Часть 1 и Часть 2
- Как уменьшить время холодного старта Android-приложения на 28%
- Внедрение зависимостей с DITranquillity
- Изучение внедрение зависимостей в Android – Dagger, Koin и Kodein
- Внедрение зависимостей (dependency injection) в Swift 5.1