Введение в Ktor и Exposed
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:
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.Usersimport org.jetbrains.exposed.sql.insertimport org.jetbrains.exposed.sql.selectAllimport 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.appModuleimport com.example.models.initDatabaseimport com.example.services.UserServiceimport io.ktor.application.*import io.ktor.features.ContentNegotiationimport io.ktor.features.DefaultHeadersimport io.ktor.features.StatusPagesimport io.ktor.gson.gsonimport io.ktor.http.HttpStatusCodeimport io.ktor.response.*import io.ktor.request.*import io.ktor.routing.*import io.ktor.server.engine.embeddedServerimport io.ktor.server.netty.Nettyimport org.koin.ktor.ext.injectimport 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": "john@example.com" } для добавления пользователя.
GET /users для получения списка пользователей.
Настройка базы данных с помощью Exposed
Создадим таблицу и DAO-класс для работы с базой данных. Создадим директорию src/main/kotlin/com/example/models и добавим туда файл User.kt для модели пользователя.
import org.jetbrains.exposed.dao.IntIdTableimport org.jetbrains.exposed.sql.Databaseimport 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] = "alice@example.com" }
// Вставка поста для пользователя 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] = "newalice@example.com" }}Удаление данных:
Для удаления пользователя (и, возможно, связанных записей) используется 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.RegisterBeanMapperimport org.jdbi.v3.sqlobject.statement.SqlQueryimport 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", "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
dependencies { implementation("io.insert-koin:koin-core:3.4.0") implementation("io.insert-koin:koin-ktor:3.4.0") // для интеграции с Ktor}Создание классов и зависимостей:
class UserRepository { fun getUser() = "User data from repository"}
// UserService.ktclass 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.startKoinimport 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.stopKoinimport org.koin.dsl.moduleimport org.koin.test.KoinTestimport org.koin.test.injectimport kotlin.test.BeforeTestimport kotlin.test.AfterTestimport 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:
Классы, которые мы будем тестировать, выглядят так
class UserRepository {
fun getUser(): String { // В реальной ситуации этот метод мог бы обращаться к базе данных return "Real User Data" }}
// UserService.ktclass UserService(private val userRepository: UserRepository) {
fun getUserData(): String { return userRepository.getUser() }}Настройка модуля Koin для тестирования:
Создадим тест, где будем подменять UserRepository на мок-объект с помощью MockK.
import io.insert-koin.core.context.startKoinimport io.insert-koin.core.context.stopKoinimport io.insert-koin.dsl.moduleimport io.insert-koin.test.KoinTestimport io.insert-koin.test.injectimport io.mockk.everyimport io.mockk.mockkimport kotlin.test.AfterTestimport kotlin.test.BeforeTestimport kotlin.test.Testimport 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.assertEqualsimport org.junit.jupiter.api.Testimport org.junit.jupiter.api.TestInstanceimport org.junit.jupiter.api.BeforeAllimport org.junit.jupiter.api.AfterAllimport org.testcontainers.containers.PostgreSQLContainerimport org.testcontainers.junit.jupiter.Containerimport org.testcontainers.junit.jupiter.Testcontainersimport java.sql.Connectionimport 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:
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.sqlCREATE 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.sqlALTER TABLE usersADD 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/mydatabaseDB_USER=myuserDB_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/mydatabaseDB_USER=myuserDB_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 файл.
Сохраните настройки и запустите проект.