Вступ до 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 файл.
Збережіть налаштування і запустіть проєкт.