Дата публикации: 30-11-2022

Ладонь против Ножа: Ручной DI вместо Dagger

handdi.jpg

Год назад я написал пост про то что DI фреймворки не решают проблемы, а заменяют их новыми. На что многие мне ответили “а как же так? ты что, пишешь зависимости рУкАмИ? Ты что, Dagger пишет код за меня!”. Ну что ж, давайте я в новом посте покажу тебе, мой сомневающийся в силе фабрик читатель, как при помощи by-делегации в Kotlin можно писать кода не больше чем при использовании Dagger.

Покажите мне код!

После прошлой статьи меня упрекнули в том, что размышления есть, а кода нет. Поэтому этот пост я начну сразу с того что вкину пример. За основу я взял семпл Дениса Загаевского - его доклад на Мобиус 2021 я считаю сейчас самым актуальным руководством по Dagger на момент написания этого поста. Для меня важным было что в семпле есть многомодульность и разделение на api/implementation.

Знакомьтесь, фабрика

Если же код читать лень - давай разбираться на кофемашинах. Вот смотри, это - фабрика. Она может собирать для меня некоторую зависимость CoffeeMachine.

class Factory {
    fun buildMyCoffeeMachine(): CoffeeMachine
}

Добавляем зависимости зависимости

Но что делать, если моя кофемашина сама имеет какую-то зависимость? Например, некоторый CoffeeConfig? Я могу заставить того, кому нужна зависимость, предоставить нам его. В терминах Dagger это называется сложным словом AssistedInject. В терминах ручного DI это называется “добавить параметр”.

class Factory {
    fun buildMyCoffeeMachine(config: CoffeeConfig): CoffeeMachine
}

Добавляем зависимости фабрики

Ладно, конфиг ещё можно как часть API как-то переложить на вызывающего метод. Но если моей кофемашине нужен какой-нибудь WaterSupply, его точно не нужно таскать в каждое место, где требуется кофемашина. Тогда я могу передать WaterSupply в мою фабрику.

class Factory(val supply: WaterSupply) {
    fun buildMyCoffeeMachine(config: CoffeeConfig): CoffeeMachine
}

Соединяем две фабрики

Теперь давай представим что этот WaterSupply собирается в свою очередь какой-то другой фабрикой. Как мне соединить две фабрики между собой и не помереть под горами бойлерплейта?

class WaterFactory {
    fun buildWaterSupply(): WaterSupply
}
class CoffeeFactory(val supply: WaterSupply) {
    fun buildMyCoffeeMachine(config: CoffeeConfig): CoffeeMachine
}

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

Убираем бойлерплейт

Итак, перед тем как раскрыть секрет пути Открытой Ладони в DI, давай немного отрефакторим фабрику кофемашин. Я выделил ей интерфейс Dependencies, чтобы не таскать аргументы раз за разом.

class CoffeeFactory(val deps: Dependencies) {
    fun buildMyCoffeeMachine(config: CoffeeConfig): CoffeeMachine
    interface Dependencies {
        val waterSupply: WaterSupply
    }
}

Теперь WaterFactory достаточно заимплементить этот интерфейс и всё в шоколаде.

class WaterFactory : CoffeeFactory.Dependencies {
    override val waterSupply: WaterSupply
}

val coffeeMachine = CoffeeFactory(WaterFactory()).buildMyCoffeeMachine(config)

Щепотка делегации

Пока код я только добавлял, хотя глава называется “убираем бойлерплейт”. Но на самом деле я его перепрячу “под ковёр”. До этого я не показывал конструктор CoffeeMachine. А там вот что!

class CoffeeMachine(val config: CoffeeConfig, val deps: Dependencies) {
    interface Dependencies {
        val waterSupply: WaterSupply
    }
}

Ха-ха, вот это я тебя подколол, да? Отрефакторил код, ещё до того как показал его сниппет. Но теперь, когда карты на столе, маски сняты, и я застал тебя врасплох, позволь показать тебе ещё один трюк с рефакторингом “задним числом”. Взгляни ещё раз на фабрику кофемашин.

class CoffeeFactory(
    val deps: Dependencies
): CoffeeMachine.Dependencies,
   Dependencies by deps {
    fun buildMyCoffeeMachine(config: CoffeeConfig): CoffeeMachine =
        CoffeeMachine(config, this)
    interface Dependencies {
        val waterSupply: WaterSupply
    }
}

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

Но как же фича X из Dagger?

Я упоминал, что AssistedInject - это просто передача параметра в метод. Да, внимательный глаз мог заметить что для этого пришлось изменить конструктор. Но мне кажется что это нормальный трейд-офф. Почти точно так же просто покрывается большинство фич Dagger.

Named

Так как Dagger завязан на типы возвращаемого значения, чтобы разделить две зависимости одного типа, ему пришлось вводить Named. А теперь, следите за руками:

interface Dependencies {
    val waterSupplyA: WaterSupply
    val waterSupplyB: WaterSupply
}

Вау, две зависимости одного типа, без аннотаций, как мне это удаётся?

Scope

Помню как в своё время очень долго пытался разобраться, что же делают скоупы в Dagger. А вот что они делают в ручном DI:

class MyFactory: MyDependencies {
    // No scope
    override val dependencyA get() = DependencyA()
    // Scoped to MyFactory
    override val dependencyB = DependencyB()
}

Закрыть скоуп можно, просто потеряв ссылку на MyFactory, не благодари.

Provider

Если какая-то зависимость нужна не прям щас, а как-нибудь потом, то из предыдущего примера не сложно догадаться, что это легко решает by lazy.

class MyFactory: MyDependencies {
    override val someLazyDep by lazy { SomeLazyDep() }
}

И то и то

Надеюсь после прочтения этой статьи тебе стало писать код руками не так страшно. Наверное стоит ещё разок померить сколько занимают kaptGenerateStubs у тебя на проекте, сколько человекочасов было закопано в изучение Dagger и работу над его экосистемой. И спросить себя: оно того стоило? Я считаю, что нет.

Подпишись и обсуждай в Telegram