Перейти к содержимому

Введение в Ktor и Exposed

Ktor это набирающий в последнее время популярность асинхронный фреймворк для разработки приложений на языке Kotlin. Он позволяет создавать как серверные, так и клиентские приложения, предоставляя инструментарий для работы с HTTP, WebSockets и другими сетевыми протоколами.

Асинхронность и высокопроизводительность:
Ktor использует корутины Kotlin, что обеспечивает эффективную асинхронную обработку запросов и высокую производительность приложения.

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

Простота и выразительность кода:
Благодаря использованию DSL (Domain-Specific Language), код становится более читаемым и понятным, упрощая процесс разработки.

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

Интеграция с экосистемой Kotlin:
Полная совместимость с другими библиотеками и инструментами Kotlin, такими как kotlinx.serialization для сериализации данных и kotlinx.coroutines для управления потоками.

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

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

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

Koin:
библиотека для внедрения зависимостей.

Exposed:
ORM для работы с базой данных.

JDBI (Java Database Interface):
библиотека на Java для упрощенной работы с реляционными базами данных.

и многое другое

Язык и интеграция:

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:
Подходит для крупных корпоративных приложений с комплексными требованиями и где ценится богатый функционал фреймворка.

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

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 для взаимодействия с базой данных и выполняет две основные функции: добавление пользователя и получение списка всех пользователей.

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. В каждом map создается карта, где ключи — это названия полей (id, name, email), а значения берутся из соответствующих столбцов в таблице Users:
«id» to it[Users.id]: добавляет в карту id пользователя.
«name» to it[Users.name]: добавляет имя пользователя.
«email» to it[Users.email]: добавляет email пользователя.

Теперь определим основной модуль приложения Ktor с маршрутом для работы с пользователями.
Создаем src/main/kotlin/com/example/Application.kt:

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())
            }
        }
    }
    
}
fun main() {
    embeddedServer(Netty, port = 8080) {
        initDatabase()
        installModules()
    }.start(wait = true)
}

embeddedServer:
это функция для создания и запуска сервера. Она использует Netty в качестве движка и запускает сервер на порту 8080.

initDatabase():
инициализация базы данных. Вызывает функцию, которая настраивает подключение к базе и создает таблицы.

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

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

Эта функция настраивает Ktor и Koin и определяет маршруты для обработки запросов.

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

    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 и сообщение об ошибке.

    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:

import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.request.*

  
class ExternalApiService {
  
    private val client = HttpClient(CIO)

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

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

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»):

client.get(…):
это метод, который делает HTTP GET-запрос по указанному URL.

URL https://jsonplaceholder.typicode.com/todos/1:
это тестовый API-эндпоинт, предоставляющий данные о задаче с ID 1.

Метод get:
это обобщённая функция, поэтому Kotlin автоматически определяет тип возвращаемого значения на основе ожидаемого типа результата, в данном случае String.
Когда функция getExternalData() вызывается, клиент делает запрос и возвращает результат в виде строки, которая содержит JSON-ответ от сервера.

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

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

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

Использование API:
POST /users с телом запроса { «name»: «John», «email»: «[email protected]» } для добавления пользователя.
GET /users для получения списка пользователей.

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

import org.jetbrains.exposed.dao.IntIdTable
import org.jetbrains.exposed.sql.Database
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, создавая отношение «пользователь — посты» (один пользователь может иметь много постов).

object Posts : IntIdTable() {
    val title = varchar("title", 255)
    val content = text("content")
    // внешний ключ, ссылающийся на пользователя
    val user = reference("user_id", Users) 
}

Теперь функция инициализации базы данных будет создавать обе таблицы:

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

Вставка данных в связанные таблицы:

transaction {
    // Вставка пользователя
    val userId = Users.insertAndGetId {
        it[name] = "Alice"
        it[email] = "[email protected]"
    }

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

Запрос с использованием связи:
Чтобы получить все посты конкретного пользователя, можно выполнить запрос:

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.

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)
}

Теперь функция инициализации базы данных должна создать три таблицы:

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

Вставка данных в связанные таблицы:
Создадим теги и свяжем их с постом

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
    }
}

Запрос для получения всех тегов поста:
Для получения тегов, связанных с постом

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

transaction {
    Users.update({ Users.id eq userId }) {
        it[email] = "[email protected]"
    }
}

Удаление данных:
Для удаления пользователя (и, возможно, связанных записей) используется delete

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

Пример сложного запроса с сортировкой и лимитом:
Запросим последние 5 постов пользователя, отсортированные по дате создания (предполагаем, что поле created в таблице Posts существует и автоматически заполняется)

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 постов пользователя:

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 (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:

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")
}

Создаем класс модели:

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

Создаем интерфейс для SQL-запросов, используя аннотации JDBI:

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 для выполнения запросов:

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", "[email protected]")
        userDao.insertUser("Bob", "[email protected]")

        
        // Получаем пользователя по 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 — это библиотека для внедрения зависимостей (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

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

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

// 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:

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):

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

import org.koin.ktor.ext.inject


class UserController {
  
    private val userService by inject<UserService>()

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

Пример использования Koin для тестов:
Koin позволяет легко заменить зависимости в тестах. Например

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())
    }
    
}

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

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

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:
Классы, которые мы будем тестировать, выглядят так

// 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.

import io.insert-koin.core.context.startKoin
import io.insert-koin.core.context.stopKoin
import io.insert-koin.dsl.module
import io.insert-koin.test.KoinTest
import io.insert-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)
        
    }
    
}

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

Создание мок-объекта:

private val mockRepository = mockk<UserRepository>()

Мы создаем мок-объект для UserRepository с помощью MockK, чтобы не использовать реальную базу данных или другие внешние зависимости.

Определение тестового модуля:

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

В тестовом модуле UserRepository заменяется на mockRepository, а UserService инстанцируется с использованием этой подмененной зависимости.

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

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

Здесь мы указываем, что при вызове getUser() у mockRepository метод должен возвращать «Mocked User Data». Это позволяет протестировать UserService изолированно от реального поведения UserRepository.

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

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

Вызвав getUserData у UserService, мы ожидаем, что он вернет «Mocked User Data», так как это поведение было задано для мока UserRepository.

Остановка Koin:

stopKoin()

После завершения теста Koin останавливается, чтобы тестовый контекст не повлиял на другие тесты.

Этот пример демонстрирует, как использовать Koin для управления зависимостями и MockK для подмены поведения зависимостей в тестах. Благодаря этому подходу мы можем изолировать UserService и протестировать его логику, не полагаясь на реализацию UserRepository.

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:

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 для теста с базой данных:

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', '[email protected]');"
        )

        
        // Выполнение запроса
        val resultSet = connection.createStatement().executeQuery("SELECT * FROM users")
        resultSet.next()
        val name = resultSet.getString("name")
        val email = resultSet.getString("email")

        assertEquals("Alice", name)
        assertEquals("[email protected]", 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 в build.gradle.kts:

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

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

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_email_to_users.sql
Каждый файл миграции содержит SQL-команды для выполнения изменения в базе данных.

Пример файла миграции V1__create_users_table.sql:
Создадим файл миграции для создания таблицы users

-- 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_email_to_users.sql:
Добавим новый столбец created_at в таблицу users

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

Запуск миграций Flyway:
Теперь мы можем запустить миграции с помощью команды Gradle

./gradlew flywayMigrate

Эта команда выполнит все миграции, которые ещё не применены, и обновит структуру базы данных. Flyway сохраняет информацию о выполненных миграциях в специальной таблице (flyway_schema_history), чтобы гарантировать, что каждая миграция выполняется только один раз.

Откат миграции (если требуется):
Если нам нужно откатить миграцию, можно использовать команду:

./gradlew flywayUndo

Однако следует отметить, что Flyway поддерживает откат миграций (undo) только в коммерческой версии. В бесплатной версии миграции можно отменить вручную, добавив отдельные SQL-файлы для изменения или удаления созданных данных.

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

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.

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 на локальной машине).

Для более гибкого управления переменными окружения, особенно в разных средах (разработка, тестирование, продакшен), можно использовать библиотеку dotenv. Эта библиотека позволяет хранить переменные окружения в файле .env и загружать их при старте приложения.

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

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

Создание .env файла:
Создаем .env файл в корне проекта и добавляем в него переменные окружения:

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

Использование переменных окружения из .env файла в коде:
Затем можно использовать библиотеку dotenv для загрузки переменных из .env файла:

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 также поддерживает использование переменных окружения для конфигурации приложения. Переменные окружения можно загружать через application.conf или получать их с помощью System.getenv().

Пример конфигурации базы данных в application.conf:
В application.conf можно указать путь к переменным окружения через ${}:

ktor {
    deployment {
        port = 8080
    }
    database {
        url = ${DB_URL}
        user = ${DB_USER}
        password = ${DB_PASSWORD}
    }
}

Пример использования в коде Ktor:
В коде Ktor можно получить значения из конфигурации и использовать их для подключения к базе данных:

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-системах) можно указать переменные окружения при запуске приложения.

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 файла, в формате КЛЮЧ=ЗНАЧЕНИЕ. Например:

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