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 файл.
Сохраните настройки и запустите проект.