Ktor это набирающий в последнее время популярность асинхронный фреймворк для разработки приложений на языке Kotlin. Он позволяет создавать как серверные, так и клиентские приложения, предоставляя инструментарий для работы с HTTP, WebSockets и другими сетевыми протоколами.
В этой статье:
➤ Преимущества Ktor
➤ Основные отличия между Ktor и Spring
➤ Пример приложения Ktor
➤ Сервис для работы с пользователями
➤ Настройка модуля Ktor
➤ Настройка базы данных с помощью Exposed
➤ Настройка базы данных с помощью JDBI
➤ Настройка Koin для внедрения зависимостей
➤ Примеры тестов с использованием Mockk
➤ Примеры тестов с использованием Testcontainers
➤ Пример миграций с использованием Flyway
➤ Использование переменных окружения
Преимущества 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:
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
Теперь определим основной модуль приложения 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()) } } } }
Функция main:
fun main() { embeddedServer(Netty, port = 8080) { initDatabase() installModules() }.start(wait = true) }
embeddedServer:
это функция для создания и запуска сервера. Она использует Netty в качестве движка и запускает сервер на порту 8080.
initDatabase():
инициализация базы данных. Вызывает функцию, которая настраивает подключение к базе и создает таблицы.
installModules():
основная функция конфигурации приложения, устанавливающая Koin и функции Ktor.
Функция installModules:
fun Application.installModules() { // Koin install(Koin) { modules(appModule) }
Эта функция настраивает Ktor и Koin и определяет маршруты для обработки запросов.
install(Koin) { modules(appModule) }:
подключение Koin для внедрения зависимостей, инициализирует модуль appModule, содержащий зависимости, включая UserService.
Установка Ktor-функций:
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:
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 для получения списка пользователей.
Настройка базы данных с помощью Exposed
Создадим таблицу и 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
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 для внедрения зависимостей
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()) } }
Примеры тестов с использованием Mockk
Вот пример, как можно протестировать 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
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 в проект:
Добавим зависимости 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 на локальной машине).
Использование .env файла с библиотекой dotenv:
Для более гибкого управления переменными окружения, особенно в разных средах (разработка, тестирование, продакшен), можно использовать библиотеку 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:
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 файл.
Сохраните настройки и запустите проект.