Вступление
Привет, меня зовут Иван Курак, я Android-разработчик приложения Ozon Job. Если в первой статье мы разбирали фреймворк Koin, то во второй на наш исследовательский стол попадёт Dagger, который мы используем на большинстве проектов Ozon, в том числе на приложении Ozon Курьер Экспресс, за которое отвечает наш отдел. Это мобильное приложение, которое позволяет курьерам-фрилансерам и водителям службы доставки взять подработку в Ozon и доставлять экспресс-заказы от селлера напрямую клиенту или в ПВЗ Ozon.
В проекте мы используем Dagger 2, чтобы собирать общие компоненты и навигацию между модулями, изолировать зависимости, улучшить тестируемость и поддерживаемость.
Его важный плюс в том, что он строит дерево зависимостей и может в момент компиляции узнать, какие зависимости достижимы, а какие нет. Стоит сказать, что мы не будет разбирать механизм кодогенерации, а сосредоточимся именно на классах, которые Dagger 2 создаёт для своей работы.
Эта статья будет полезна тем, кто использует Dagger 2 в своих приложениях и иногда/часто попадает в ситуации непонимания, почему Dagger 2 ведёт себя не так, как мы ожидаем. А это может создавать определённые трудности, особенно при отладке сложных проблем или при необходимости настройки более сложных сценариев внедрения зависимостей.
Например, в приложении Ozon Курьер Экспресс ведутся большие работы по переписыванию приложения на новую архитектуру. Поэтому рядом с существующей DI-архитектурой появилась вторая DI-архитектура. Чтобы их подружить, пришлось более подробно узнать, что генерирует Dagger 2.
Дополнительная (но не менее важная) цель статьи — показать, что базовый код, который генерирует Dagger, не такой уж и страшный :).
Как и в статье про Koin, мы напишем DI для такой группы классов:
// Интерфейс для зависимости
interface Engine {
fun start()
}
// Реализация зависимости
class ElectricEngine : Engine {
override fun start() {
println("Запуск электрического двигателя")
}
}
// Реализация зависимости
class GasolineEngine : Engine {
override fun start() {
println("Запуск бензинового двигателя")
}
}
// Класс, использующий зависимость
class Car(private val engine: Engine) {
fun start() {
engine.start()
}
}
Всё довольно просто: у нас есть Car
, и ему нужен какой-то тип двигателя.
Кратко о Dagger 2 и Dependency Injection
Что такое Dagger 2?
Dagger 2 — это мощный фреймворк для инъекции зависимостей, который использует компонентную архитектуру, его можно сравнить со швейцарским ножом. Когда вам нужна определённая зависимость, Dagger 2 готов предоставить её вместе со всеми необходимыми деталями.
Компонент в Dagger 2 — это такой набор инструментов, где каждый инструмент специализируется на определённой задаче. И когда вы запрашиваете объект (инструмент), Dagger 2 не только предоставляет его, но и обеспечивает все необходимые зависимости (детали для правильной работы инструмента), как будто он автоматически собирает и подготавливает инструмент перед использованием.
Dagger 2 является реализацией паттерна Dependency Injection. Dependency Injection — паттерн, сутью которого является получение зависимостей внутри конструктора или получение их через setter’ы. При работе с данным паттерном наш объект не знает, откуда к нему приходят его зависимости, из-за чего наш класс не является ответственным за то, как создать необходимые ему зависимости, он просто их получает.
Его простая реализация могла бы выглядеть так:
fun main() {
val component = Component.Builder()
.build()
component.getCar().start()
}
object CarModule {
fun provideCar(engine: Engine): Car = Car(engine)
}
object EngineModule {
fun provideEngine(): Engine = ElectricEngine()
}
class Component private constructor() {
fun getEngine(): Engine = EngineModule.provideEngine()
fun getCar(): Car = CarModule.provideCar(getEngine())
class Builder {
fun build(): Component {
return Component()
}
}
}
Что здесь происходит?
У нас есть класс Component
, у которого определено несколько функций, чтобы просто получить необходимые нам экземпляры. Имея только экземпляр класса Component
, мы можем вызвать нужный нам метод, чтобы получить интересующую нас зависимость. А как эта зависимость была создана, от нас полностью скрыто, и не является ответственностью разработчика, который использует Component
.
Пусть вас не смущает, что мы сразу создаём класс Component, а не интерфейс. Dagger’у необходим именно интерфейс, поскольку его реализацию он генерирует сам, но в данной статье было решено упростить этот момент.
Inject в public-поля
Часто можно увидеть, как люди, пользуясь Dagger 2, делают Inject
прямо в поля, как на примере ниже.
Только для объяснения данного механизма мы переместили Engine
из конструктора в переменную:
@Component
interface MyComponent {
fun inject(car: Car)
}
class Car {
@Inject
lateinit var engine: Engine
fun start(){
engine.start()
}
}
Попробуем повторить данное поведение. Для этого создадим отдельный класс, который назовём CarMembersInjector
.
object CarMembersInjector {
fun inject(car: Car, engine: Engine){
car.engine = engine
}
}
И нам остаётся только вызвать метод inject
из нашего Component
.
class Component private constructor(...) {
fun getEngine(): Engine
fun inject(car: Car) {
CarMembersInjector.inject(car, getEngine())
}
}
Зачем нужна отдельная абстракция и почему не присвоить значение прямо внутри Component
?
Для того чтобы иметь возможность эту логику переиспользовать, представим, что ваш класс Car является родителем для какого-то другого класса.
Например, так:
open class Car {
@Inject
lateinit var engine: Engine
open fun start(){
engine.start()
}
}
class OtherTypeCar: Car() {
@Inject
lateinit var supportEngine: Engine
override fun start() {
engine.start()
supportEngine.start()
}
}
Да, пример абсурдный, но тем не менее — как прокинуть зависимости и в OtherTypeCar
и Car
?
Ответ: просто для каждого класса вызвать соответствующие MembersInjector
-классы.
class Component private constructor(...) {
fun getEngine(): Engine
fun inject(car: OtherTypeCar) {
OtherTypeCarMembersInjector.inject(car, getEngine(), getEngine())
}
}
object OtherTypeCarMembersInjector {
fun inject(car: OtherTypeCar, engine: Engine, supportEngine: Engine){
car.supportEngine = supportEngine // Вставка своих зависимостей
CarMembersInjector.inject(car, engine) //Вставка родительских зависимостей
}
}
И на этом всё. Теперь понятно, почему данные переменные должны быть неприватными, ведь для вставки зависимостей нам приходится обращаться к ним напрямую извне.
Inject в protected-поля
Возможно, некоторые скажут, что они помечают переменные модификатором protected, и Dagger 2 всё равно сможет вставить значение в поле.
Это выглядит как-то так:
class Car{
@Inject
protected lateinit var engine: Engine
}
Ответ кроется в модификаторе protected и в том, что наша кодогенерация генерирует Java-код (по крайней мере, на момент написания статьи).
Работа модификатора protected немного отличается в Java и Kotlin. Как в Java, так и в Kotlin переменные, помеченные модификатором protected, видны наследникам. Но в Java переменные, помеченные модификатором protected, будут видны также на уровне пакета, то есть если в рамках одного пакета создать Java-класс, он сможет вытянуть из вашего экземпляра protected-переменные. И сюрприз! Dagger 2 генерирует наши MembersInjector
’ы в тех же пакетах, где и лежат наши классы.
Подмена зависимостей
В данной статье мы не будем рассматривать, как отличить реализации между собой. Как в случае, когда хотим получить экземпляр классаEngine
, но не знаем, какой именно ElectricEngine
или GasolineEngine
. Ведь при создании компонента ручками мы сами прекрасно знаем, какую функцию вызывать, чтобы получить правильный экземпляр.
class EngineModule {
fun provideElectricEngine(): Engine = ElectricEngine()
fun provideGasolineEngine(): Engine = GasolineEngine()
}
class Component private constructor(...) {
fun getEngine(): Engine = /*Решать разработчику откуда получать экземпляр из EngineModule.provideElectricEngine или EngineModule.provideGasolineEngine*/
fun getCar(): Car = CarModule.provideCar(getEngine())
}
Для этих целей процессор аннотаций Dagger 2 использует аннотацию @Qualifier
, которая просто даёт понимание, как сгенерировать код, чтобы правильный метод был вызван для правильного места.
Рассмотрим одну интересную фичу, которая есть в Dagger 2.
Представим, что мы бы хотели при создании компонента иметь возможность заменить определённый модуль на другую реализацию. Для этого перепишем наш EngineModule
, сделав его открытым классом и изменив способ работы с EngineModule
внутри компонента таким образом, чтобы реализацию можно было подменить.
open class EngineModule {
open fun provideEngine(): Engine = ElectricEngine()
}
class Component private constructor(
private val engineModule: EngineModule
) {
fun getEngine(): Engine = engineModule.provideEngine()
<...>
class Builder {
private var engineModule: EngineModule? = null
fun engineModule(engineModule: EngineModule): Builder{
this.engineModule = engineModule
return this
}
fun build(): Component {
if (engineModule == null){
engineModule = EngineModule()
}
return Component(engineModule!!)
}
}
}
Теперь у нас есть возможность наследоваться от EngineModule
и заменить реализацию метода provideEngine
(), и при создании нашего компонента мы сможем заменить наш ElectricEngine
на другую реализацию. А если ничего не передать, то получим создание экземпляра по умолчанию. Что ж, давайте заменим наш ElectricEngine
на GasolineEngine
.
class OtherEngineModule: EngineModule() {
override fun provideEngine(): Engine = GasolineEngine()
}
val component = Component.Builder()
.engineModule(OtherEngineModule())
.build()
Как повторить это в Dagger 2
Чтобы в Dagger 2 повторить эту логику, достаточно просто наш Module сделать открытым классом.
@Module
open class EngineModule{
@Provides
open fun providerEngine(): Engine {
return ElectricEngine()
}
}
object OtherEngineModule: EngineModule(){
override fun providerEngine(): Engine {
return GasolineEngine()
}
}
После пересборки у билдера появится новый метод для передачи экземпляра любого наследника от EngineModule
. Если вы переопределяли@Component.Builder
у Component, придётся его дописать ручками.
Возможность создать синглтон
Попробуем завести логику создания синглтонов, но для начала заведём некую сущность которую назовемProvider
.
interface Provider<T> {
fun get(): T
}
Реализации данного интерфейса будут являться поставщика зависимостей
Научимся сначала создавать factory-объекты. Создадим новый интерфейс Factory
.
interface Factory<T>: Provider<T>
Вы спросите: «Зачем для интерфейса Provider
создавать такого наследника, как Factory
, который не приносит ничего нового, а, по сути просто меняет имя?».
Это я объясню чуть позже.
Пока для примера создадим реализацию Factory
для класса Car
.
class CarFactory private constructor (
private val engineProvider: Provider<Engine>
): Factory<Car>{
override fun get(): Car = CarModule.provideCar(engineProvider.get())
companion object {
fun create(engineProvider: Provider<Engine>): CarFactory {
return CarFactory(engineProvider)
}
}
}
Через метод create
мы создаём инстанс CarFactory
и затем, когда нам будет нужен новый экземпляр нашего Car
, мы у нашей фабрики просто дёргаем метод get
.
Как можно заметить, нашей фабрике нужен экземпляр Provider<Engine>
. Что ж, давайте и его реализуем!
class EngineFactory private constructor (
private val engineModule: EngineModule
): Factory<Engine> {
override fun get(): Engine = engineModule.provideEngine()
companion object {
fun create(engineModule: EngineModule): EngineFactory {
return EngineFactory(engineModule)
}
}
}
Поскольку в предыдущей главе мы заменили реализацию EngineModule
на класс, мы передали его экземпляр как параметр конструктора, теперь нам нужен его конкретный экземпляр, чтобы получать из него экземпляр класса Engine
Перепишем наш Component
, чтобы можно было работать с нашими фабриками.
class Component private constructor(
private val engineModule: EngineModule
) {
private var engineProvider: Provider<Engine> = EngineFactory.create(engineModule)
private val carProvider: Provider<Car> = CarFactory.create(engineProvider)
fun getEngine(): Engine = engineProvider.get()
fun getCar(): Car = carProvider.get()
<...>
}
Теперь при вызове метода getCar
мы пойдём в наш carProvider
. Он внутри себя обратится к engineProvider
, а этот в свою очередь обратится к engineModule
, и мы получим наш Car
.
Создание factory-зависимостей без создания Provider
Возможно, по данным классам не совсем очевидно, для чего нужны Factory
-экземпляры. Ведь можно создавать экземпляры Car
и Engine
, и не используя Factory
-экземпляры. У Dagger 2 есть такая оптимизация ( для ее использования используется аннотация @Bind или вставка в конструктор c аннотации @inject ). В нашем примере она бы выглядела вот так:
class CarFactory private constructor (...): Factory<Car>{
<...>
companion object {
fun providerCar(engine: Engine): Car {
return CarModule.provideCar(engine)
}
}
}
class EngineFactory private constructor(...): Factory<Engine> {
<...>
companion object {
fun providerEngine(engineModule: EngineModule): Engine {
return engineModule.provideEngine()
}
}
}
И наши функции внутри Component
выглядели бы примерно так:
private val engineModule: EngineModule
) {
fun getEngine(): Engine = EngineFactory.providerEngine(engineModule)
fun getCar(): Car = CarFactory.providerCar(getEngine())
Другими словами, хочешь что-то создать — иди к Factory
, даже если Factory
- экземпляр тебе не нужен.
Попробуем теперь создать наш Car
как синглтон и для этого создадим ещё одну реализацию Provider
.
//В Dagger данный класс назван DoubleCheck как алгоритм что он использует для синхронизации
class Singleton<T> private constructor(
@Volatile private var provider: Provider<T>?
): Provider<T> {
@Volatile private var instance: Any = UNINITIALIZED
override fun get(): T {
//Проверка создан ли уже экземпляр
if (instance == UNINITIALIZED) {
synchronized(this) {
//Вторая проверка но уже внутри блока synchronized
if (instance == UNINITIALIZED) {
instance = provider!!.get() as Any
provider = null
}
}
}
@Suppress("UNCHECKED_CAST")
return instance as T
}
companion object {
private val UNINITIALIZED = Any()
}
}
Мы видим, что наш Singleton
создаёт Singleton, используя алгоритм DoubleCheck, чтобы гарантировать, что в многопоточной среде мы точно создадим только один экземпляр.
Как работает алгоритм DoubleCheck в многопоточной среде
Представим, что у нас есть два потока. Войдя в метод, оба потока проверяют, была ли проинициализирована наша переменная, сравнив наш instance c неинициализированным состоянием.
if (instance == UNINITIALIZED)
Поскольку она неинициализированная, на двух потоках мы упрёмся в блок synchronized.
Дальше наш код поочерёдно работает с каждым потоком. Один поток будет ждать, пока другой поток выйдет из synchronized
-блока.
Что же делает поток, который первый зашёл в блок synchronized
? Он проверит снова, была ли уже проинициализирована переменная.
synchronized(this) {
//Вторая проверка но уже внутри блока synchronized
if (instance == UNINITIALIZED) {
<...>
}
}
И поскольку она всё ещё равна UNINITIALIZED
, мы пройдём в if-блок и внутри данного блока как раз и произойдёт инициализация нашей переменной и присвоение ей значения.
instance = provider!!.get() as Any
Поскольку наша переменная помечена как @Volatile
(чтобы избежать inline
-конструктора (подробнее здесь)), её изменения будут видны во всех потоках. После этого первый поток выходит из блока synchronized
, и теперь очередь в него зайти второму потоку, второй поток также проверит, была ли уже проинициализирована переменная, и увидит, что теперь наш инстанс отличен от UNINITIALIZED
, поэтому в блок if
мы не попадём, и мы просто выйдем из блока synchronized
.
Таким образом, мы получаем способ создавать один экземпляр в многопоточной среде.
В своей реализации я упростил метод создания Singleton-объекта, оставив только механизм DoubleCheck. В оригинальном методе также имеется проверка на циклическую инициализацию нашего Singleton-объекта, но воспроизвести ситуацию, от которой данная проверка спасает, мне не удалось. Поэтому буду признателен, если вы напишете в комментариях, как это можно сделать.
Добавим ещё один метод, чтобы нам было удобно создавать наш синглтон Provider
:
class Singleton<T> private constructor(
@Volatile private var provider: Provider<T>?
): Provider<T> {
<...>
companion object {
fun <T> provider(provider: Provider<T>): Provider<T>{
if (provider is Singleton) return provider
return Singleton(provider)
}
}
}
Простая проверка, что если наш provider
уже является экземпляром Singleton
, не создавать новый класс, а использовать существующий, ну или в противном случае мы создаём новый экземпляр.
Теперь будем создавать наш Engine
как Singleton. для этого в Component
внесём маленькое изменение:
class Component private constructor(
private val engineModule: EngineModule
) {
/* было */
private var engineProvider: Provider<Engine> = EngineFactory.create(engineModule)
/* стало */
private var engineProvider: Provider<Engine> = Singleton.provider(EngineFactory.create(engineModule))
private val carProvider: Provider<Car> = CarFactory.create(engineProvider)
fun getEngine(): Engine = engineProvider.get()
fun getCar(): Car = carProvider.get()
<...>
}
Получается, что в момент создания нашего экземпляра Component
, мы сразу создаём Singleton
-провайдер, и при первом же вызове метода engineProvider.get
() мы и создадим нашу реализацию. И как можно заметить, при каждом вызове метода getCar
(),мы будет создавать новый экземпляр класса Car
с одним и тем же экземпляром Engine
.
Теперь вернёмся к вопросу, который был задан чуть выше: «Зачем нам нужно для интерфейса Provider
такой наследник как Factory
, который не приносит ничего нового, а, по сути, просто меняет имя?».
Покажу на примере, чего мы хотели добиться.
val engineProvider: Singleton<Engine> = TODO()
check(engineProvider is Provider<*>) // всегда true
check(engineProvider is Factory<*>) // всегда false
То есть, другими словами, для классов, которые являются реализацией Factory
-интерфейса, мы создали более строгое разделение классов и теперь, какую бы мы реализацию Provider
не взяли, мы можем быть уверены, что она не сможет сразу быть и Factory, и Singleton.
Ручное создание factory
Как вы, наверное, заметили, создавать для каждого Factory отдельную реализацию слишком времязатратно. Поэтому, если есть желание создать мануальный DI, похожий на тот, что рассматривается в статье, я бы посоветовал вам отказаться от интерфейса Factory в пользу следующего кода:
class Factory<T> private constructor(
private val newInstance: () -> T
): Provider<T> {
override fun get(): T = newInstance()
companion object {
fun <T> provider(newInstance: () -> T): Provider<T>{
return Factory(newInstance)
}
}
}
И модифицировать Component следующим образом.
class Component private constructor(
private val engineModule: EngineModule
) {
/* было */
private var engineProvider: Provider<Engine> = Singleton.provider(EngineFactory.create(engineModule))
private val carProvider: Provider<Car> = CarFactory.create(engineProvider)
/* стало */
private var engineProvider: Provider<Engine> = Singleton.provider(Factory.provider{ engineModule.provideEngine() })
private val carProvider: Provider<Car> = Factory.provider{ Car(engineProvider.get()) }
}
Создание SubComponent
Несмотря на то что самый распространенный способ связывания Compoent
'ов используя механизм Component Dependency, его ручная реализация имеет мало интересных мест. именно поэтому мы рассмотрим как создавать SubComponent
Для данного примера удалим из нашего Component
метод getCar
() вместе с его провайдером, а его получение Car
перенесём в SubComponent
. И напишем интерфейс нашего SubComponent
.
interface SubComponent {
fun getCar(): Car
interface Builder {
fun build(): SubComponent
}
}
Как мы видим, у нас есть SubComponent
, из которого мы можем получить наш экземпляр класса Car
, а чтобы создать наш SubComponent
, у нас есть Builder
.
Давайте напишем ему реализацию.
class Component private constructor(...) {
<...>
fun getSubComponentBuilder(): SubComponent.Builder = SubComponentImpl.BuilderImpl(this)
private class SubComponentImpl private constructor(
private val component: Component
): SubComponent {
private val carProvider: Provider<Car> = CarFactory.create(component.engineProvider)
override fun getCar(): Car = carProvider.get()
class BuilderImpl(
private val component: Component
): SubComponent.Builder {
override fun build(): SubComponent {
return SubComponentImpl(component)
}
}
}
Обратим внимание, что наш класс SubComponentImpl
содержит внутри себя вполне привычный нам код создания экземпляра класса Car
за исключением того нюанса, что мы обращаемся к engineProvider
не напрямую, а через наш класс Component
.
Также видим, что мы добавили в наш Component
метод getSubComponentBuilder
(), который возвращает нам экземпляр SubComponent.Builder
, чтобы при необходимости мы могли сами добавить в Builder
необходимые зависимости.
Как можно заметить, нашему SubComponentImpl
необходима прямая ссылка на Component
, чтобы брать из него зависимости. И поскольку это вложенный класс, имея прямую ссылку на свой внешний класс, мы можем обращаться к его приватным полям.
Проброс параметров
Попробуем разобрать ещё один кейс. Иногда мы хотим создать экземпляр с зависимостью, которая известна нам только в момент создания экземпляра — в Dagger 2 это реализовано с помощью аннотаций: @Assisted/@AssistedInject/@AssistedFactory
Попробуем реализовать и такой сценарий. Для этого модернизируем наш класс Car
.
class Car(
private val engine: Engine,
private val name: String
)
Чтобы реализовать данную возможность, создадим интерфейс-фабрику и заменим функцию getCar
() из класса Component на getCarFactory
().
interface AssistedCarFactory {
fun create(name: String): Car
}
interface SubComponent {
/* было */
fun getCar(): Car
/* стало */
fun getCarFactory(): AssistedCarFactory
interface Builder {
fun build(): SubComponent
}
}
Напишем реализацию к нашему интерфейсу.
class AssistedCarFactoryImpl private constructor(
private val engineProvider: Provider<Engine>,
): AssistedCarFactory {
override fun create(name: String): Car = Car(engineProvider.get(), name)
companion object{
fun create(engineProvider: Provider<Engine>): AssistedCarFactory {
return AssistedCarFactoryImpl(engineProvider)
}
}
}
Реализация довольна очевидна: при создании AssistedCarFactoryImpl
мы передаём ему в конструктор engineProvider
, так как нам необходима эта зависимость для создания экземпляра класса Car
. Также мы реализуем метод create
, который просто создаёт экземпляр класса Car
.
Реализуем эту логику в нашем SubComponentImpl
и получаем работающий пример.
private class SubComponentImpl private constructor(
private val component: Component
): SubComponent {
private val carProvider: AssistedCarFactory = AssistedCarFactoryImpl.create(
engineProvider = component.engineProvider
)
override fun getCarFactory(): AssistedCarFactory = carProvider
}
Выводы
Какие механизмы Dagger 2 мы рассмотрели.
inject
в поля класса. Внедрения зависимостей в поля класса.Создание
Singleton.
МеханизмSingleton
, который гарантирует, что класс имеет только один экземпляр во время выполнения приложения. Это позволяет обеспечить единообразный доступ к ресурсам, которые должны быть доступны во всём приложении.Создание
SubComponent
. Он позволяет создавать более модульные и гибкие графы зависимостей.Работа
AssistedInject.
Механизм позволяет внедрять зависимости в объекты, которые создаются динамически и имеют параметры, которые не могут быть вложены вComponent
на этапе его создания.
В этой статье мы рассмотрели код, похожий на тот, что генерирует Dagger 2 в процессе кодогенерации. Несмотря на то, что написанный нами код кажется простым, писать руками реализацию каждому Component
занимает много времени, также как и всё это поддерживать. Поэтому хорошим вариантом использования данной DI-архитектуры является кодогенерация, чем и занимается Dagger 2. Но мануальный вариант имеет право на жизнь, например, в небольших приложениях или при написании своей библиотеки без зависимостей на сторонние технологии.