DI.kt: одна из первых DI библиотек для Kotlin Multiplatform

image-loader.svg

Прошу приветствовать одну из первых 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 будет показывать ошибки в модулях.

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

  • Пока что доступна альфа-версия.

На этом у меня все. Буду рад, если вы попробуете библиотеку и поделитесь обратной связью. Спасибо за чтение!

© Habrahabr.ru