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

Так произошло и со мной. Решил написать полноценный функционал для работы с зависимостями и, так как я Android-разработчик, адаптировать для работы в моей привычной среде и для моих таких же привычных задач. 

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

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

Постановка задачи

Всё началось с постановки задач и примерного пути развития кода. Путь был выстроен примерно такой:

  • сохранение зависимостей в контейнере 

  • возможность держать в контейнере зависимости одного типа 

  • фабрики для создания зависимостей 

  • автосоздаваемые зависимости 

  • модули для более удобного заполнения зависимостями контейнер. Расширения для Android

  • зависимости между контейнерами

  • коллекции в контейнере

  • публикация библиотеки

Сохранение зависимостей в контейнере

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

interface DiContainer {
   val container: RootContainer
}

class DiContainerImpl : DiContainer {
   override val container: RootContainer = RootContainer()
}

class RootContainer {
   private val dependencies: MutableMap<Class<out Any>, Any> = mutableMapOf()
   ...
}

Выше представлен фундамент моего подхода, позволяющий как использовать готовую реализацию контейнера, так и переопределить его в будущем, когда RootContainer начнёт принимать зависимости и настройки извне.

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

fun <T : Any> provide(clazz: Class<out T>, dependency: T) {
   dependencies[clazz] = dependency
}

Возможность держать в контейнере зависимости одного типа

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

class RootContainer {
   private val qualifiedDependencies: MutableMap<Class<out Annotation>, Any> = mutableMapOf()
   ...
}

Данная задача требовала небольших правок имеющегося кода: мне требовалось понять, содержит ли класс переданной зависимости нужную аннотацию, и если да, то положить в другую коллекцию.

fun <T : Any> provide(clazz: Class<out T>, dependency: T) {
   val qualifiedAnnotation = findQualifiedAnnotation(clazz)
   if (qualifiedAnnotation != null) {
       qualifiedDependencies[qualifiedAnnotation.annotationClass.java] = dependency
   } else {
       dependencies[clazz] = dependency
   }
}

За методом findQualifiedAnnotation скрыта незначительная логика по поиску аннотации у класса. В остальном поставленную задачу можно считать выполненной. 

Фабрики для создания зависимостей

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

data class DependencyFactory<T>(
   val factory: () -> T,
)

fun <T: Any> T.tryFactory(): T {
   return if (this is DependencyFactory<*>) {
       this.factory.invoke() as T
   } else {
       this
   }
}

Заполнение контейнера фабричной зависимостью выглядит так:

inline fun <reified T : Any> factory(noinline factory: () -> T) {
   val dependencyFactory = DependencyFactory(factory)
   provide(T::class.java, dependencyFactory)
}

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

Автосоздаваемые зависимости

Автосоздаваемая зависимость — это зависимость, инстанс которой можно создать на основании других зависимостей при условии, что они уже находятся в контейнере. То есть если для создания класса Z нужно ему в конструктор передать A и B, которые уже есть в контейнере, моя система должна уметь находить эти A и B, инстанциирую на их основе Z.

private fun <T> create(constructor: Constructor<T>): T {
   val parameters = constructor.parameters
   val parametersList = mutableListOf<Any>()
   parameters.forEach { parameter ->
       val qualifiedAnnotation = findQualifiedAnnotation(parameter)
       val value = getInternal(
           parameter.type,
           qualifiedAnnotation?.annotationClass?.java,
       )
       parametersList.add(value)
   }
   return constructor.newInstance(*parametersList.toTypedArray()) as T
}

Задача решилась очень элегантно, на мой взгляд, потребовав всего лишь пройтись по параметрам конструктора и попытаться достать зависимости нужного типа из контейнера, а затем просто передать все собранные объекты в метод newInstance.

На данный момент мой метод внутреннего получения данных из контейнера стал выглядеть примерно так:

private fun <T : Any> getInternal(
   clazz: Class<T>,
   qualifierClazz: Class<out Annotation>? = null,
): T {
   return getQualifiedDependency<T>(qualifierClazz)?.tryFactory()
       ?: (dependencies[clazz] as? T)?.tryFactory()
       ?: createDependency(clazz, qualifierClazz)
       ?: throw IllegalStateException("...")
}

Модули для более удобного заполнения зависимостями контейнер. Расширения для Android

Мне хотелось сразу решить проблему смены конфигурации, поэтому я написал делегат для хранения экземпляра моего контейнера внутри ViewModel

class DiContainerStoreViewModel(
   val container: RootContainer,
) : ViewModel()

Далее настроил получение DiContainerStoreViewModel через делегат: 

fun ViewModelStoreOwner.retainContainer(
   modules: List<DiModule> = emptyList(),
   overrideViewModelStore: ViewModelStore? = null,
): Lazy<RootContainer>

DiModule — это дата класс, помогающий мне сохранить все вызовы по работе с зависимостями, чтобы потом инициировать их на конкретном экземпляре контейнера. 

fun module(module: RootContainer.() -> Unit): DiModule {
   return DiModule(module)
}

data class DiModule(
   val module: RootContainer.() -> Unit,
)

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

val sampleModule = module {
   provide(SomeDep("hello"))

   factory { FirstInteractorImpl() }
   factory { SecondInteractorImpl() }
}

Зависимости между контейнерами

Данная задача прекрасно вписывалась в имеющийся код и требовала лишь передать в конструктор RootContainer другой контейнер, вызываемый в рамках getInternal. Естественно последним вызовом, чтобы в начале проверить наличие зависимости в текущем контейнере, а только потом переходить к поиску в зависимом. 

Коллекции в контейнере

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

Это значит, что если я положил несколько элементов в коллекцию Map<String, Int>, а потом положу Map<Int, String>, то мой контейнер должен держать в системе уже две коллекции, и, запрашивая коллекцию Map<String, Int>, я не хочу получать элементы из Map<Int, String>. И в этом была вся сложность, так как в рантайме вытащить типы дженериков коллекции Map не было возможно. 

В итоге решил задачу созданием ещё одной коллекции, а также аннотации, в аргументы которой я передавал нужные типы. 

private val dependencyMaps: MutableMap<DependencyMapIdentifier, MutableMap<Any, Any>> = mutableMapOf()

Data class для хранения типов ключа и значения для соответствующей Map:

private data class DependencyMapIdentifier(
   val keyClass: Class<out Any>,
   val valueClass: Class<out Any>,
)

Заполнение коллекции данными выглядит так:

fun <K : Any, V : Any> intoMap(key: K, value: V) {
   val mapIdentifier = DependencyMapIdentifier(key.javaClass, value.javaClass)
   val existedMap = dependencyMaps[mapIdentifier]
   if (existedMap != null) {
       existedMap[key] = value
   } else {
       val newMap: MutableMap<Any, Any> = mutableMapOf(key to value)
       dependencyMaps[mapIdentifier] = newMap
   }
}

На этапе заполнения коллекции создавался data class, хранящий значения классов и использовавший его в качестве ключа.

А для запроса зависимости требовалась такая конструкция:

class AutoCreatedDependency @Inject constructor(
   @MapDependency(String::class, String::class) stringMap: Map<String, String>,
   @MapDependency(String::class, Boolean::class) booleanMap: Map<String, Boolean>,
)

Была небольшая проблема с поиском коллекции, потому что классы примитивов трансформировались из классов оберток, таких как java.lang.Boolean в boolean, что потребовало данного способа получения из KClass класса обертки: mapDependencyAnnotation.keyClass.javaObjectType

Публикация библиотеки

Для публикации я воспользовался git-командами:

git tag 1.0
git push --tags

После этого на GitHub открыл вкладку Reseases -> Draft a new release, выбрал нужный тег и нажал Publish release. Практически сразу моя библиотеки нашлась в jitpack, где помимо ссылок и версий можно найти строку для badge в рамках GitHub, чтобы вставить в README.md и видеть в нём всегда актуальную версию публикуемой библиотеки. 

Итог

Я написал свою первую полноценную библиотеку и прошёлся по интересным для себя проблемам, что дало более глубокое понимания работы с зависимостями, а также доставило удовольствие. Весь код библиотеки можно найти в GitHub.  

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


  1. dyadyaSerezha
    26.03.2024 15:47
    +4

    Реплика в зал.

    Я видел, как DI используется совершенно бездумно, просто потому, что это модно и кошерно сегодня, как завтра будет модно что-то другое.

    Например, в одном внутреннем проекте одной фирмы каждый релиз билдился с нуля целиком и никогда в течение жизни ни одна зависимость не менялась. Но этих run-time зависимостей были сотни и сотни. Вопрос - зачем? Зачем так усложнять проект, если все зависимости по сути статические?


    1. ddruganov
      26.03.2024 15:47
      +6

      Чтобы прогать на интерфейсах и писать тесты?


      1. dyadyaSerezha
        26.03.2024 15:47
        +3

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


      1. YegorP
        26.03.2024 15:47
        +4

        прогать на интерфейсах

        А без DI нельзя прогать на интерфейсах что ли?

        interface X { ... };
        class A implements X { ... };
        const someX: X = new A();
        function foo(x: X) { ... };
        foo(new A());

        Котлин не знаю, сорян.

        писать тесты

        Тоже можно без DI, только вместо альтернативного composition root будут моки классов. Опять же, может, в Котлине дела иначе обстоят, я хз.


        1. AccountForHabr
          26.03.2024 15:47
          +1

          Без DI у Вас не получилось. Вы написали то что делает обычный IOC контейнер, но в ручную.

          Ну и в примитивном случае "прогать на интерфейсах" можно. но что если вложенность будет хотя бы 3?

          и "const someX: X = new A();" - это уже не "прогать на интефейсах", а жесткая завязка на реализацию interface X, что усложнит как минимум тестирование.


    1. Evolinc Автор
      26.03.2024 15:47
      +2

      Если под "статическими зависимостями" имеется ввиду хранение их в статике, то утверждение ваше ложное в условиях android проектов. Зависимости требуют ограниченного времени жизни. Их может быть и сто тысяч, но на время использования приложения может понадобиться, например, только 100, и это реальный случай.

      Утверждение о неизменности очень зависит от проекта и специфика задач, поэтому приводить один единственный проект из опыта как аргумент к чему-то - считаю не совсем корректным.


      1. dyadyaSerezha
        26.03.2024 15:47

        Именно один проект. Потому что каждую технологию надо применять строго по требованиям конкретного проекта. Андроид это или что-то еще, значения вообще не имеет.


    1. AccountForHabr
      26.03.2024 15:47

      Где тут run-time зависимости? Как вы без DI тесты юнит тесты пишете?


      1. dyadyaSerezha
        26.03.2024 15:47

        Еще раз, это сильно зависит от проекта.


  1. jj_swp
    26.03.2024 15:47

    Я так понял, это какой-то пет проект (библиотека). Хелоу ворлд в мир велосипедов? :) Я к чему интересуюсь, прочитал про постановку задачи, красивое, но зачем и почему?