1. Вступ
Коли ви вперше беретеся за мережевий код, рука сама тягнеться до мрії: «хочу один рядок — і щоб одразу прийшла відповідь, усе розпарсилося й запрацювало; а якщо не запрацювало — ну… якось». Проблема в тому, що мережа — чемпіонка з несподіванок: сервер може відповісти не тим, інтернет може зникнути, API може повернути HTML замість JSON, а ви сидітимете й думатимете, що це Kotlin «зламався».
Ідея «без магії» проста: ми робимо всі кроки явно. Ми явно отримуємо HttpResponse, явно перевіряємо статус, явно читаємо тіло (як текст) — і лише потім вирішуємо, що робити далі. Так код стає трохи довшим, зате значно простішим для налагодження. І так: Ktor — один із популярних Kotlin‑інструментів для HTTP (і на клієнті, і на сервері). Його часто згадують як Kotlin‑фреймворк для бекенд‑завдань.
HttpClient: один об’єкт, багато запитів, одна відповідальність
HttpClient у Ktor — це «головний пульт керування» мережевими запитами. Його найважливіша ідея така: клієнт створюють один раз і перевикористовують. Якщо створювати нового клієнта на кожен запит, ви отримаєте зайві під’єднання й зайві витрати ресурсів, а одного дня — дивні гальмування (або «чому воно зависає лише на третьому запиті?»).
Є й інший ключовий момент: HttpClient треба закривати. Він утримує ресурси (з’єднання, потоки), і якщо ви їх не відпустите, програма в найкращому разі поводитиметься неохайно, а в найгіршому — ви побачите загадкові витоки та зависання. Тож від самого початку звикаємо до дисципліни: try/finally і client.close().
Для орієнтира корисно тримати в голові просту таблицю відмінностей:
| Сутність | Що це | Живе скільки | Навіщо потрібна |
|---|---|---|---|
|
«машина», яка вміє робити HTTP | зазвичай увесь main/увесь сценарій | налаштовуємо «як ходимо в мережу» |
|
конкретна відповідь на конкретний запит | один запит | читаємо статус, заголовки, тіло |
Рушій і чому в прикладах часто трапляється CIO
На JVM Ktor Client може працювати через різні «рушії» (engine). Якщо сказати простіше, engine — це «внутрішня реалізація транспорту»: те, чим саме клієнт встановлюватиме з’єднання й «ганятиме» байти. Для навчальних прикладів часто беруть CIO, бо він добре підходить як зрозумілий варіант за замовчуванням для JVM.
Зараз наша мета дуже проста: навчитися явно створювати клієнт, щоб студент (тобто ви) не відчував, ніби половину роботи зроблено «нишком».
Невелика ремарка про залежності: вам знадобляться модулі ktor-client-core і рушій для JVM (наприклад, ktor-client-cio). Версії в реальному проєкті добирають під ваші Gradle і Kotlin, але як приклад можна зустріти запис виду io.ktor:ktor-client-core:2.3.11.
2. Мінімальний клієнт і перший запит
Мінімальне збирання клієнта: HttpClient(CIO) без зайвих шарів
Наше перше завдання — зробити функцію, яка створює HttpClient. Так, звучить смішно («функція, яка повертає об’єкт»), але це важливий архітектурний рефлекс: коли створення клієнта зібране в одному місці, ви не розмножите його по проєкту, як кроликів, яких забули замкнути в клітці.
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
fun buildClient(): HttpClient {
return HttpClient(CIO)
}
Зверніть увагу: тут ще немає жодних плагінів, таймаутів і «розумних» налаштувань. Ми навмисно тримаємо складність на мінімумі: спершу навчимося ходити, потім — бігати, а потім — робити сальто (але не в проді, будь ласка).
runBlocking у main: як подружити консоль і suspend
Ktor Client працює з корутинами. Це означає, що мережеві операції зазвичай виконують із suspend-коду, адже очікування відповіді не повинно блокувати потік «в лоб». У консольному застосунку в нас є звичайний fun main(), і він не suspend. Тому ми використовуємо «місток» — runBlocking { ... }, який ви вже бачили в темах про корутини.
Сприймайте runBlocking як навчальний/консольний інструмент: він запускає корутину й чекає на її завершення. Усередині цього блоку ми можемо спокійно викликати client.get(...).
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val client = HttpClient(CIO)
println("Клієнт створено") // Клієнт створено
client.close()
println("Клієнт закрито") // Клієнт закрито
}
Цей приклад здається марним, але він закріплює два ключові кроки: «створив» і «закрив». Якщо ви привчитеся до цього зараз, далі буде менше болю.
Перший запит «без магії»: отримуємо HttpResponse і читаємо bodyAsText()
Тепер зробимо найпростіший GET‑запит. Принципово важливо: ми не намагаємося «одразу отримати рядок» або «одразу отримати об’єкт». Ми отримуємо HttpResponse, а вже потім окремим кроком читаємо тіло.
Щоб приклад можна було реально запускати, часто використовують тестові сервіси на кшталт https://httpbin.org/get (він повертає JSON, але ми поки читаємо його як звичайний текст).
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.request.get
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val client = HttpClient(CIO)
try {
val response: HttpResponse = client.get("https://httpbin.org/get")
val text: String = response.bodyAsText()
println("status=${response.status.value}") // status=200 (якщо все добре)
println("body length=${text.length}") // body length=...
} finally {
client.close()
}
}
Зверніть увагу на стиль: ми не відкладаємо close() «на потім». Він стоїть у finally, тому що мережа — це місце, де «потім» може й не настати (виняток, помилка, скасування — що завгодно).
Чому bodyAsText() — окремий крок і чому це зручно
Іноді студент запитує: «А чому не можна зробити так, щоб get() одразу повернув рядок?» Можна… але тоді ви втрачаєте структуру HTTP‑відповіді. А вона важлива. Статус‑код, заголовки й тіло — це три різні частини контракту, і змішувати їх в одну кашу дуже спокусливо, але це погано для налагодження.
Є ще практичніший момент: читання тіла — це операція. Вам може захотітися спершу перевірити статус, а потім вирішити, чи читати тіло як «успіх», чи як «текст помилки». Навіть якщо поки ми робимо лише bodyAsText(), сама звичка «розділяти кроки» — золото.
Спробуємо написати маленьку функцію‑помічник, яка «перетворює відповідь на зрозумілі дані». Ми поки не ліземо в JSON і моделі (це буде окрема тема), але вже можемо оформити результат як data class.
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
data class HttpRawResponse(
val status: Int,
val body: String
)
suspend fun HttpResponse.toRaw(): HttpRawResponse {
val text = bodyAsText()
return HttpRawResponse(status = status.value, body = text)
}
Тут, до речі, захований дуже важливий «дорослий» плюс: тепер у вас є точка розширення. Сьогодні вона повертає статус і текст, завтра ви додасте заголовки, післязавтра — трасування, але зовнішній код залишиться читабельним.
3. Налаштування запиту й невеликий рефакторинг
Request builder: де живуть заголовки та query‑параметри
Майже будь‑який реальний GET‑запит рано чи пізно перетворюється з «просто URL» на «URL + параметри + заголовки». У Ktor це налаштовують через request builder — блок у фігурних дужках після get(...). Важливо не плутати ролі: у цьому блоці ми будуємо запит, але не обробляємо відповідь.
Покажемо приклад, де ми додаємо Accept і query‑параметр q. Ми спеціально робимо це «по‑дорослому», без ручного склеювання рядка "?q=kotlin&limit=10" (бо ручне склеювання — це як писати паролі на стікерах: швидко, але потім соромно).
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.contentType
suspend fun demoSearch(client: HttpClient) {
val response = client.get("https://httpbin.org/get") {
header(HttpHeaders.Accept, ContentType.Application.Json)
url { parameters.append("q", "kotlin") }
}
println("status=${response.status.value}") // status=200
println("body=${response.bodyAsText().take(40)}...") // body={ ... }...
}
Тут уже видно гарну межу: «як будуємо запит» — усередині { ... }, «що робимо з відповіддю» — після отримання response.
Маленький рефакторинг: виносимо мережевий виклик у функцію
Щойно у вас зʼявляється другий запит, main починає роздуватися. Це нормальний етап «дорослішання» програми: спершу все в одному місці, потім ви починаєте виділяти функції. Зараз зробимо невеликий, але корисний крок: створимо функцію, яка робить GET і повертає HttpRawResponse.
Зверніть увагу: функція suspend, бо всередині — мережа. А main лишається «диригентом»: він створює клієнт, викликає функції й друкує результат.
import io.ktor.client.HttpClient
import io.ktor.client.request.get
suspend fun getText(client: HttpClient, url: String): HttpRawResponse {
val response = client.get(url)
return response.toRaw()
}
І використання:
import io.ktor.client.engine.cio.CIO
import io.ktor.client.HttpClient
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val client = HttpClient(CIO)
try {
val r = getText(client, "https://httpbin.org/get")
println("status=${r.status}") // status=200
println("body length=${r.body.length}") // body length=...
} finally {
client.close()
}
}
Цей рефакторинг здається невеликим, але він дисциплінує архітектуру: мережевий код перестає бути «просто шматочком у main» і стає окремою одиницею, яку простіше читати, тестувати й розвивати.
Невелика схема: що відбувається під час «простого завантаження тексту»
Щоб закріпити модель у голові, корисно уявити виконання запиту як послідовність кроків. Так, це виглядає «очевидно», але саме такі очевидні схеми рятують, коли щось пішло не так, а ви дивитеся в код о другій годині ночі.
sequenceDiagram
participant M as main/runBlocking
participant C as HttpClient
participant S as Сервер (URL)
participant R as HttpResponse
M->>C: get(url)
C->>S: HTTP-запит (GET)
S-->>C: HTTP-відповідь (status + headers + body)
C-->>R: HttpResponse
M->>R: status
M->>R: bodyAsText()
R-->>M: String (текст тіла)
Ключова думка: HttpResponse — це не «текст відповіді», а об’єкт, з якого текст (або інші форми тіла) витягують окремим кроком.
4. Типові помилки під час роботи з HttpClient
Помилка №1: створювати HttpClient усередині кожної функції запиту.
Новачок часто пише fun load() { val client = HttpClient(...); client.get(...); } і повторює це в пʼяти місцях. Спершу здається, що це «нормально», але потім зʼявляються дивні затримки й зайві під’єднання, і стає незрозуміло, де закривати ресурси. Правильніше тримати один клієнт на вищому рівні та передавати його у функції.
Помилка №2: забувати закривати HttpClient.
Якщо клієнт не закривати, ресурси можуть «висіти» до завершення процесу, а інколи програма поводитиметься так, ніби «не завершується» (особливо якщо ви пізніше додасте фонові операції). Звикайте до try/finally { client.close() } як до чищення зубів: нудно, зате корисно.
Помилка №3: намагатися «читати тіло» й «перевіряти статус» в одному нерозбірливому клубку.
Коли код перетворюється на простирадло, ви починаєте ловити помилки логіки: наприклад, парсите тіло так, ніби це успіх, хоча прийшов 404 або 500. Навіть якщо ви поки просто друкуєте текст, тримайте кроки роздільно: спочатку response.status, потім bodyAsText(), а потім — рішення.
Помилка №4: склеювати URL із query‑параметрами вручну.
Рядки на кшталт "https://site/api?q=" + query + "&limit=" + limit дуже швидко призводять до багів із ?/&, пробілами й екрануванням. Ktor дає url { parameters.append(...) } — користуйтеся цим. Це не «краса заради краси», а профілактика ситуації «чому сервер не розуміє мій запит».
Помилка №5: читати тіло кілька разів і дивуватися дивній поведінці.
Тіло відповіді — це потік даних, і в різних реалізаціях повторне читання може бути проблемним або дорогим. Практичний стиль простий: один раз викликали bodyAsText(), зберегли в змінну — і далі працюйте з цим рядком. Так і швидше, і зрозуміліше під час налагодження.
Помилка №6: намагатися зробити мережевий виклик без корутин і дивуватися, що компілятор свариться.
client.get(...) — suspend-операція, і Kotlin чесно не дасть вам викликати її зі звичайної функції. Це не «Kotlin вередує», а захист від блокувань і хаотичного асинхронного коду. Рішення передбачуване: або ви всередині runBlocking (у консолі), або ви в suspend-функції.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ