1. Введение

Принцип внедрения (инжектирования) зависимостей становится все более неотъемлемой частью процесса разработки. Без него сложно представить себе достижение желанного разделения обязанностей в коде или обеспечение должного уровня тестируемости.

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

Конечно, существует фреймворк Guice, но если мы хотим иметь что-то более подходящее для языка Kotlin, стоит обратить внимание на Koin. Этот легковесный фреймворк предоставляет возможности для внедрения зависимостей через DSL, что является нетривиальной задачей в случае Java-ориентированного Guice.

Помимо того, что Koin обладает возможностью выразительного объявления зависимостей между компонентами в коде, он также имеет интегрированную поддержку для известных приложений, разрабатываемых на языке программирования Kotlin. Конкретно, он облегчает взаимодействие и интеграцию с популярными фреймворками и платформами, такими как Ktor для создания серверных приложений и Android-платформа для мобильных приложений. Важно отметить, что Koin создан без использования "магии" - он не генерирует прокси-объектов, не оперирует рефлексией и не предпринимает эвристических попыток найти подходящую реализацию для удовлетворения нашей зависимости. Вместо этого он делает только то, что ему явно указано, и не обладает "автосвязыванием", как в Spring.

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

2. Как начать работу с Koin

Как и в случае с любой библиотекой, нам необходимо добавить некоторые зависимости. Все зависит от конкретного проекта: для успешной работы нам потребуется либо стандартная настройка с использованием Kotlin (в таком случае, это будет так называемый "ванильный" (чистый) Kotlin-проект), либо у нас может быть проект, основанный на фреймворке Ktor, который позволяет разрабатывать серверные приложения. Если мы используем систему сборки Gradle с Kotlin DSL, потребуется указать две зависимости в нашем проекте. Эти зависимости нужны для того, чтобы библиотека Koin корректно функционировала в режиме "ванильного" Kotlin-проекта, то есть в обычном режиме без специфических фреймворков:

val koin_version = "3.2.0-beta-1"
implementation("io.insert-koin:koin-core:$koin_version")
testImplementation("io.insert-koin:koin-test:$koin_version")

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

testImplementation("io.insert-koin:koin-test-junit5:$koin_version")

Аналогично, для версии с использованием фреймворка Ktor, чтобы осуществить интеграцию с Koin, имеется специальная зависимость (она заменяет основную зависимость (применяемую по умолчанию) в Ktor-приложениях):

implementation("io.insert-koin:koin-ktor:$koin_version")

Вот и всё, что нам нужно, чтобы начать применение библиотеки Koin. Мы будем использовать последнюю бета-версию, чтобы руководство оставалось актуальным в течение длительного времени.

3. Модули и Определения

Давайте начнем наше путешествие, создав реестр для нашего паттерна DI (Dependency Injection, Внедрение зависимостей). Мы будем регистрировать зависимости в этом реестре, чтобы впоследствии инжектировать их в различные части нашего приложения. 

3.1. Модули

Модули содержат объявления зависимостей между сервисами, ресурсами и репозиториями. Они позволяют организовать эти зависимости и предоставить информацию о том, какие компоненты могут быть доступны для инжекции в другие части приложения. Может быть несколько модулей, по одному для каждого семантического поля. При создании контекста Koin, все модули передаются в функцию modules(), о которой будет рассказано позже.

Каждый модуль может зависеть от определений, которые находятся в других модулях. Koin выполняет вычисление зависимостей в модулях “лениво”, то есть он не создает или не разрешает зависимости до тех пор, пока они действительно не потребуются во время выполнения программы. Это позволяет избегать избыточных вычислений и создания ненужных объектов, что повышает эффективность и производительность приложения. Определения могут даже образовывать циклы зависимостей. Такое обычно возникает в сложных приложениях, где различные компоненты взаимодействуют между собой и зависят от результатов работы друг друга.

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

Для создания модуля мы должны использовать функцию module {}:

class HelloSayer() {
    fun sayHello() = "Hello!"
}

val koinModule = module {
    single { HelloSayer() }
}

Модули могут быть включены друг в друга:

val koinModule = module {
    // Some configuration
}

val anotherKoinModule = module {
    // More configuration
}

val compositeModule = module {
    includes(koinModule, anotherKoinModule)
}

Более того, они могут образовывать структуру в виде сложного дерева без значительного ущерба для производительности. includes() объединит в одну общую коллекцию все определения (выполнит операцию "сплющивания" (flatten)) и они станут доступными на одном уровне. Это позволяет избежать необходимости долгого иерархического доступа к определениям внутри вложенных модулей и сможет упростить их использование в других частях приложения.

Функция includes() в библиотеке Koin объединяет не только определения (зависимости), но также и сами компоненты (сервисы, ресурсы и другие элементы), которые были объявлены в разных модулях. 

3.2. Определения Singleton и Factory

Для создания определения чаще всего приходится использовать единственную функцию single<T>{}, где T — это тип, который должен соответствовать запрашиваемому типу в последующих вызовах get<T>():

single<RumourTeller> { RumourMonger(get()) }

single {} создаст определение для объекта-синглтона и каждый раз, когда вызывается метод get(), будет возвращать один и тот же экземпляр (инстанс) этого объекта.

Еще один способ создания синглтона (singleton) — новая фича singleOf() в версии 3.2.. Этот подход основан на двух наблюдениях.

Во-первых, большинство классов Kotlin имеют только один конструктор. Благодаря значениям по умолчанию, им не требуются несколько конструкторов для поддержки различных сценариев использования, как в Java.

Во-вторых, большинство определений не имеют альтернативных вариантов. В старых версиях Koin это приводило к появлению таких определений, как:

single<SomeType> { get(), get(), get(), get(), get(), get() }

Следовательно, вместо этого мы можем указать конструктор, который хотим вызвать:

class BackLoop(val dependency: Dependency)

val someModule = module {
    singleOf(::BackLoop)
}

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

Еще одним вариантом подхода является функция factory {}, используемая для определения зависимости, которая будет создаваться новым экземпляром каждый раз при вызове get() для этой зависимости:

factory { RumourSource() }

Если мы не объявим зависимость при помощи single {} c параметром createdAtStart = true (т.е. сразу при старте приложения), то лямбда-выражение (*creator lambda) будет выполняться только в том случае, если какой-либо компонент KoinComponent явно запросит эту зависимость. (*Лямбда-выражение указывает на функцию, которая определяет способ создания экземпляра зависимости при использовании фреймворка Koin).

3.3. Варианты определений

Важно понимать, что каждое определение — это лямбда. Это означает, что хотя часто определение может быть простым вызовом конструктора, оно не ограничивается только этим:

fun helloSayer() = HelloSayer()

val factoryFunctionModule = module {
    single { helloSayer() }
}

Кроме того, определение может иметь параметр:

module {
    factory { (rumour: String) -> RumourSource(rumour) }
}

В случае синглтона, первый вызов создаст экземпляр, и все последующие попытки передать параметр будут проигнорированы:

val singleWithParamModule = module {
    single { (rumour: String) -> RumourSource(rumour) }
}

startKoin {
    modules(singleWithParamModule)
}
val component = object : KoinComponent {
    val instance1 = get<RumourSource> { parametersOf("I've seen nothing") }
    val instance2 = get<RumourSource> { parametersOf("Jane is seeing Gill") }
}

assertEquals("I've heard that I've seen nothing", component.instance1.tellRumour())
assertEquals("I've heard that I've seen nothing", component.instance2.tellRumour())

В случае фабрики (factory), каждое внедрение (injection) будет инстанцироваться со своим параметром, как и ожидалось: 

val factoryScopeModule = module {
    factory { (rumour: String) -> RumourSource(rumour) }
}

startKoin {
    modules(factoryScopeModule)
}
// Same component instantiation

assertEquals("I've heard that I've seen nothing", component.instance1.tellRumour())
assertEquals("I've heard that Jane is seeing Gill", component.instance2.tellRumour())

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

Еще один прием связан с тем, как определить несколько объектов одного типа. Надо создать несколько различных экземпляров одного и того же класса или типа, но с разными параметрами или настройками. Это на самом деле сделать очень просто:

val namedSources = module {
    single(named("Silent Bob")) { RumourSource("I've seen nothing") }
    single(named("Jay")) { RumourSource("Jack is kissing Alex") }
}

У нас есть несколько объектов одного и того же типа RumourSource, но с разными именами – "Silent Bob" и "Jay".

В этом коде мы определяем два разных экземпляра RumourSource с помощью функции single. Особенность заключается в использовании функции named, которая позволяет нам давать имена для определенных экземпляров.

В первой строчке мы создаем экземпляр RumourSource с именем "Silent Bob" и задаем ему сообщение "Я ничего не видел". Во второй строчке – экземпляр с именем "Jay" и сообщением "Джек целуется с Алекс".

Теперь, когда мы инжектируем эти экземпляры в другие компоненты нашего приложения, то сможем легко различать их по имени. Например, в коде мы запросим экземпляр с именем "Silent Bob" или "Jay" для дальнейшего использования.

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

4. Koin-компоненты 

Определения из модулей используются в KoinComponents. Класс, реализующий интерфейс KoinComponent, в некоторой степени аналогичен Spring @Component. Он Он связан с глобальным экземпляром Koin, который создается при инициализации приложения, и служит точкой входа в дерево объектов, описанных в модулях:

class SimpleKoinApplication : KoinComponent {
    private val service: HelloSayer by inject()
}

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

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

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

4.1. Немедленное вычисление в сравнении с ленивым

Объект, реализующий интерфейс KoinComponent, обладает способностью использовать методы inject() и get() для получения зависимостей:

class SimpleKoinApplication : KoinComponent {
    private val service: HelloSayer by inject()
    private val rumourMonger: RumourTeller = get()
}

Внедрение (инжектирование) зависимости с использованием метода inject() означает, что зависимость не создается сразу при инициализации объекта, а откладывается до момента первого обращения к ней. Для этого используется ключевое слово by вместе с методом inject(). Создается делегат — специальный объект, который отвечает за ленивое вычисление зависимости. Он будет выполнен при первом обращении к зависимости.

В отличие от ленивого вычисления (inject()), метод get() позволяет получить зависимость немедленно, без ожидания. Когда объект вызывает метод get() для определенной зависимости, он сразу получает непосредственно созданный экземпляр этой зависимости. 

Таким образом, использование методов inject() и get() дает разработчикам гибкость в управлении зависимостями, позволяя выбирать между ленивым и немедленным вычислением с учетом конкретных требований приложения.

5. Экземпляр Koin

Для активации всех наших определений нам необходимо создать экземпляр Koin. Его можно сделать и зарегистрировать в GlobalContext, и он будет доступен во время работы (рантайма) всего приложения. Либо мы сформируем автономный (standalone) экземпляр Koin и управлять ссылкой на него будем самостоятельно. 

Другими словами, мы делаем экземпляр Koin, который существует отдельно от какого-либо глобального контекста, и тогда мы сами ответственны за управление данным экземпляром.

Когда мы создаем "standalone" экземпляр Koin, то осуществляем это с помощью функции koinApplication{}, которая позволяет определить модули и другие настройки: 

val app = koinApplication {
    modules(koinModule)
}

Этот экземпляр Koin представляет собой наше приложение или "стартовую точку" для управления зависимостями.

Мы должны сохранить ссылку на приложение (экземпляр Koin, созданный с помощью koinApplication{}) в переменной app и использовать его позднее для инициализации наших компонентов. 

Важно заметить, что создание экземпляра Koin с помощью koinApplication{} еще не инициализирует наши компоненты. Это лишь создает конфигурацию Koin. Для того чтобы начать использовать наши зависимости, мы должны вызвать startKoin {}, передавая ему модули приложения.

Функция startKoin {} создает глобальный экземпляр Koin, который будет доступен для использования в нашем коде:

startKoin {
    modules(koinModule)
}

Этот экземпляр предоставляет доступ к зависимостям, определенным в наших модулях.

Вместе с тем, стоит отметить, что Koin имеет определенные предпочтения в отношении некоторых фреймворков. Один из таких примеров — Ktor. У него есть свой способ инициализации конфигурации Koin. Давайте поговорим о настройке Koin как в "ванильном" Kotlin-приложении, так и в веб-сервере Ktor.

5.1. Базовое ванильное приложение с Koin

Для начала работы с базовой конфигурацией Koin необходимо создать экземпляр Koin:

startKoin {
    logger(PrintLogger(Level.INFO))
    modules(koinModule, factoryScopeModule)
    fileProperties()
    properties(mapOf("a" to "b", "c" to "d"))
    environmentProperties()
    createEagerInstances()
}

Эта функция может быть вызвана только один раз за весь жизненный цикл JVM (Java Virtual Machine). Наиболее важной ее частью является вызов modules(), в котором загружаются все определения. Однако мы можем загрузить дополнительные модули и некоторые выгрузить позже с помощью loadKoinModules() и unloadKoinModules().

Давайте рассмотрим другие вызовы внутри лямбды startKoin {}. Это logger(), различные *properties() и вызов createEagerInstances(). Первые две из функций, а именно logger() и *properties(), требуют отдельных пояснений, так что им следует посвятить отдельный раздел. 

Третья функция, о которой идет речь, createEagerInstances(), позволяет явно создавать и инициализировать те самые определенные синглтоны при запуске приложения (с аргументом createdAtStart = true), даже если они еще не были явно запрошены. 

5.2. Базовый Ktor-сервер с Koin

Для Ktor-сервера Koin — это всего лишь еще одна установленная фича. Она играет роль вызова startKoin {}:

fun Application.module(testing: Boolean = false) {
    koin {
        modules(koinModule)
    }
}

После этого класс Application получает функциональность KoinComponent и может inject() (инжектировать) зависимости:

routing {
    val helloSayer: HelloSayer by inject()
    get("/") {
        call.respondText("${helloSayer.sayHello()}, world!")
    }
}

5.3. Изолированный (standalone) экземпляр Koin

Может было бы разумным не использовать глобальный экземпляр Koin для SDK и библиотек. Для достижения этой цели, мы можем использовать функцию koinApplication {} чтобы создать изолированный экземпляр Koin-контейнера и сохранить на него ссылку:

val app = koinApplication {
    modules(koinModule)
}

Затем нам нужно переопределить часть стандратной функциональности KoinComponent:

class StandaloneKoinApplication(private val koinInstance: Koin) : KoinComponent {
    override fun getKoin(): Koin = koinInstance
    // other component configuration
}

После этого мы сможем инстанцировать компоненты во время выполнения (рантайма) с одним дополнительным аргументом — экземпляром Koin:

StandaloneKoinApplication(app.koin).invoke()

6. Логирование и Свойства

Теперь давайте поговорим о тех функциях logger() и properties() в настройке startKoin {}.

6.1. Логгер Koin

Чтобы упростить поиск проблем в настройке, мы можем включить логгер Koin. Фактически, он и так всегда включен, но по умолчанию используется его имплементация EmptyLogger. Мы можем изменить ее на PrintLogger, чтобы просматривать логи Koin в стандартном выводе:

startKoin {
    logger(PrintLogger(Level.INFO))
}

Либо, в качестве альтернативы, можно реализовать свой Logger. Если мы используем Ktor, Spark или Android-версию Koin, то есть возможность применить их логгеры: SLF4JLogger или AndroidLogger.

6.2. Свойства

Koin также может использовать свойства из файла (обычно это файл koin.properties, который располагается по умолчанию в папке classpath:koin.properties, следовательно, в нашем проекте этот файл должен быть в src/main/resources/koin.properties), из переменных системного окружения и непосредственно из переданной карты (мапы):

startKoin {
    modules(initByProperty)
    fileProperties()
    properties(mapOf("rumour" to "Max is kissing Alex"))
    environmentProperties()
}

Затем в модуле мы можем получить доступ к этим свойствам с помощью методов getProperty():

val initByProperty = module { 
    single { RumourSource(getProperty("rumour", "Some default rumour")) }
}

7. Тестирование приложений Koin

Koin также предоставляет достаточно развитую инфраструктуру тестирования. Реализуя интерфейс KoinTest, мы предоставляем нашему тесту функциональность KoinComponent и даже больше:

class KoinSpecificTest : KoinTest {
    @Test
    fun `when test implements KoinTest then KoinComponent powers are available`() {
        startKoin {
            modules(koinModule)
        }
        val helloSayer: HelloSayer = get()
        assertEquals("Hello!", helloSayer.sayHello())
    }
}

7.1. Мокирование (Mocking) определений Koin

Простой способ мокирования или иной замены сущностей, объявленных в модулях, — это создание специального ситуативного модуля на лету при запуске Koin в тесте:

startKoin {
    modules(
        koinModule,
        module {
            single<RumourTeller> { RumourSource("I know everything about everyone!") }
        }
    )
}

Другой способ — использовать расширения JUnit 5 для startKoin {} и мокирования:

@JvmField
@RegisterExtension
val koinTestExtension = KoinTestExtension.create {
    modules(
        module {
            single<RumourTeller> { RumourSource("I know everything about everyone!") }
        }
    )
}

@JvmField
@RegisterExtension
val mockProvider = MockProviderExtension.create { clazz ->
    mockkClass(clazz)
}

После регистрации этих расширений мокирование не обязательно должно быть в специальном модуле или в одном месте. Любой вызов declareMock<T> {} создаст подходящий мок:

@Test
fun when_extensions_are_used_then_mocking_is_easier() {
    declareMock<RumourTeller> {
        every { tellRumour() } returns "I don't even know."
    }
    val mockedTeller: RumourTeller by inject()
    assertEquals("I don't even know.", mockedTeller.tellRumour())
}

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

7.2. Проверка модулей Koin

Koin также предоставляет инструменты для проверки конфигурации модуля и выявления всех возможных проблем с инжекцией, с которыми мы можем столкнуться в процессе рантайма. Сделать это очень просто: мы должны вызвать checkModules() внутри конфигурации KoinApplication, либо проверить список модулей с помощью checkKoinModules():

koinApplication {
    modules(koinModule, staticRumourModule)
    checkModules()
}

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

koinApplication {
    modules(koinModule, module { single { RumourSource() } })
    checkModules {
        withInstance<RumourSource>()
        withParameter<RumourTeller> { "Some param" }
    }
}

8. Заключение

В этом руководстве мы внимательно изучили библиотеку Koin и научились ее применять.

Koin создает корневой контекстный объект, который содержит настройки для создания зависимостей и специфические параметры для этих зависимостей. Для синглтон-объектов он также сохраняет ссылки на экземпляры этих зависимостей.

Это своего рода хранилище, где определения зависимостей (singleton, factory и т. д.) объединяются вместе и могут быть запрошены из вашего кода. 

Зависимости могут быть описаны в виде модулей

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

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

Модули предоставляют описание зависимостей, которые загружаются в функцию-создатель (creator function) объекта Koin. Они описывают, как создавать зависимости с помощью функций-продюсеров (producer functions), которые часто представляют собой простые вызовы конструкторов. 

Модули могут зависеть друг от друга, и отдельные определения способны иметь различные области видимости (scopes), такие как синглтон (singleton) и фабрика (factory). Области видимости (scopes) в контексте Koin определяют, как долго будет существовать созданный объект зависимости и каким образом он будет создаваться. Например, синглтон означает, что объект создается один раз и сохраняется для повторного использования. Все последующие запросы на этот объект будут возвращать один и тот же экземпляр. Фабрика, напротив, создает новый экземпляр каждый раз, когда запрашивается зависимость. Определения также способны содержать параметры, которые позволяют нам внедрять данные, специфичные для окружения, в дерево объектов. Это означает, что мы можем передавать конкретные значения или настройки в конструкторы зависимостей в момент их создания. Таким образом, мы в состоянии адаптировать поведение наших зависимостей в соответствии с текущим окружением или требованиями приложения.

Для доступа к зависимостям нам нужно пометить один или несколько объектов как KoinComponent. После этого мы можем объявлять поля в таких объектах с использованием делегатов inject() или же производить их немедленную инициализацию с помощью get().

Во время создания Koin мы можем инжектировать параметры из файла, среды и программно созданной Map (карты). Можно использовать все три способа или любое их подмножество. В объекте Koin эти свойства хранятся в едином реестре, так что преимущество получает последнее загруженное определение свойства.

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

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

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

Как обычно, весь наш код доступен на GitHub.


Материал подготовлен в преддверии старта онлайн-курса "Kotlin Backend Developer. Professional". Недавно в рамках курса прошел открытый урок на тему применимости Kotlin в различных направлениях разработки: Multimedia, ML, 3D/VR, Frontend, IoT/Robotics, Blockchain. Если интересно, запись занятия можно посмотреть по ссылке.

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