Kodein — интересная альтернатива Dagger 2 для внедрения зависимостей в Kotlin +12


Здравствуйте, меня зовут Владимир, я работаю главным ИТ-инженером в СберТехе, в команде Digital Business Platform. Как-то раз за обедом мы обсуждали плюсы-минусы Dagger 2 и то, что хотели бы поменять в своей реализации. Нас много, и кода мы, соответственно, тоже пишем много, так что на тот момент в нашем приложении уже было 100500 методов и полтонны dex-файлов. Пораскинув мозгами, пришли к выводу, что писать меньше у нас не получится, зато можно уменьшить количество генерируемого кода при компиляции. Так было принято решение искать альтернативу существующему мастодонту от компании Google.



Как выбирали


Мы описали несколько общих требований к тому избранному, который спасёт нас от лишней кодогенерации:

1. Генерация зависимостей в рантайме, а не при компиляции

В своем проекте мы давно вышли за пределы multidex (уже трижды) и на данный момент в проекте есть 4 dex-файла. Большую роль в их формировании играет кодогенерация при компиляции.

2. Простота использования

Представьте ситуацию. Вам нужно быстро накидать архитектуру с учётом применения паттерна dependency injection. Вы выбрали из команды несколько джуниоров, которым поручили внедрение зависимостей с помощью фреймворка. За пару часов они успешно справились с задачей, а проект даже завелся и не упал в рантайме. Быстро и эффективно, все несказанно рады.

Опыт работы с Dagger говорит, что пары часов не хватит. А то и пары дней. Или даже месяцев. Простота использования ведет к высокой скорости работы — а когда речь идет о внедрении зависимостей у 20 миллионов клиентов, вопрос времени нельзя не учитывать.

3. Функциональность не меньше чем у Dagger 2

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

В итоге мы остановились на нескольких фреймворках:

  • Feather
  • Proton
  • Kodein
  • Transfuse
  • Toothpick
  • Tiger
  • Lightsaber

Последние четыре варианта мы откинули сразу, так как они используют кодогенерацию. Зачем менять шило на мыло? Поэтому взгляд пал на Feather, Proton и Kodein. Но первые два давно не поддерживаются, а Kodein релизится довольно часто. В итоге мы решили остановиться на нем.

DI или SL?


Небольшое лирическое отступление. На Reddit и среди андроид-архитекторов разгорается спор о том, является ли данный фреймворк реализацией паттерна dependency injection или это реализация service locator. Аргумент сторонников service locator: так сказал сам Jake Warton и неплохо это обосновал. Но все же сам разработчик фреймворка позиционирует себя как DI. Внутри команды мы тоже спорили на эту тему, но не пришли к общему мнению. Я связался с Salomon Brys (разработчик фреймворка). Цитирую ответ:

«To be exact, there are a number of differences between a DI and a SL lib:
— A DI lib must understand transient dependencies and order of initialization
— A DI lib must provide scopes (singleton, provider, etc.)
Kodein is a DI lib, for sure.»


Исходя из позиции автора фреймворка, Kodein — это не service locator, потому что он в большей степени удовлетворяет требованиям паттерна DI. Лично я считаю, что это вопрос терминологии, а не того, что делает инструмент. Если библиотека удовлетворяет вашим потребностям, то не важно, как называется паттерн, который она реализует. Главное, что работа выполняется без проблем.

Внедрение и недостатки


Переход на Kodein стал возможен, когда мы начали переводить наш проект на модульную архитектуру — выводить все продукты в отдельные, максимально независимые друг от друга модули. Это развязало нам руки и позволило заменить в одном небольшом модуле Dagger 2 на Kodein. Если что, мы могли бы просто откатиться на предыдущую стабильную версию модуля и работать дальше.

Сначала мы немного опасались, что Kodein будет некорректно работать на Java проекте, так как написан на Kotlin и использует его «фишечки». Но оказалось, что у Kodein хорошая поддержка java-кода, и внедрять туда зависимости можно так же просто, как и на Dagger 2 (а можно еще проще — об этом будет ниже).

Переход с Dagger 2 на Kodein не был болезненным, так как модуль был достаточно небольшого размера, и DI там использовалось не очень активно. Процесс прошел достаточно гладко, все завелось с полпинка, и зависимости так и полезли внедряться по назначению. Приятным бонусом было отсутствие проблем с kapt, как это было у Dagger 2. Конечно, лучше внедрять Kodein на проекте, написанном на Kotlin, но мы ведь экспериментировали, поэтому надо было рассмотреть библиотеку с неожиданных сторон.

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

  1. Самый главный и жирный минус — это то, что мы можем получить ошибку в рантайме, не понимая, откуда она прилетела. Это была проблема первого Dagger, от которой избавились в Dagger 2, перейдя на кодогенерацию при компиляции. Теперь то же самое мы встречаем и в Kodein. Проблема решается тщательным тестированием и покрытием функционала автотестами. Тем более что сам фреймворк нас подталкивает использовать его возможности при написании тестов.
  2. Если у вас старый и большой java-проект и нет возможности подключить Kotlin, то Kodein вам не подходит. Но это, скорее, болезнь нашего громадного проекта с годами наследия.
  3. Низкая популярность в реальных проектах. Это приводит к тому, что проект развивается гораздо медленнее того же Dagger, потому что разработчики получают меньше фидбека, меньше контрибьюторов в репозиторий. Не факт, что и мы выведем Kodein в продакшн, потому что сейчас еще боремся с теми минусами, которые нашли.

Как подключить


Подключение фреймворка в проект не занимает много времени, достаточно просто указать зависимость в gradle файле:

implementation 'com.github.salomonbrys.kodein:kodein:4.1.0'

Связывание на Kotlin:


Объявляем объект Kodein, в котором будут объявляться зависимости.

val kodein = Kodein {
    bind<Message>() with provider { MessageFactory(0, 5) }
    bind<DbProvider>() with singleton { SqliteDS.open("path/to/file") }
}

Теперь подробнее об этом: метод bind говорит о том, что тут будет происходить связывание классов посредством inline функции. В дженериках мы указываем интерфейс, реализацию которого хотим заинжектить. После идёт метод with, который говорит о способе связывания, в Kodein их несколько, они будут описаны чуть ниже, в данном случае это provider и singleton. После этого в фигурных скобках мы подставляем нужный экземпляр класса, который мы хотим заинжектить.

Само связывание будет выглядеть вот таким образом:

class Controller(private val kodein: Kodein) {
    private val ds: DataSource = kodein.instance()
}

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

Виды связывания


Kodein позволяет инжектить зависимости несколькими способами:

  • Factory binding — это связывание, которое требует аргумент, чтобы получить экземпляр необходимого типа из фабрики. Генерация экземпляра будет происходить каждый раз, когда будет запрошен экземпляр нужного типа:

val kodein = Kodein {
    bind<Message>() with factory { type: Int -> MessageFactory(type) }
}

  • Provider binding — этот биндинг возвращает экземпляр необходимого типа с любого хранилища и, в отличие от Factory binding, не требует аргументы для инъекции. Генерация также будет происходить каждый раз при запросе.

val kodein = Kodein {
    bind<Message> with provider { MessageFactory(6) }
}

  • Singleton binding — тут все просто, мы создаем синглтон с ленивой инициализацией. Создание синглтона будет вызываться всего раз.

val kodein = Kodein {
    bind<DBProvider>() with singleton { SqliteDS.open("path/to/file") }
}


  • Tagged bindings — позволяют помечать тегами типы, для которых будут генерироваться разные экземпляры.

val kodein = Kodein {
    bind<Message>() with factory { type: Int -> MessageFactory(type) } 
    bind<Message>("ImageMessage") with provider { MessageFactory(10) } 
    bind<Message>("PaymentMessage") with singleton { MessageFactory(20) } 
}

Также есть возможности:

  • Создания синглтона без ленивой инициализации. Объект будет генерироваться при инициализации объекта Kodein.
  • Получения экземпляра (только если он был создан).
  • Создания смешанного варианта синглтона и фабричного биндинга — когда мы хотим получить экземпляр с аргументом и не создавать каждый раз новый.

Еще одна полезная функция — оборачивание связок в слабые и мягкие ссылки. Это может дать преимущество, если вы понимаете, что объекты не должны быть долгоживущими.

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

Создание модулей


Kodein позволяет хранить связывания в модулях примерно так же, как и Dagger 2. Пример:

val apiModule = Kodein.Module {
    bind<MessengerApi>() with singleton { DefaultMessengerApi() }
   
}

И затем при инициализации объекта Kodein можно указать нужный модуль:

val kodein = Kodein {
    import(apiModule)
   
}

Переопределение связываний


По умолчанию Kodein запрещает переопределять связывания, так как случайное «двойное» связывание может привести к непредвиденным последствиям и трате времени на поиск проблемы. Но такая возможность присутствует. Это будет удобно, например, для тестирования классов, где вы можете поменять реализацию на какой-либо мок. Это делается следующим образом:

val kodein = Kodein {
    bind<MessengerApi>() with singleton { DefaultMessengerApi() }
    /* ... */
    bind<MessengerApi>(overrides = true) with singleton { CustomMessengerApi() }
}
 

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

val kodein = Kodein {
    /* ... */
    import(testEnvModule, allowOverride = true)
}

Также имеется флаг, который говорит о том, что было бы предпочтительнее брать уже переопределенный экземпляр. На случай если в «новом» экземпляре будет некоторая «улучшенная» функциональность:

val testModule = Kodein.Module(allowSilentOverride = true) {
    bind<EmailClient>() with singleton { MockEmailClient() } 
}

Ленивая инициализация


Благодаря делегированию свойств у Kotlin, в Kodein можно использовать ленивую инициализацию связываний практически для всех видов связываний:

class Controller(private val kodein: Kodein) {
    private val messageFactory: (Int) -> Dice by kodein.lazy.factory() 
    private val dbProvider: DataSource by kodein.lazy.instance() 
    private val randomProvider: () -> Random by kodein.lazy.provider() 
    private val answerConstant: String by kodein.lazy.instance("answer") 
}

Как генерируются классы в рантайме


Генерация классов происходит за счет встраиваний функций. Это позволяет встраивать инъекции в код при компиляции. То есть компилятор просто вставит кусок нужного кода в то место, где будет объявлена встроенная функция. Генерация классов в Kodein происходит по тому же принципу. Выбор нужных типов для создания экземпляров происходит за счёт reified generics. Подробнее можно почитать по ссылкам в конце статьи.

Kodein на java-проектах


А что делать тем, у кого проекты написаны не на Kotlin, а на Java? Kodein предоставляет ту же функциональность и на старой доброй Java, но с некоторыми ограничениями. Фреймворк частично поддерживает спецификацию JSR-330, а конкретно аннотацию Inject, которая знакома всем, кто хоть раз сталкивался с DI на Java.

Есть здесь и некоторые минусы. Инъекции производятся не через inline-функции и дженерики, а с помощью рефлексии. Соответственно, будет потеря в производительности за счет рефлексии и мультиязычной компиляции. Кроме того, объект с биндингами обязательно нужно писать на Kotlin — если в условиях указано использование исключительно Java, то, увы, Kodein вам не подойдёт.

Чтобы использовать Kodein на java-проектах, необходимо сделать несколько шагов:

  1. Подключить JxInjector в gradle:

compile 'com.github.salomonbrys.kodein:kodein-jxinject:4.1.0'

2.  Добавить в объект Kodein модуль jxIntector:

val kodein = Kodein {
    import(jxInjectorModule)
    /* Other bindings */

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

 public class MyJavaController {
    @Inject
    public MyJavaController(Connection connection, FileSystem fs) {
        /* ... */
    }
    /* ... */
}


Второй способ — с использованием специфичных объектов Kodein:

MyJavaController controller = Jx.of(kodein).newInstance(MyJavaController.class);

или так:

MyJavaController controller = new MyJavaController();
Jx.of(kodein).inject(controller);

Заключение


Простой и легковесный DI-фреймворк, позволяющий буквально за вечер разобраться в основах и начать внедрять его в свой проект, да и еще написанный на Kotlin и проверенный в продакшене? Звучит слишком хорошо, но с Kodein это стало правдой.

Данный фреймворк не является 100%-ной альтернативой Dagger 2, но его можно использовать на жирных проектах с кучей кода, где даже мистер Пропер бы не справился, где дополнительная кодогенерация может вылиться в новый dex-файл. Но не стоит считать, что для маленьких проектов Kodein будет плох. Он значительно упрощает внедрение зависимостей, не требуя при этом знания механизмов, специфичных для Dagger 2.

Также Kodein очень хорошо ложится на Kotlin и довольно хорошо себя чувствует на таких проектах. Здесь, как минимум, очень интересна кодогенерация классов — это отклонение от подхода с рефлексией в пользу тех возможностей, которые предоставляет Kotiln. К сожалению, весь код, написанный в рамках этого эксперимента, мы еще не вывели в продакшн, так как нет 100% уверенности, что фреймворк не выстрелит нам в ногу. Исследование продолжается.

Полезные ссылки





К сожалению, не доступен сервер mySQL