---
title: "Въведение в Ktor и Exposed"
url: "https://romankryvolapov.com/bg/ktor-manual/"
description: "Сървърно приложение на Kotlin с Ktor: работа с базата данни чрез Exposed и JDBI, инжектиране на зависимости с Koin, тестове с Mockk и Testcontainers, миграции с Flyway."
language: bg
published: 2024-11-04
updated: 2026-11-13
---
Ktor е асинхронен фреймуърк за разработка на приложения на Kotlin, който напоследък набира популярност. Позволява да се създават както сървърни, така и клиентски приложения и предоставя инструментариум за работа с HTTP, WebSockets и други мрежови протоколи.

## Предимства на Ktor

**Асинхронност и висока производителност:** \
Ktor използва корутините на Kotlin, което осигурява ефективна асинхронна обработка на заявките и висока производителност на приложението.

**Лекота и модулност:** \
Фреймуъркът предоставя минимален набор от базови функции и позволява да се включат само необходимите модули и плъгини, което намалява размера и сложността на приложението.

**Простота и изразителност на кода:** \
Благодарение на DSL (Domain-Specific Language) кодът става по-четим и разбираем, което опростява разработката.

**Кросплатформеност:** \
Ktor поддържа JVM, JavaScript и Native, което позволява да се създават приложения за различни платформи, включително сървъри, десктоп и мобилни устройства.

**Интеграция с екосистемата на Kotlin:** \
Пълна съвместимост с други библиотеки и инструменти на Kotlin, като kotlinx.serialization за сериализация на данни и kotlinx.coroutines за управление на нишките.

**Разширяемост:** \
Възможност за създаване на собствени плъгини и разширения, което позволява фреймуъркът да се адаптира към специфичните нужди на проекта.

**Поддръжка на съвременни протоколи:** \
Вградена поддръжка на HTTP/2, WebSockets и други съвременни мрежови технологии.

**Активна общност и поддръжка:** \
Редовни обновления от JetBrains и активна общност от разработчици, което осигурява стабилност и непрекъснато развитие на фреймуърка.

**Заедно с Ktor могат да се използват:**

**Koin:**\
библиотека за инжектиране на зависимости.

**Exposed:**\
ORM за работа с база данни.

**JDBI (Java Database Interface):**\
библиотека на Java за опростена работа с релационни бази данни.

и много други

## Основни разлики между Ktor и Spring

**Език и интеграция:**

**Ktor:** \
Проектиран е специално за Kotlin и използва максимално възможностите му, като корутините и DSL (Domain-Specific Language) за конфигурация.\
**Spring:** \
Базиран е на Java, но поддържа Kotlin чрез специални модули. Някои функции обаче може да са по-слабо оптимизирани за Kotlin.\
Архитектурен подход:

**Ktor:** \
Предлага лек и модулен подход и позволява на разработчиците да избират само необходимите компоненти. Това дава повече контрол върху конфигурацията и зависимостите.\
**Spring:** \
Предлага всеобхватен набор от функции „от кутията“, което може да ускори разработката, но понякога води до излишък.

**Производителност:**

**Ktor:** \
Благодарение на асинхронната си природа и на използването на корутини Ktor може да осигури висока производителност при обработка на голям брой едновременни заявки.\
**Spring:** \
Традиционно използва синхронен модел, макар че поддържа асинхронност чрез Spring WebFlux. Това обаче усложнява настройката и използването.\
Конфигурация и настройка:

**Ktor:** \
Използва конфигурация в кода с помощта на DSL, което прави настройките по-интуитивни и типово безопасни.\
**Spring:** \
Разчита на анотации и външни конфигурационни файлове (XML или YAML), което може да е по-малко прозрачно и да изисква повече код.

**Екосистема и поддръжка:**

**Ktor:** \
По-нов е и има по-малка екосистема в сравнение със Spring, но се развива активно и се поддържа от JetBrains.\
**Spring:** \
Има дълга история, обширна документация и голяма общност, което улеснява намирането на решения и поддръжката.

**Употреба:**

**Ktor:** \
Идеален е за микроуслуги и приложения, където са важни производителността и гъвкавостта.\
**Spring:** \
Подходящ е за големи корпоративни приложения със сложни изисквания, където се цени богатата функционалност на фреймуърка.

## Пример за приложение с Ktor

**Конфигурация на build.gradle.kts:**

```kotlin
plugins {
    application
    kotlin("jvm") version "1.9.10"
}


application {
    mainClass.set("com.example.ApplicationKt")
}


repositories {
    mavenCentral()
}


dependencies {

    // Ktor server and client
    implementation("io.ktor:ktor-server-core:2.3.2")
    implementation("io.ktor:ktor-server-netty:2.3.2")
    implementation("io.ktor:ktor-client-core:2.3.2")
    implementation("io.ktor:ktor-client-cio:2.3.2")

    // Logging
    implementation("ch.qos.logback:logback-classic:1.4.8")

    // Koin
    implementation("io.insert-koin:koin-ktor:3.4.0")

    // Exposed
    implementation("org.jetbrains.exposed:exposed-core:0.43.0")
    implementation("org.jetbrains.exposed:exposed-dao:0.43.0")
    implementation("org.jetbrains.exposed:exposed-jdbc:0.43.0")

    // Database (H2 for example)
    implementation("com.h2database:h2:2.1.214")

}

```

## Услуга за работа с потребители

Създаваме директорията src/main/kotlin/com/example/services и добавяме файла UserService.kt – клас, който управлява операциите с базата данни, свързани със същността User. Той използва библиотеката Exposed за взаимодействие с базата данни и изпълнява две основни функции: добавяне на потребител и получаване на списъка с всички потребители.

```kotlin
import com.example.models.Users
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction


class UserService {

    fun addUser(name: String, email: String) {
        transaction {
            Users.insert {
                it[Users.name] = name
                it[Users.email] = email
            }
        }
    }


    fun getUsers(): List<Map<String, Any>> {
        return transaction {
            Users.selectAll().map {
                mapOf("id" to it[Users.id], "name" to it[Users.name], "email" to it[Users.email])
            }
        }
    }

}
```

**Методът addUser(name: String, email: String):**\
Този метод добавя нов потребител в базата данни. Нека видим как се случва това:

**transaction:**\
блокът transaction гарантира, че всички операции в него се изпълняват в една транзакция. Това е важно при работа с база данни, за да се запази целостта на данните. Ако в транзакцията възникне грешка, промените се отменят.

**Users.insert:**\
функцията insert добавя нов запис в таблицата Users.

**it\[Users.name\] = name и it\[Users.email\] = email:**\
в блока insert се задават стойностите за полетата на таблицата Users. Тук name и email се вземат от параметрите на функцията addUser.

**Методът getUsers():**\
Този метод връща списък с всички потребители от базата данни във вид на списък от карти (ключ–стойност). Ще разгледаме как работи.

**transaction:**\
както в addUser, и тук се използва блок за транзакция при операциите с базата данни. Всички операции в този блок се изпълняват като едно цяло.

**Users.selectAll():**\
методът selectAll() избира всички записи от таблицата Users.

**.map { … }:**\
методът map се използва за преобразуване на всеки ред от резултата на селекцията в Map. За всеки ред се създава карта, в която ключовете са имената на полетата (id, name, email), а стойностите се вземат от съответните колони на таблицата Users:\
**"id" to it\[Users.id\]:** добавя в картата id на потребителя.\
**"name" to it\[Users.name\]:** добавя името на потребителя.\
**"email" to it\[Users.email\]:** добавя email на потребителя.

## Настройка на модула на Ktor

Сега ще дефинираме основния модул на приложението с Ktor и маршрут за работа с потребителите.\
Създаваме src/main/kotlin/com/example/Application.kt:

```kotlin
import com.example.di.appModule
import com.example.models.initDatabase
import com.example.services.UserService
import io.ktor.application.*
import io.ktor.features.ContentNegotiation
import io.ktor.features.DefaultHeaders
import io.ktor.features.StatusPages
import io.ktor.gson.gson
import io.ktor.http.HttpStatusCode
import io.ktor.response.*
import io.ktor.request.*
import io.ktor.routing.*
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import org.koin.ktor.ext.inject
import org.koin.ktor.ext.Koin


fun main() {
    embeddedServer(Netty, port = 8080) {
        initDatabase()
        installModules()
    }.start(wait = true)
}


fun Application.installModules() {

    // Koin
    install(Koin) {
        modules(appModule)
    }


    // Ktor features
    install(DefaultHeaders)
    install(ContentNegotiation) {
        gson {
            setPrettyPrinting()
        }
    }
    install(StatusPages) {
        exception<Throwable> { cause ->
            call.respond(HttpStatusCode.InternalServerError, cause.localizedMessage)
        }
    }


    val userService by inject<UserService>()
    routing {
        route("/users") {
            post {
                val request = call.receive<Map<String, String>>()
                val name = request["name"] ?: error("Name is required")
                val email = request["email"] ?: error("Email is required")
                userService.addUser(name, email)
                call.respond(HttpStatusCode.Created)
            }

            get {
                call.respond(userService.getUsers())
            }
        }
    }

}
```

**Функцията main:**

```kotlin
fun main() {
    embeddedServer(Netty, port = 8080) {
        initDatabase()
        installModules()
    }.start(wait = true)
}
```

**embeddedServer:**\
това е функцията за създаване и стартиране на сървъра. Тя използва Netty като двигател и стартира сървъра на порт 8080.

**initDatabase():**\
инициализация на базата данни. Извиква функцията, която настройва връзката с базата и създава таблиците.

**installModules():**\
основната функция за конфигуриране на приложението, която инсталира Koin и функциите на Ktor.

**Функцията installModules:**

```kotlin
fun Application.installModules() {
    // Koin
    install(Koin) {
        modules(appModule)
    }
```

Тази функция настройва Ktor и Koin и дефинира маршрутите за обработка на заявките.

**install(Koin) { modules(appModule) }:**\
включване на Koin за инжектиране на зависимости; инициализира модула appModule, който съдържа зависимостите, включително UserService.

**Инсталиране на функциите на Ktor:**

```kotlin
    install(DefaultHeaders)
    install(ContentNegotiation) {
        gson {
            setPrettyPrinting()
        }
    }
    install(StatusPages) {
        exception<Throwable> { cause ->
            call.respond(HttpStatusCode.InternalServerError, cause.localizedMessage)
        }
    }
```

**DefaultHeaders:**\
добавя стандартни HTTP хедъри към всеки отговор (например Date).

**ContentNegotiation:**\
настройка на сериализацията и десериализацията на данните в JSON с помощта на библиотеката Gson.

**StatusPages:**\
обработка на грешки. Тук, ако възникне изключение, сървърът връща статус 500 Internal Server Error и съобщение за грешката.

**Инжектиране на UserService и маршрути на Ktor:**

```kotlin
    val userService by inject<UserService>()
    routing {
        route("/users") {
            post {
                val request = call.receive<Map<String, String>>()
                val name = request["name"] ?: error("Name is required")
                val email = request["email"] ?: error("Email is required")
                userService.addUser(name, email)
                call.respond(HttpStatusCode.Created)
            }

            get {
                call.respond(userService.getUsers())
            }
        }
    }
```

**val userService by inject():**\
тук inject на Koin инжектира екземпляр на UserService в приложението. Тази услуга се използва за взаимодействие с базата данни.

**routing:**\
блокът за маршрутизация на Ktor, където са дефинирани пътищата и обработчиците на заявките.

**Маршрутът /users:**

**POST /users**: \
Получава данни от тялото на заявката (във формат JSON), след което извиква userService.addUser(name, email), за да добави потребителя в базата данни. При успешно добавяне се изпраща отговор с код 201 Created.

**GET /users**: \
Връща списъка с всички потребители от базата данни с помощта на userService.getUsers() и изпраща резултата на клиента.

**HTTP клиент за заявки към външни API:**\
Ако е нужно да добавим HTTP клиент за заявки към външни API, можем да създадем в src/main/kotlin/com/example/services файла ExternalApiService.kt:

```kotlin
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.request.*
import io.ktor.client.statement.*

class ExternalApiService {

    private val client = HttpClient(CIO)


    suspend fun getExternalData(): String {
        return client.get("https://jsonplaceholder.typicode.com/todos/1").bodyAsText()
    }

}
```

**Подробно обяснение:**

**private val client = HttpClient(CIO):**\
Тук се създава обектът client от тип HttpClient, който се използва за изпращане на HTTP заявки.\
На конструктора на HttpClient се подава CIO – асинхронен HTTP двигател, който Ktor предоставя за изпълнение на мрежови заявки. Този двигател поддържа асинхронно изпълнение на входно-изходните операции и позволява ресурсите да се използват оптимално.\
Създаденият екземпляр на HttpClient ще се използва за всички заявки, изпълнени с този клиент, докато не бъде затворен.

**suspend fun getExternalData(): String:**\
suspend е ключова дума, която показва, че функцията е асинхронна и изпълнението ѝ може да бъде спряно.\
Асинхронните функции в Kotlin позволяват да се стартират операции, които отнемат време (например мрежови заявки), без да се блокира основната нишка.\
Тази функция връща резултат от тип String, който съдържа тялото на отговора от външното API.

**return client.get("https://jsonplaceholder.typicode.com/todos/1").bodyAsText():**

**client.get(…):**\
това е методът, който изпълнява HTTP GET заявка към посочения URL.

**URL https://jsonplaceholder.typicode.com/todos/1:**\
това е тестов API ендпойнт, който предоставя данни за задача с ID 1.

**Методът get:**\
това е функция, която връща обект HttpResponse, а не низ. За да се получи тялото на отговора като низ, се извиква bodyAsText().\
Когато се извика функцията getExternalData(), клиентът изпраща заявката и връща резултата като низ, който съдържа JSON отговора от сървъра.

**Особености на работата:**

**Асинхронност:** \
Тъй като функцията е suspend, заявката се изпълнява асинхронно. Това позволява на програмата да не блокира нишката, докато чака отговор от сървъра, което е особено полезно при голям брой мрежови заявки.

**Клиентът CIO:** \
Използването на асинхронен двигател позволява оптимално управление на нишките, намалява натоварването на системата и повишава производителността.

**Използване на API:**\
POST /users с тяло на заявката { "name": "John", "email": "john@example.com" } за добавяне на потребител.\
GET /users за получаване на списъка с потребители.

## Настройка на базата данни с Exposed

Ще създадем таблица и DAO клас за работа с базата данни. Създаваме директорията src/main/kotlin/com/example/models и добавяме в нея файла User.kt за модела на потребителя.

```kotlin
import org.jetbrains.exposed.dao.id.IntIdTable
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.transactions.transaction

object Users : IntIdTable() {
    val name = varchar("name", 50)
    val email = varchar("email", 100).uniqueIndex()
}


fun initDatabase() {
    Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;", driver = "org.h2.Driver")
    transaction {
        SchemaUtils.create(Users)
    }
}
```

**Базов пример за таблицата Users:**\
Класът Users наследява IntIdTable(), което означава, че таблицата ще има автоинкрементиращо се целочислено поле id като първичен ключ. Полетата name и email са дефинирани като низове, като email е обявено за уникално:

**Функцията initDatabase:**\
Свързва се с базата данни (тук за тестове се използва H2 в паметта) и създава схемата (таблиците) с помощта на SchemaUtils.create.

**Пример за създаване на връзки „един към много“:**\
Ще добавим таблицата Posts, която ще има външен ключ, свързан с таблицата Users, и така ще създадем отношението „потребител – постове“ (един потребител може да има много постове).

```kotlin
object Posts : IntIdTable() {
    val title = varchar("title", 255)
    val content = text("content")
    // външен ключ, който сочи към потребителя
    val user = reference("user_id", Users)
}
```

Сега функцията за инициализация на базата данни ще създава и двете таблици:

```kotlin
fun initDatabase() {
    Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;", driver = "org.h2.Driver")
    transaction {
        SchemaUtils.create(Users, Posts)
    }
}
```

**Вмъкване на данни в свързани таблици:**

```kotlin
transaction {
    // Вмъкване на потребител
    val userId = Users.insertAndGetId {
        it[name] = "Alice"
        it[email] = "alice@example.com"
    }

    // Вмъкване на пост за потребителя
    Posts.insert {
        it[title] = "Първи пост"
        it[content] = "Съдържание на първия пост"
        it[user] = userId
    }
}

```

**Заявка с използване на връзка:**\
За да получим всички постове на конкретен потребител, можем да изпълним заявка:

```kotlin
transaction {
    val userPosts = Posts.select { Posts.user eq userId }.map {
        it[Posts.title] to it[Posts.content]
    }

    userPosts.forEach { (title, content) ->
        println("Post title: $title, content: $content")
    }
}
```

**Пример за създаване на връзка „много към много“:**\
Ще добавим таблицата Tags и връзка „много към много“ между Posts и Tags.

```kotlin
object Tags : IntIdTable() {
    val name = varchar("name", 50)
}


// Междинна таблица за връзката „много към много“
object PostTags : Table() {
    val post = reference("post_id", Posts)
    val tag = reference("tag_id", Tags)
    override val primaryKey = PrimaryKey(post, tag)
}
```

**Сега функцията за инициализация на базата данни трябва да създава три таблици:**

```kotlin
fun initDatabase() {
    Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;", driver = "org.h2.Driver")
    transaction {
        SchemaUtils.create(Users, Posts, Tags, PostTags)
    }
}
```

**Вмъкване на данни в свързани таблици:**\
Ще създадем тагове и ще ги свържем с поста

```kotlin
transaction {
    val postId = Posts.insertAndGetId {
        it[title] = "Втори пост"
        it[content] = "Съдържание на втория пост"
        it[user] = userId
    }

    val tagId1 = Tags.insertAndGetId { it[name] = "Kotlin" }
    val tagId2 = Tags.insertAndGetId { it[name] = "Programming" }

    PostTags.insert {
        it[post] = postId
        it[tag] = tagId1
    }
    PostTags.insert {
        it[post] = postId
        it[tag] = tagId2
    }
}
```

**Заявка за получаване на всички тагове на поста:**\
За да получим таговете, свързани с поста

```kotlin
transaction {
    val tags = (PostTags innerJoin Tags).slice(Tags.name)
        .select { PostTags.post eq postId }
        .map { it[Tags.name] }
    println("Tags for post: $tags")
}
```

**Пример за обновяване и изтриване на данни:**

**Обновяване на данни:**\
Да приемем, че трябва да обновим email на потребителя. Използваме заявката update

```kotlin
transaction {
    Users.update({ Users.id eq userId }) {
        it[email] = "newalice@example.com"
    }
}

```

**Изтриване на данни:**\
За изтриване на потребител (и евентуално на свързаните записи) се използва delete

```kotlin
transaction {
    Users.deleteWhere { Users.id eq userId }
}
```

**Пример за сложна заявка със сортиране и лимит:**\
Ще изберем последните 5 поста на потребителя, сортирани по дата на създаване (приемаме, че полето created в таблицата Posts съществува и се попълва автоматично)

```kotlin
object Posts : IntIdTable() {
    val title = varchar("title", 255)
    val content = text("content")
    val created = datetime("created").defaultExpression(CurrentDateTime())
    val user = reference("user_id", Users)
}
```

**Сега, за да получим последните 5 поста на потребителя:**

```kotlin
transaction {
    val latestPosts = Posts.select { Posts.user eq userId }
        .orderBy(Posts.created, SortOrder.DESC)
        .limit(5)
        .map {
            it[Posts.title] to it[Posts.created]
        }
    latestPosts.forEach { (title, created) ->
        println("Title: $title, Created: $created")
    }
}
```

## Настройка на базата данни с JDBI

**JDBI (Java Database Interface):**\
това е библиотека на Java за опростена работа с релационни бази данни. Предоставя удобно API върху JDBC (Java Database Connectivity), което прави взаимодействието с базата данни по-малко сложно и по-разбираемо. JDBI позволява SQL заявките да се пишат в чист вид, но автоматизира преобразуването на данните между SQL и Java обекти, което опростява работата с бази данни и прави кода по-четим и по-лесен за поддръжка.

**Основни особености на JDBI:**

**Лекота:**\
JDBI е ориентиран към използване на прости SQL заявки и не налага ORM (Object-Relational Mapping) като Hibernate или Exposed.

**Поддръжка на SQL Object API:**\
В JDBI SQL заявките могат да се описват с анотации в интерфейси и автоматично да се свързват с методи, което позволява да се избегне писането на много код.

**Мапинг на обекти:**\
JDBI автоматично преобразува редовете от резултата на SQL заявката в Java обекти.

**Гъвкавост:**\
За разлика от ORM решенията, JDBI позволява да се разработват приложения, в които SQL заявките се пишат директно, като се запазва пълен контрол върху изпълнението на заявките и производителността.

**Основни компоненти на JDBI:**

**Handle:**\
основният обект на JDBI за изпълнение на заявки. Той отваря и управлява връзката с базата данни.

**SQL Object API:**\
позволява SQL заявките да се пишат като методи на интерфейси с анотации, което прави кода лаконичен.

**Fluent API:**\
предоставя „течен“ API за работа със SQL заявки, в който параметрите и изпълнението на заявките могат да се настройват гъвкаво.

**Пример за работа с JDBI:**\
Да приемем, че имаме база данни с таблица users. Нека разгледаме базов пример за използване на JDBI за взаимодействие с тази таблица.

**Настройка на JDBI:**\
Включваме зависимостта на JDBI в build.gradle.kts:

```kotlin
dependencies {
    implementation("org.jdbi:jdbi3-core:3.29.0")
    // за SQL Object API
    implementation("org.jdbi:jdbi3-sqlobject:3.29.0")
     // за поддръжка на Kotlin
    implementation("org.jdbi:jdbi3-kotlin:3.29.0")
}
```

**Създаваме клас на модела:**

```kotlin
data class User(
  val id: Int,
  val name: String,
  val email: String
)
```

**Създаваме интерфейс за SQL заявките, като използваме анотациите на JDBI:**

```kotlin
import org.jdbi.v3.sqlobject.config.RegisterBeanMapper
import org.jdbi.v3.sqlobject.statement.SqlQuery
import org.jdbi.v3.sqlobject.statement.SqlUpdate


// Указва на JDBI автоматично да мапва резултатите от заявките в User
@RegisterBeanMapper(User::class)
interface UserDao {

    @SqlUpdate("INSERT INTO users (name, email) VALUES (:name, :email)")
    fun insertUser(name: String, email: String)


    @SqlQuery("SELECT * FROM users WHERE id = :id")
    fun findById(id: Int): User?


    @SqlQuery("SELECT * FROM users")
    fun listUsers(): List<User>

}
```

**Използване на UserDao за изпълнение на заявки:**

```kotlin
import org.jdbi.v3.core.Jdbi

fun main() {

    // Създаваме връзка с JDBI
    val jdbi = Jdbi.create("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;", "username", "password")


    // Работа с базата данни чрез DAO
    jdbi.useHandle<Exception> { handle ->
        val userDao = handle.attach(UserDao::class.java)


        // Създаваме таблицата
        handle.execute("CREATE TABLE users (id IDENTITY PRIMARY KEY, name VARCHAR(50), email VARCHAR(100))")


        // Добавяме потребители
        userDao.insertUser("Alice", "alice@example.com")
        userDao.insertUser("Bob", "bob@example.com")


        // Получаваме потребителя по ID
        val user = userDao.findById(1)
        println("User: $user")


        // Получаваме списъка с всички потребители
        val users = userDao.listUsers()
        println("All users: $users")
    }

}
```

**Обяснение на примера:**

**Настройка на връзката:** \
JDBI се създава с Jdbi.create(…), където се посочват низът за връзка, потребителското име и паролата.

**Използване на интерфейса UserDao:** \
В JDBI интерфейсът UserDao е свързан с връзката към базата данни чрез handle.attach(UserDao::class.java), което позволява SQL заявките да се използват като методи.

**SQL анотации:** \
Методите в UserDao са анотирани с @SqlUpdate (за заявки за промяна) и @SqlQuery (за заявки за избор). Те определят кои SQL заявки ще се изпълняват при извикване на съответните методи.

**Преобразуване на резултатите:** \
@RegisterBeanMapper(User::class) указва на JDBI автоматично да преобразува резултатите в обекти User, което опростява работата с резултатите от заявките.

## Настройка на Koin за инжектиране на зависимости

Koin е библиотека за инжектиране на зависимости (Dependency Injection, DI) в Kotlin. Тя позволява да се опрости и автоматизира управлението на зависимостите, като кодът става по-чист и лесен за тестване. Koin се отличава от другите DI фреймуърци по това, че не изисква анотации или генериране на код, а използва Kotlin DSL за деклариране на зависимостите.

**Основни особености на Koin:**

**Лекота и простота:**\
Koin е разработен специално за Kotlin, затова използва Kotlin DSL и позволява декларациите на зависимостите да се пишат с минимална настройка.

**Без анотации и прокси класове:**\
За разлика от Dagger или Hilt, Koin не използва анотации и не изисква предварителна компилация, за да работи. Това го прави бърз и гъвкав за настройка.

**Поддръжка на модулна структура:**\
Koin позволява зависимостите да се организират в модули, което е удобно за мащабиране и поддръжка на голямо приложение.

**Тестваемост:**\
С Koin зависимостите лесно се подменят за тестове, благодарение на което юнит тестването става по-просто.

**Основни компоненти на Koin:**

**Модули:**\
контейнер за зависимости, в който са описани всички необходими класове и техните зависимости.

**single и factory:**\
Koin поддържа два типа декларации на зависимости:\
**single:**\
създава синглтон – един и същ екземпляр на класа през цялото време на работа на приложението.\
**factory:**\
създава нов екземпляр на класа при всяко извикване.

**inject и get:**\
inject се използва за инжектиране на зависимости чрез делегати, а get – за получаване на зависимост навсякъде в кода, където е достъпен контекстът на Koin.

**Пример за използване на Koin:**\
Ще разгледаме как се инжектират зависимости с помощта на Koin на примера на просто приложение с класовете UserRepository и UserService.

**Добавяне на зависимостта на Koin към проекта:**\
В build.gradle.kts добавяме зависимостта за Koin

```kotlin
dependencies {
    implementation("io.insert-koin:koin-core:3.4.0")
    implementation("io.insert-koin:koin-ktor:3.4.0") // за интеграция с Ktor
}
```

**Създаване на класовете и зависимостите:**

```kotlin
// UserRepository.kt
class UserRepository {
    fun getUser() = "User data from repository"
}


// UserService.kt
class UserService(private val userRepository: UserRepository) {
    fun getUserData() = userRepository.getUser()
}
```

**Създаване на модул на Koin:**\
Ще създадем модул, който декларира зависимостите за UserRepository и UserService:

```kotlin
import org.koin.dsl.module


val appModule = module {
  	// единствен екземпляр на UserRepository
    single { UserRepository() }
    // нов екземпляр на UserService при всяка заявка
    factory { UserService(get()) }
}
```

**single { UserRepository() }:**\
определя UserRepository като синглтон.

**factory { UserService(get()) }:**\
определя UserService като фабрика, която създава нов екземпляр всеки път, когато той бъде поискан. get() указва на Koin да използва екземпляра на UserRepository за създаването на UserService.

**Стартиране на Koin в приложението:**\
Koin се стартира чрез добавяне на модулите в неговия контекст. В Ktor това се прави чрез install(Koin):

```kotlin
import io.ktor.application.*
import org.koin.core.context.startKoin
import org.koin.ktor.ext.Koin


fun main() {
    embeddedServer(Netty, port = 8080) {
        install(Koin) {
            modules(appModule)
        }
        // останалата конфигурация на Ktor
    }.start(wait = true)
}
```

**Използване на зависимостите с inject:**\
Сега можем да използваме UserService с помощта на inject

```kotlin
import org.koin.ktor.ext.inject


class UserController {

    private val userService by inject<UserService>()


    fun printUserData() {
        println(userService.getUserData())
    }
}
```

**Пример за използване на Koin в тестове:**\
Koin позволява лесно да се заменят зависимостите в тестовете. Например

```kotlin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
import org.koin.test.KoinTest
import org.koin.test.inject
import kotlin.test.BeforeTest
import kotlin.test.AfterTest
import kotlin.test.Test


class UserServiceTest : KoinTest {

    private val userService by inject<UserService>()


    @BeforeTest
    fun setup() {
        val testModule = module {
            single { UserRepository() }
            factory { UserService(get()) }
        }
        startKoin { modules(testModule) }
    }


    @AfterTest
    fun tearDown() {
      	// Спиране на Koin след тестовете
        stopKoin()
    }


    @Test
    fun testUserData() {
        assertEquals("User data from repository", userService.getUserData())
    }

}
```

## Примери за тестове с Mockk

Ето пример как може да се тества UserService с библиотеката Koin за инжектиране на зависимости и MockK за създаване на моукове (фалшиви обекти).\
Да приемем, че тестваме UserService, който използва UserRepository за получаване на данни за потребителя. В теста ще заместим реалния UserRepository с мок обект, за да изолираме теста от базата данни или от други външни

**Настройка на зависимостите:**\
В build.gradle.kts добавяме зависимостите за Koin, MockK и Koin-test.

```kotlin
dependencies {
    // Основни зависимости
    implementation("io.insert-koin:koin-core:3.4.0")


    // Зависимости за тестове
    testImplementation("io.insert-koin:koin-test:3.4.0")
    testImplementation("io.mockk:mockk:1.12.0")
    testImplementation("org.jetbrains.kotlin:kotlin-test")
}

```

**Класовете UserRepository и UserService:**\
Класовете, които ще тестваме, изглеждат така

```kotlin
// UserRepository.kt
class UserRepository {

    fun getUser(): String {
        // В реална ситуация този метод би могъл да се обръща към база данни
        return "Real User Data"
    }
}


// UserService.kt
class UserService(private val userRepository: UserRepository) {

    fun getUserData(): String {
        return userRepository.getUser()
    }
}

```

**Настройка на модула на Koin за тестване:**\
Ще създадем тест, в който ще подменим UserRepository с мок обект с помощта на MockK.

```kotlin
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
import org.koin.test.KoinTest
import org.koin.test.inject
import io.mockk.every
import io.mockk.mockk
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals


class UserServiceTest : KoinTest {

    // Зависимостта UserService ще бъде инжектирана автоматично от Koin
    private val userService by inject<UserService>()
    private val mockRepository = mockk<UserRepository>()


    @BeforeTest
    fun setup() {

        // Дефиниране на тестовия модул на Koin, в който заменяме UserRepository с мок
        val testModule = module {
          	// Използваме mockRepository вместо реалния UserRepository
            single { mockRepository }
            single { UserService(get()) }
        }

        // Стартираме Koin с тестовия модул
        startKoin { modules(testModule) }

    }


    @AfterTest
    fun tearDown() {
        // Спираме Koin след изпълнението на теста
        stopKoin()
    }


    @Test
    fun `should return mock user data`() {

        // Настройваме поведението на мок обекта
        every { mockRepository.getUser() } returns "Mocked User Data"


        // Изпълняваме тествания метод
        val result = userService.getUserData()


        // Проверяваме дали резултатът отговаря на очакванията
        assertEquals("Mocked User Data", result)

    }

}
```

**Обяснение на теста:**

**Създаване на мок обект:**

```kotlin
private val mockRepository = mockk<UserRepository>()
```

Създаваме мок обект за UserRepository с помощта на MockK, за да не използваме реална база данни или други външни зависимости.

**Дефиниране на тестовия модул**:

```kotlin
val testModule = module {
    single { mockRepository }
    single { UserService(get()) }
}
```

В тестовия модул UserRepository се заменя с mockRepository, а UserService се създава с тази подменена зависимост.

**Настройка на поведението на мока**:

```kotlin
every { mockRepository.getUser() } returns "Mocked User Data"
```

Тук указваме, че при извикване на getUser() на mockRepository методът трябва да връща "Mocked User Data". Това позволява UserService да се тества изолирано от реалното поведение на UserRepository.

**Проверка на резултата**:

```kotlin
val result = userService.getUserData()
assertEquals("Mocked User Data", result)
```

След като извикаме getUserData на UserService, очакваме той да върне "Mocked User Data", тъй като това поведение е зададено за мока на UserRepository.

**Спиране на Koin**:

```kotlin
stopKoin()
```

След края на теста Koin се спира, за да не повлияе тестовият контекст на другите тестове.

Този пример показва как да се използва Koin за управление на зависимостите и MockK за подмяна на поведението на зависимостите в тестовете. Благодарение на този подход можем да изолираме UserService и да тестваме логиката му, без да разчитаме на реализацията на UserRepository.

## Примери за тестове с Testcontainers

Testcontainers е Java библиотека за стартиране на Docker контейнери в тестова среда. Тя позволява реални услуги, като бази данни, опашки за съобщения, кешове и други инфраструктурни компоненти, да се стартират като Docker контейнери директно по време на изпълнението на тестовете. Това прави Testcontainers удобен за писане на интеграционни тестове, които изискват работа с истински услуги, вместо използване на фалшиви реализации или вградени бази данни.

**Основни особености на Testcontainers:**

**Изолирана тестова среда:**\
Всеки тест се стартира в отделен контейнер, което позволява да се избегне влиянието на тестовете един върху друг и да се получават еднакви резултати независимо от локалните настройки.

**Поддръжка на различни бази данни:**\
Testcontainers поддържа много популярни бази данни, като PostgreSQL, MySQL, Redis, MongoDB и други. Това позволява приложението да се тества със същата база данни, която се използва в продукция.

**Поддръжка на други услуги:**\
Освен бази данни, Testcontainers поддържа услуги като Kafka, RabbitMQ, Selenium (за тестване на UI) и други.

**Интеграция с JUnit 4 и 5:**\
Testcontainers се интегрира лесно с популярните фреймуърци за тестване, като JUnit 4 и JUnit 5.

**Кога да се използва Testcontainers:**\
Testcontainers е полезен в следните ситуации\
Когато е нужно да се тества взаимодействие с база данни (например с PostgreSQL или MySQL) в условия, близки до реалната среда.\
При тестване на взаимодействието между различни компоненти на системата, например между приложение и брокер на съобщения.\
За писане на интеграционни тестове, където е важно да има достъп до истинска услуга, а не до фалшива реализация.\
Пример за използване на Testcontainers с PostgreSQL и JUnit 5\
Да приемем, че имаме приложение на Kotlin, което използва база данни PostgreSQL, и искаме да напишем интеграционен тест за проверка на взаимодействието с базата.

**Включване на зависимостите:**\
Добавяме зависимостите за Testcontainers и PostgreSQL в build.gradle.kts:

```kotlin
dependencies {
    testImplementation("org.testcontainers:testcontainers:1.19.0")
    testImplementation("org.testcontainers:postgresql:1.19.0")
    testImplementation("org.junit.jupiter:junit-jupiter:5.9.2")
}
```

**Пример за използване на Testcontainers за тест с база данни:**

```kotlin
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.AfterAll
import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import java.sql.Connection
import java.sql.DriverManager


@Testcontainers
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class PostgresTest {


    @Container
    private val postgresContainer = PostgreSQLContainer<Nothing>("postgres:15-alpine").apply {
        withDatabaseName("testdb")
        withUsername("testuser")
        withPassword("testpassword")
        // Стартираме контейнера
        start()
    }


    private lateinit var connection: Connection


    @BeforeAll
    fun setup() {
        // Свързване с базата данни вътре в контейнера
        connection = DriverManager.getConnection(
            postgresContainer.jdbcUrl,
            postgresContainer.username,
            postgresContainer.password
        )

        // Създаване на таблица за теста
        connection.createStatement().executeUpdate(
            """
            CREATE TABLE users (
                id SERIAL PRIMARY KEY,
                name VARCHAR(50) NOT NULL,
                email VARCHAR(100) NOT NULL
            );
            """
        )
    }


    @Test
    fun `test insert and query`() {
        // Вмъкване на данни
        connection.createStatement().executeUpdate(
            "INSERT INTO users (name, email) VALUES ('Alice', 'alice@example.com');"
        )


        // Изпълнение на заявка
        val resultSet = connection.createStatement().executeQuery("SELECT * FROM users")
        resultSet.next()
        val name = resultSet.getString("name")
        val email = resultSet.getString("email")

        assertEquals("Alice", name)
        assertEquals("alice@example.com", email)
    }


    @AfterAll
    fun teardown() {
        connection.close()
        // Спиране на контейнера след края на тестовете
        postgresContainer.stop()
    }
}

```

**Обяснение на кода:**

**Създаване на контейнера PostgreSQL:**\
С помощта на PostgreSQLContainer създаваме контейнер с PostgreSQL. Задаваме името на базата данни, потребителското име и паролата.\
Контейнерът се стартира с образа postgres:15-alpine.

**Настройка на връзката с базата данни:**\
В @BeforeAll се установява връзка с базата данни в контейнера.\
Също така се създава таблицата users за съхранение на данните от теста.

**Тестване на вмъкването и на заявката за данни:**\
В @Test се изпълнява вмъкване на данни и избирането им от таблицата users, след което коректността на данните се проверява с assertEquals.

**Спиране на контейнера след края на тестовете:**\
Контейнерът с PostgreSQL автоматично се спира след изпълнението на всички тестове благодарение на @AfterAll.

**Предимства на използването на Testcontainers:**

**Реални условия за тестване:** \
Позволява работа с реални услуги и инфраструктура, което помага да се открият грешки, които може да не се проявят при използване на вградени или имитирани услуги.

**Изолация на тестовете:** \
Всеки тест се стартира в нов контейнер, което гарантира чистота на данните и липса на зависимост от състоянието на другите тестове.

**Съвместимост с CI/CD:** \
Testcontainers се интегрира лесно в CI/CD пайплайни, което позволява тестовете да се стартират в изолирани среди на различни платформи.

## Пример за миграции с Flyway

Flyway е инструмент за управление на миграциите на базата данни, който позволява да се автоматизира процесът на обновяване и връщане назад на схемата на базата данни. Обикновено се използва за версиониране на структурата на базата данни и за поддържане на съгласуваност на схемата във всички среди.

**Стъпки за настройка и използване на Flyway:**\
Да приемем, че имаме проект с база данни, в който е необходимо да се добавят, променят или изтриват таблици, както и да се управляват промените в структурата чрез миграции на Flyway.

**Добавяне на Flyway към проекта:**\
Добавяме зависимостите на Flyway в build.gradle.kts:



```kotlin
dependencies {
    implementation("org.flywaydb:flyway-core:9.8.1")
    // Драйвер за PostgreSQL
    implementation("org.postgresql:postgresql:42.3.1")
}
```

**Настройка на Flyway:**\
Добавяме конфигурацията на Flyway в build.gradle.kts

```kotlin
import org.flywaydb.gradle.task.FlywayMigrateTask


plugins {
    id("org.flywaydb.flyway") version "9.8.1"
}


flyway {
    url = "jdbc:postgresql://localhost:5432/mydatabase"
    user = "username"
    password = "password"
    schemas = arrayOf("public")
    // Директория на миграциите
    locations = arrayOf("filesystem:src/main/resources/db/migration")
}
```

**Създаване на миграция:**\
Flyway търси SQL файловете на миграциите в папката, посочена в locations. За всяка промяна се създава файл за миграция, който ще бъде приложен автоматично при стартиране на Flyway. Миграциите трябва да следват формата на името на файла: V\<номер>__\<описание>.sql.

**Пример:**\
V1__create_users_table.sql\
V2__add_created_at_to_users.sql\
Всеки файл за миграция съдържа SQL команди за изпълнение на промяна в базата данни.

**Пример за файл за миграция V1__create_users_table.sql:**\
Ще създадем файл за миграция за създаване на таблицата users

```sql
-- V1__create_users_table.sql
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    name VARCHAR(50) NOT NULL,
    email VARCHAR(100) NOT NULL UNIQUE
);
```

**Пример за файл за миграция V2__add_created_at_to_users.sql:**\
Ще добавим нова колона created_at в таблицата users

```sql
-- V2__add_created_at_to_users.sql
ALTER TABLE users
ADD COLUMN created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP;
```

**Стартиране на миграциите на Flyway:**\
Сега можем да стартираме миграциите с команда на Gradle

```bash
./gradlew flywayMigrate
```

Тази команда ще изпълни всички миграции, които все още не са приложени, и ще обнови структурата на базата данни. Flyway запазва информация за изпълнените миграции в специална таблица (flyway_schema_history), за да гарантира, че всяка миграция се изпълнява само веднъж.

**Връщане назад на миграция (ако е необходимо):**\
Ако ни е нужно да върнем миграция назад, можем да използваме командата:

```bash
./gradlew flywayUndo
```

Трябва обаче да се отбележи, че Flyway поддържа връщане назад на миграции (undo) само в комерсиалната версия. В безплатната версия миграциите могат да се отменят ръчно, като се добавят отделни SQL файлове за промяна или изтриване на създадените данни.

**Пример за код за стартиране на миграциите програмно:**\
В някои случаи може да се наложи миграциите да се стартират програмно, например при стартиране на приложението. Flyway позволява това:

```kotlin
import org.flywaydb.core.Flyway


fun main() {
    val flyway = Flyway.configure()
        .dataSource("jdbc:postgresql://localhost:5432/mydatabase", "username", "password")
        .load()
    flyway.migrate()
}
```

## Използване на променливи на средата

Глобалните променливи на средата често се използват за съхранение на поверителна информация или на настройки, които зависят от средата за изпълнение на приложението (например бази данни, API ключове и други настройки), за да не се съхраняват директно в кода.\
В Kotlin (и Java) променливите на средата могат да се получават чрез системни свойства или чрез специални библиотеки, като dotenv. Нека разгледаме няколко начина за използване на променливите на средата.

**Пример за директен достъп до променливите на средата:**

Променливите на средата могат да се получат в Kotlin с помощта на System.getenv().\
Да приемем, че ни е нужно да получим данните за връзка с базата данни, като URL, потребителско име и парола, които са зададени в променливите на средата DB_URL, DB_USER, DB_PASSWORD.

```kotlin
fun main() {
    val dbUrl = System.getenv("DB_URL") ?: "jdbc:postgresql://localhost:5432/defaultdb"
    val dbUser = System.getenv("DB_USER") ?: "default_user"
    val dbPassword = System.getenv("DB_PASSWORD") ?: "default_password"
}
```

Този подход е удобен за прости приложения и е подходящ за променливи на средата, които се дефинират в системата (например във файла .bashrc или .zshrc на локалната машина).

**Използване на .env файл с библиотеката dotenv:**

За по-гъвкаво управление на променливите на средата, особено в различни среди (разработка, тестване, продукция), може да се използва библиотеката dotenv. Тя позволява променливите на средата да се съхраняват във файл .env и да се зареждат при стартиране на приложението.

**Добавяне на зависимост:**\
Добавяме библиотеката dotenv в build.gradle.kts:

```kotlin
dependencies {
    implementation("io.github.cdimascio:java-dotenv:5.2.2")
}
```

**Създаване на .env файл:**\
Създаваме .env файл в корена на проекта и добавяме в него променливите на средата:

```dotenv
DB_URL=jdbc:postgresql://localhost:5432/mydatabase
DB_USER=myuser
DB_PASSWORD=supersecretpassword
```

**Използване на променливите на средата от .env файла в кода:**\
След това може да се използва библиотеката dotenv за зареждане на променливите от .env файла:

```kotlin
import io.github.cdimascio.dotenv.dotenv


fun main() {
    val dotenv = dotenv()
    val dbUrl = dotenv["DB_URL"] ?: "jdbc:postgresql://localhost:5432/defaultdb"
    val dbUser = dotenv["DB_USER"] ?: "default_user"
    val dbPassword = dotenv["DB_PASSWORD"] ?: "default_password"
}
```

**Използване на променливите на средата с Ktor:**

Ktor също поддържа използването на променливи на средата за конфигурация на приложението. Променливите на средата могат да се зареждат през application.conf или да се получават с помощта на System.getenv().

**Пример за конфигурация на базата данни в application.conf:**\
В application.conf може да се посочи пътят към променливите на средата чрез ${}:

```text languages="HOCON"
ktor {
    deployment {
        port = 8080
    }
    database {
        url = ${DB_URL}
        user = ${DB_USER}
        password = ${DB_PASSWORD}
    }
}
```

**Пример за използване в кода на Ktor:**\
В кода на Ktor могат да се вземат стойностите от конфигурацията и да се използват за връзка с базата данни:

```kotlin
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*


fun main() {
    embeddedServer(Netty, port = 8080) {
        environment.config.config("database").apply {
            val dbUrl = property("url").getString()
            val dbUser = property("user").getString()
            val dbPassword = property("password").getString()
        }
    }.start(wait = true)
}
```

**Пример за стартиране с глобални променливи на средата:**\
За работа с променливи на средата в различни среди (например в Docker или в CI/CD системи) променливите на средата могат да се зададат при стартиране на приложението.

```bash
DB_URL="jdbc:postgresql://production-db:5432/proddb" \
DB_USER="produser" \
DB_PASSWORD="prodpassword" \
java -jar myapp.jar
```

**В IntelliJ IDEA лесно може да се настрои зареждането на променливите на средата от .env файл за проект на Kotlin/Java:**\
За да добавите променливите на средата от .env файла към конфигурацията за стартиране на Application в IntelliJ IDEA, изпълнете следните стъпки

**Отваряне на конфигурацията за стартиране:**\
В менюто изберете Run > Edit Configurations….\
Намерете конфигурацията за стартиране на вашето приложение в списъка (например Application).

**Добавяне на променливи на средата:**\
В настройките на конфигурацията намерете полето Environment variables.\
Натиснете иконата … до това поле, за да отворите редактора на променливите на средата.\
Добавете променливите ръчно, като копирате стойностите от .env файла, във формат КЛЮЧ=СТОЙНОСТ. Например:

```dotenv
DB_URL=jdbc:postgresql://localhost:5432/mydatabase
DB_USER=myuser
DB_PASSWORD=supersecretpassword
```

Променливите могат да се добавят една по една с натискане на +, или всички наведнъж, като се разделят със символа ; на Windows или : на macOS/Linux.\
Натиснете OK, за да запазите променливите.

**Запазване и стартиране:**\
Натиснете Apply и OK, за да запазите конфигурацията за стартиране.\
Сега променливите на средата ще бъдат достъпни за вашето приложение при стартиране. Можете да ги получите с System.getenv("КЛЮЧ") в кода.

**Алтернативен начин:** \
Използване на плъгина EnvFile\
За да заредите автоматично променливите от .env файла:\
Инсталирайте плъгина EnvFile в IntelliJ IDEA:\
Отворете File > Settings > Plugins.\
Намерете EnvFile и го инсталирайте.\
Рестартирайте IDE след инсталацията.\
В Run > Edit Configurations… отворете конфигурацията за стартиране на вашето приложение.\
В раздела EnvFile отбележете Enable EnvFile и изберете .env файла.\
Запазете настройките и стартирайте проекта.

[ Copyright: Roman Kryvolapov ](https://t.me/RomanKryvolapov)
