DI.kt: одна из первых DI библиотек для Kotlin Multiplatform
Прошу приветствовать одну из первых DI библиотек для Kotlin Multiplatform — DI.kt.
Вы можете спросить: «А зачем нам ещё DI либы?». Долгое время полноценного DI для Kotlin Multiplatform не было. Существующие библиотеки — это сервис-локаторы (Koin, Kodein, Popkorn), которые не валидируют граф зависимостей во время компиляции. А это одна из важнейших фич многих привычных Java и Android сообществам DI библиотек и фреймворков. Чтобы принести эту фичу в Kotlin Multiplatform, я и написал DI.kt. Библиотека намного проще привычного нам Dagger — нет мультибиндингов и прочих концептов, которые делают его таким сложным в освоении (и периодически используются неправильно).
Disclaimer: Библиотека сейчас в альфе, потому что использует недокументированный API плагина компилятора Kotlin.
Что делает и как работает DI.kt
Плагин компилятора генерирует код для создания сущностей. А это все, что нам нужно от DI библиотеки.
DI.kt использует недокументированный API плагина компилятора и, вместо генерации файлов, добавляет весь нужный код на этапе генерации IR. Это ведет к некоторым ограничениям, но также значительно уменьшает время компиляции: без генерации новых файлов повторные циклы не нужны.
Чем DI.kt отличается от других библиотек
Если разработчик забывает предоставить зависимость или добавляет циклическую зависимость, случается ошибка компиляции. С DI.kt не нужно проверять граф зависимостей специальным тестом, который еще необходимо правильно настроить и ничего не пропустить. Также не будет падений во время выполнения из-за отсутствующей зависимости. Собралось — значит будет работать.
DI.kt прост и лаконичен. Весь код и аннотации, которые относятся к DI, помещаются в модули. Шесть аннотаций покрывают все, что нам может потребоваться от библиотеки.
Как использовать библиотеку
Установка. Все просто — добавляем плагин в build.gradle проекта:
plugins {
id 'io.github.sergeshustoff.dikt' version '1.0.0-aplha6'
}
Простой пример использования. Теперь давайте попробуем библиотеку на гипотетическом коде. Вы можете легко адаптировать этот пример под реальный проект.
Предположим, у нас есть класс DoImportantWorkUsecase, который требует MyRepository для своей работы. Мы хотим иметь возможность создавать DoImportantWorkUsecase где-то без знаний о том, какие зависимости требуются классу. DoImportantWorkUsecase принимает MyRepository как параметр конструктора:
class DoImportantWorkUsecase(val repo: MyRepository)
Это классический случай инъекции через конструктор. Теперь нам нужно место, где класс будет создаваться без явной передачи MyRepository извне. Это значит, детали нужно где-то скрыть, например, в MyModule. Термин «модуль» часто используется в DI-библиотеках, хоть и не всегда реализуется одинаково.
Нам понадобится только класс с несколькими методами:
class MyModule {
@Create
fun doImportantWorkUsecase(): DoImportantWorkUsecase
}
Вот так просто — мы объявляем функцию, а библиотека генерирует тело функции.
Существует только одна проблема — IDE не знает о плагине компилятора и показывает ошибки на таких функциях. Чтобы выключить уведомления об этих ошибках, нужно установить Idea Plugin.
Теперь если мы попробуем скомпилировать код, то получим сообщение об ошибке в функции doImportantWorkUsecase: «Can’t resolve dependency MyRepository». Ошибка указывает, что мы не предоставили библиотеке информацию о том, как получить нужную зависимость MyRepository.
Давайте это исправим. Для этого необходимо донести до библиотеки, что что нужно создать MyRepository, использовать для этого основной конструктор и сохранить его в модуле как синглтон (не глобальный, а условный— он привязан к экземпляру модуля):
@CreateSingle
private fun myRepository(): MyRepository
Мы сделали функцию приватной, чтобы не давать конечному пользователю модуля доступ к низкоуровневым деталям.
Теперь нужно предоставить зависимости для MyRepository. Предположим, что для работы репозитория нам необходима база данных и http-клиент:
class MyRepository(
private val db: Db,
private val client: HttpClient
)
Мы хотим передать базу извне модуля так, чтобы она передавалась в модуль при создании. Для этого просто добавляем поле в конструктор модуля, тогда DI.kt сможет использовать это поле как зависимость в генерируемом коде:
class MyModule(private val db: Db)
Остается только http-клиент. Мы можем создать его внутри модуля как поле с «ленивой» инициализацией:
private val client: HttpClient by lazy {
// complex client creation here
}
Наконец-то код компилируется без ошибок. Класс DoImportantWorkUsecase создается со всеми нужными зависимостями при вызове myModule.doImportantWorkUsecase ().
Финальная версия модуля выглядит так:
class MyModule(private val db: Db) {
private val client: HttpClient by lazy {
// complex client creation here
}
@CreateSingle
private fun myRepository(): MyRepository
@Create
fun doImportantWorkUsecase(): DoImportantWorkUsecase
}
На первый взгляд кажется, что в примере много шаблонного кода. Но когда понадобится создать новые классы с теми же зависимостями, нужно будет добавить только одну функцию без тела для каждого нового класса.
Обратите внимание, что у класса DoImportantWorkUsecase нет дополнительных аннотаций. В этом отличие DI.kt от большинства DI библиотек под Java, которые используют аннотации. В случае с DI.kt нет необходимости засорять код деталями реализации DI.
Но есть в этом и минусы — нужно прописывать каждую предоставляемую зависимость в модулях. Можно пометить модуль аннотацией @UseConstructors (MyRepository: class) и удалить функцию myRepository. Это позволит убрать часть шаблонного кода, но тогда репозиторий больше не будет синглтоном.
Используем вложенные модули. Мы также можем перенести создание HttpClient в другой модуль и пометить MyModule другой аннотацией. Плагин компилятора по указанию аннотации будет использовать свойства и функции другого модуля как зависимости:
@UseConstructors(MyRepository::class)
@UseModules(HttpModule::class)
class MyModule(
private val db: Db,
private val httpModule: HttpModule
) {
@Create
fun doImportantWorkUsecase(): DoImportantWorkUsecase
}
class HttpModule {
val client: HttpClient by lazy {
// complex client creation here
}
}
Подход к модулям в DI.kt больше похож на новомодный Koin или старенький Toothpick, чем на классический Dagger. Для пользователей Dagger MyModule был бы компонентом, а HttpModule — модулем. В DI.kt все немного проще — не нужно слишком много разных сущностей. Вместо этого модуль может предоставлять зависимости как конечному пользователю, так и другим модулям.
Assisted injection. Стоит также упомянуть Assisted injection, с которым хорошо знакомы пользователи Dagger. Чтобы передать важный параметр в конструктор DoImportantWorkUsecase из кода, вызывающего module.doImportantWorkUsecase (), нужно просто добавить этот параметр в функцию:
@Create
fun doImportantWorkUsecase(param: ImportantParam): DoImportantWorkUsecase
В DI.kt зависимости берутся из параметров функции, свойств и функций модуля, из других модулей или просто создаются вызовом основного конструктора.
Другие аннотации. Изначально я упомянул шесть аннотаций. Последние две — это @Provide и @ProvideSingle. Они во многом похожи на @Create и @CreateSingle, но вместо вызова основного конструктора зависимости будут предоставляться из вложенных модулей.
Например, если мы хотим получать HttpClient из MyModule, нужно всего лишь добавить функцию @Provide fun httpClient (): HttpClient в MyModule. DI.kt сгенерирует тело функции, возвращающее httpModule.client. Важный нюанс — @Create всегда создает код, который вызывает основной конструктор. Это не всегда то, что нам нужно.
Плюсы и минусы DI.kt
У любого рабочего инструмента есть свои плюсы и минусы. DI.kt — не исключение.
Плюсы
Легко мигрировать с ручной инъекции зависимостей.
Граф зависимостей проверяется во время компиляции: собирается — значит работает.
Весь DI код изолирован в модулях и не засоряет остальной код деталями инъекции зависимостей.
Есть поддержка Kotlin Multiplatform.
Минусы
Требует больше шаблонного кода, чем некоторые альтернативы.
Нужно устанавливать плагин IDE, или IDE будет показывать ошибки в модулях.
Пока что нельзя посмотреть сгенерированный код или найти информацию о том, какие зависимости используются и где.
Пока что доступна альфа-версия.
На этом у меня все. Буду рад, если вы попробуете библиотеку и поделитесь обратной связью. Спасибо за чтение!