DI фреймворки бывают двух видов: те, что строят свой граф зависимостей во время компиляции (compile time фреймворки), и те, которые делают это уже при выполнении кода (runtime фреймворки).

Kodein — типичный представитель runtime фреймворков. Это значит, что о пропущенной зависимости вы узнаете непосредственно в процессе работы приложения, что может стать неприятным сюрпризом. Только представьте себе: вы пропустили на регрессе какой-то кейс, и у вас краш в продакшене из-за DI! Это же настоящий кошмар!

Но неужели нам придётся отказываться от Kodein или других runtime фреймворков? Что можно сделать, чтобы сделать их более надёжными? Есть ответ! Мы переведём наш DI из разряда runtime проверок в разряд deploy time проверок.

Это третья статья из цикла материалов про Kodein DI для Android:

Погнали.

DI граф

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

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

Теперь о целостности. Что мы подразумеваем под этим термином в контексте DI? Представим, что мы можем поговорить с нашим графом зависимостей:

— Уважаемый Граф, дай мне класс A!

— Упс, не могу помочь. Для создания A мне нужен класс B, который у меня отсутствует. Похоже, у меня проблемы с целостностью ?

Целостность графа в Kodein

Поговорим о целостности в терминах Kodein. Представьте, что мы с вами создали такой модуль:

fun moduleOfAuth() = DI.Module("authModule") {
	bind<PhoneEndpoint>() with singleton { 
	  instance<Retrofit>().create(PhoneEndpoint::class.java) 
	}
	
	bind<PhoneDataSource>() with singleton {
	  PhoneDataSourceImpl(
	       phoneEndpoint = instance(),
	       mapper = instance(),
	  )
	}
}

Для класса PhoneDataSourceImpl нужен mapper, но в самом модуле "authModule" mapper не описан. Если mapper не будет добавлен в граф другим модулем, или будет в родительском компоненте, то наш граф будет нецелостным, и в момент предоставления PhoneDataSourceImpl Kodein кинет исключение. Кстати, давайте договоримся далее называть такой подход Injection, потому что тут происходит именно внедрение зависимостей через конструктор.

Посмотрим на пример с другим подходом, который называют Retrieval (aka Service Locator).

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

class MyRepositoryImpl(di: DIAware) : MyRepository, DIAware by di { 
  ...
  private val endpoint by instance<Endpoint>()
  ...
}

Если в случае с описанием модуля, мы, читая код, можем понять, чего нам не хватает. То здесь, написав просто by instance(), вообще ничего не понятно. Есть ли у нас Endpoint, где его искать, в каком модуле и т.д.

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

Итого, что мы имеем:

  • В Kodein DI граф собирается в runtime'е, и проверка целостности происходит тоже только в runtime'е. Это слишком поздно.

  • Если все зависимости предоставлять через конструктор (Injection), мы хоть чуть-чуть получаем явный контроль над построением графа (потому что забытое поле в конструктор нам подскажет добавить компилятор). Если использовать Retrieval подход, то и этого нет.

  • И Injection, и Retrieval подходы не дают ответа на вопрос о целостности графа, кроме как в runtime'е.

Решаем проблемку

Давайте ещё раз зафиксируем, что Kodein — это runtime фреймворк по своей сути, и его не получится просто так переделать в compile time фреймворк. Но что если мы всё-таки будем проверять целостность графа раньше, чем на проде у наших пользователей? А что может быть позже compile time и раньше runtime? Правильно — deploy time! Мы можем превратить наш DI во фреймворке с deploy time проверками. Для нас это означает, что проверку целостности мы можем делать на CI.

Что же мы можем делать на CI? Варианты следующие:

  • Unit-тесты;

  • UI-тесты.

Безусловно, UI-тесты решат проблему. Но UI-тесты — это долго и дорого. А хочется дёшево и быстро. Значит будем писать Unit-тесты!

Интересно, что авторы и основные контрибьютеры Kodein говорят, что сделают это в следующей мажорной версии (потому что надо переработать внутренности). Однако мне хочется простое и понятное решение, которое я могу сделать сам и быстро.

Мы разработали способ, который включает в себя 2 шага:

  1. Используем только Injection подход. Переносим запрос зависимостей (Retrieval) в конструкторы и модули.

  2. Пишем Unit-тесты на проверку целостности графа!

Шаг 1. Используем только Injection подход

Итак, рассмотрим первый шаг, он простой и понятный. Вот так не делаем:

class RepositoryImpl(di: DIAware) : Repository, DIAware by di {
  ...
  private val datasource by instance<DataSource>()
  private val mapper by instance<Mapper>()
  ...

Делаем вот так:

class RepositoryImpl(
  private val datasource: DataSource,
  private val mapper: Mapper,
) : Repository {

// Module
bind<Repository>() with singleton {
  RepositoryImpl(
    datasource = instance(),
    mapper = instance(),
  )
}

Теперь компилятор нам подскажет, что в конструкторе чего-то не хватает. Это был быстрый и простой шаг, теперь переходим к интересному!

Шаг 2. Unit-тесты на DI

Давайте разберёмся, что именно нам надо протестировать.

Представим, что структура ваших DI графов выглядит примерно так:

  • AppDI. Это граф с зависимостями на уровне приложения. Для пользователей Dagger 2 это аналог AppComponent. Это обычно singleton скоуп и там находятся все общие зависимости.

  • ContextDI. Это граф с доменным контекстом. Т.е. это граф группы фич или раздела приложения. Например, онбординг, главный экран, авторизация. Он может быть, а может и не быть. Зависит от архитектуры вашего проекта.

  • FeatureDI. Это граф с зависимостями на уровне конкретной фичи, экрана.

На рисунке эти 3 DI графа связаны друг с другом отношением родитель-ребёнок. Это стандартный способ ветвления компонентов в Kodein. Оно делается через методы subDI, и это можно считать аналогом SubComponent в Dagger 2.

Давайте протестируем целостность каждого графа. И сразу напишем ответ (то, как мы хотим, чтобы выглядел наш DI тест):

  @Test
  fun `SubDI of ProductFragment has integrity`() = diTest {
    assertDIHasIntegrity(diSUT)
  }

Поясню, что есть что в этом коде:

  • Я хочу, чтобы проверка целостности графа не занимала больше одной строчки. Поэтому пусть будет единый метод для этого assertDIHasIntegrity.

  • Всё это, скорее всего, будет выполняться в некоем контексте, поэтому предположим, что у нас будет скоуп-метод diTest.

Придумаем алгоритм на схемке

Как подойти к тестированию одного графа? Представим, что наш граф выглядит так — есть 3 модуля с зависимостями. Справа изображено, как зависимости зависят друг от друга.

Теперь мы должны проверить, что для каждой зависимости в нашем графе есть все фабрики или провайдеры, которые могут предоставить такую зависимость. Грубо говоря, мы проходимся по каждой зависимости и проверяем, всего ли нам хватает. Это можно визуализировать таким образом:

На гифке видно, что для a1, a2, b1 — всего хватает. А вот для c1 не хватает зависимости d1.

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

Если во внешнем источнике есть недостающие зависимости, то всё ок — проверка прошла успешно. Если их там нет, то проверка не прошла.

Теперь напишем это в коде

Рассмотрим такой пример: для типа Presenter фабрика provider создаст нам PresenterImp с трёмя зависимостями, каждая из которых будет предоставлена через метод instance().

bind<Presenter>() with provider {
    PresenterImpl(
        mapper = instance(),
        voFactory = instance(),
        interactor = instance(),
    )
}

Нам нужно проверить, что в тех случаях, когда нам требуется тип Presenter, где-то в графе должны быть зависимости mapper, voFactory и interactor. Для этого нам нужно «перехватить» вызовы instance() и узнать, с каким типом они были вызваны. Если мы это сделаем, то мы получим весь список типов нужных для создания зависимости PresenterImpl. Для этого нам надо переопределить интерфейс DirectDIBase, потому что именно в нём есть методы Instance, которые нас интересуют.

public interface DirectDIBase : DirectDIAware {
  ...
  public fun <T : Any> Instance(type: TypeToken<T>, tag: Any? = null): T
  ...
}

Стандартная реализация интерфейса DirectDIBase это класс DirectDIBaseImpl, где, как вы видите, метод Instance вызывает у контейнера метод provider.

internal abstract class DirectDIBaseImpl: DirectDI {
    override fun <T : Any> Instance(type: TypeToken<T>, tag: Any?): T {
        return container.provider(
            key = DI.Key(
                contextType = context.anyType,
                argType = TypeToken.Unit,
                type = type,
                tag = tag,
            ),
            context = context.value
        ).invoke()
    }
}

Напишем свой класс, назовём его DirectDIWrapper. И сделаем там 2 вещи:

  • На каждый Instance мы вызовем наш колбек instanceCallback, который сообщит наружу, какой тип запрашивается. Это нам нужно, чтобы учесть, что наш тестируемый тип запрашивает такую-то зависимость.

  • Вернём не реальный объект, а mock. Потому что создать реальный объект может быть слишком сложно, да он нам и не нужен.

class DirectDIWrapper(
  private val _directDI: DirectDI,
  private val instanceCallback: (TypeToken<*>) -> Unit,
) : DirectDI by _directDI {
  ...
  override fun <A, T : Any> Instance(
    argType: TypeToken<in A>,
    type: TypeToken<T>,
    tag: Any?,
    arg: A,
  ): T {
    instanceCallback(type)
    val myClass: Class<T> = Class.forName(type.qualifiedDispString()) as Class<T>
    return mock(myClass)
  }

Теперь мы готовы написать реализацию нашей функции assertDIHasIntegrity. По сути она должна сделать одну вещь: проверить, что для каждого типа в графе (в Kodein это называется bindings) есть все зависимости. Прямо как мы смотрели на гифке выше. Это будет выглядеть так:

fun DITestContext.assertDIHasIntegrity(di: DI) {
  di.container.tree.bindings.forEach {
    assertContainsDependenciesFor(di, it.key.type, it.key.tag, it.key.argType)
  }
}

Распишем метод assertContainsDependenciesFor.

Шаг 1 — получение ключа. В Kodein ключ составной — он включает в себя тип контекста, тип аргумента, тип и тэг. Не будем останавливаться подробно на всех элементах, можете сами почитать про них в исходниках Kodein — там всё понятно.

  // 1
  val key = Key(
      contextType = TypeToken.Any,
      argType = argType,
      type = type,
      tag = tag,
  )

Шаг 2 — настройка нашего DirectDIWrapper класса. Мы его создаём и передаём лямбду. В ней мы будем проверять, есть ли искомый тип в списке наших зависимостей или нет (здесь в dependencies уже сложены все биндинги из di.container.tree.bindings).

  // 2
  val missing = mutableListOf<String>()
  val directWrapper = DirectDIWrapper(di.direct) { clazz: TypeToken<*> ->
    val name = clazz.qualifiedDispString()
    if (!dependencies.contains(name)) {
      missing.add(name)
    }
  }

Шаг 3 — запуск логики получения зависимостей в Kodein. Это делается через некоторые классы библиотеки, которые, к счастью, доступны нам снаружи.

  // 3
  val noArgBindingDI: NoArgBindingDI<Any> =
    NoArgBindingDIWrap(BindingDIImpl(directWrapper, key, 0))
  noArgBindingDI.apply {
    try {
      (diBinding as? Singleton<Any, T>)?.creator?.let { it() }
      (diBinding as? Provider<Any, T>)?.creator?.let { it() }
      ...
    } catch (e: Exception) {
      println(
          "There was an error while initializing binding $diBinding, " +
              "but for tests purposes it doesn't matter"
      )
    }
  }

Обратите внимание: мы создаём BindingDIImpl с нашим directWrapper классом и ключом key. Далее мы пытаемся по типу угадать, что это за фабрика, и вызвать её. Так мы запустим проход по всем instance методам, а наш directWrapper начнёт дёргать колбек.

Последним шагом мы должны проверить, каких зависимостей не нашлось, и показать это в ошибке.

  // 4
  if (missing.isNotEmpty()) {
    fail("Not all dependencies found for: ${T::class.java.canonicalName}. Missing: $missing")
  }

Опустив пару технических строк, мы увидим, что итоговый тест на нашу фичу/экран выглядит следующим образом:

class ProductFragmentDITest {

  ...
  
  // Тестируемый DI
  private lateinit var diSUT: DI

  // Контекст это набор типов и зависимостей, с которыми мы будем работать
  private lateinit var context: DITestContext

  @Before
  fun setup() {
    diSUT = createDI()

    // В создании контекста участвуют еще пара вспомогательных классов, которые
    // нужны для получения всех типов и зависимостей нашего графа.
    context = DITestContext(
        integrityRule = integrityRule,
        parentDI = Create.unitScope.di,
    )
  }

  // Сам тест, который занимает 1 строчку
  @Test
  fun `SubDI of ProductFragment has integrity`() = withDIContext(context) {
    assertDIHasIntegrity(diSUT)
  }
}

Выводы

  • В DI мире есть 2 стула: compile time стул и runtime. Если мы пользуемся runtime DI, то нам приходится думать о целостности графа. Но как убедиться, что с нашим графом всё в порядке? Ответ есть! Мы можем проверить целостность графа в deploy time, а именно на CI.

  • На CI можно гонять Unit-тесты и UI-тесты. Мы выбираем Unit-тесты, которые проверяют целостность графа, — они быстрые и надёжные.

  • Конечно, deploy time никогда не сравнятся с compile time проверками: вы можете забыть написать тест, CI может затупить, тест может не запуститься, где-то может закрасться ошибка. Однако deploy time проверки на CI во много раз круче, чем просто runtime. Они точно повысят стабильность — проверено на собственном опыте.

Мы рассмотрели, как сделать Unit-тесты на DI на примере фреймфорка Kodein. Но по этим же принципам можно сделать тесты в любом другом runtime фреймфорке, если у вас есть доступ к списку зависимостей.

Если вы пользуетесь Kodein и вам он кажется удобным, с классным API, то не стоит от него отказываться только из-за того, что нет compile time проверок на целостность графа. Вы можете написать Unit-тесты на это и перевести DI в статус deploy time проверок!


Спасибо, что дочитали статью! Если вам интересен мой опыт, но лень читать такие большие тексты, подписывайтесь на Telegram-канал «Мобильное чтиво». В нём я делюсь своими мыслями про Android-разработку и не только в формате постов.

Если вы интересуетесь последними новостями мобильной разработки в Dodo в целом, подписывайтесь на Telegram-канал Dodo Mobile. Там мой коллега Миша Рубанов рассказывает об интерфейсах, дизайне, доступности на самых разных платформах, а ещё делится вакансиями.

Это третья статья из цикла материалов про Kodein DI для Android:

Часть 1: Kodein DI для Android. Основы API

Часть 2: Kodein DI для Android. KMP и Compose

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


  1. spirit1984
    17.04.2024 14:00

    Из статьи так и не понял, какое преимущество у runtime фреймворков перед compile time? И настолько ли оно велико у того же Kodein, чтоб прям заморачиваться с deploy time подходом?


    1. maxkachinkin Автор
      17.04.2024 14:00

      Я бы сказал, что главное преимущество — простота использования. Я думаю, что возьми разработчика, который не работал с DI и дай документацию Dagger 2 и Kodein (или Koin) и засекай время, кто быстрее напишет работающий код.
      Другой вопрос "на столько ли оно велико" — ответ стандартный it depends... Надо смотреть. Например, если вас абсолютно всё устраивает во фреймворке и единственная претензия это целостность графа и больше нет претензий — то deploy time подход вполне себе норм.


      1. Nihiroz
        17.04.2024 14:00

        Если в простоту использования ещё добавить написание и споровождение тестов, то Dagger выигрывает


      1. serej_qa
        17.04.2024 14:00

        Koin и Kodein сервис локаторы, а не DI, в отличии от dagger. Если рассматривать dagger без различных "наворотов" типо dependent components, multibindings и т.д. то, возможно, dagger даже будет проще. Написал аннотацию Inject перед конструктором и фабрика для этого класса сгенерится в компаил тайме, можешь инжектить этот (и в этот) класс куда угодно. Добавил Singleton - вуаля, провайдится будет только единственный инстанс этого класса. Lazy/Provider и всякие другие удобные штуки добавляются просто из коробки так как dagger именно DI и следует JSP

        имхо Koin и Kodein 100% вариант только для KMP, в остальных случаях я бы лично предпочел чистый dagger.