1. Чому «мережа в CLI» перетворюється на проблему
Коли ви пишете першу мережеву програму, дуже хочеться зробити все прямо в main: прочитали команду, тут же зробили client.get(...), одразу розібрали JSON, одразу println. На маленькому прикладі це навіть виглядає «нормально» — як локшина швидкого приготування: залили окропом і готово. Проблема в тому, що після кількох нових команд така «локшина» починає жити своїм життям і боляче мститися за спрощення.
Уявімо, що в нас уже є консольний застосунок із минулих днів — умовний трекер витрат. Нехай він уміє зберігати витрати локально, а сьогодні ми хочемо додати команду tip, яка отримує «фінансову пораду дня» з віддаленого сервера. Звучить нешкідливо — рівно до моменту, коли ви спробуєте зробити це «просто в CLI».
Мережа в CLI зазвичай одразу тягне за собою три проблеми.
По-перше, змішуються відповідальності: в одному місці у вас і розбір команд, і HTTP‑заголовки, і JSON‑моделі, і обробка помилок. По-друге, незрозуміло, що саме тестувати: «команду» чи «мережу»? По-третє, будь-яка зміна (наприклад, інший URL або новий формат відповіді) змушує втручатися в «священний main», який уже страшно чіпати, бо він, як давній артефакт, тримається на чесному слові.
Щоб із цього вийти, ми розділимо програму на два нові шари:
- Шар клієнта — «як ходимо в мережу».
- Шар сценаріїв — «навіщо ходимо в мережу й що робимо з результатом».
А CLI залишиться тим, чим має бути: «прочитали команду → викликали сценарій → надрукували результат».
2. Схема шарів: хто за що відповідає
Коли кажуть «архітектура», новачкам часто чується: «зараз буде 43 папки, 12 абревіатур і один шаманський бубон». Ми так робити не будемо. Нам потрібна найпростіша навчальна схема, яка справді допомагає: CLI не знає про Ktor і HTTP‑деталі, сценарій не друкує в консоль, а клієнт не вирішує, що саме казати користувачеві.
Ось візуальна модель (у вигляді блок‑схеми), до якої ми будемо повертатися:
flowchart TD
CLI["CLI (main): читає команди, друкує текст"]
SC["Scenario: вирішує, що робити з результатом"]
API["Client API: інтерфейс віддаленого сервісу"]
KTOR["Ktor implementation: HttpClient + запити"]
NET["Internet/Server"]
CLI --> SC --> API --> KTOR --> NET
KTOR --> API --> SC --> CLI
Ідея проста: що нижчий шар, то менше він «знає» про вашу програму, і тим більше він «знає» про техніку. Тому клієнт знає про HTTP і JSON, але не знає, що у вас є команда tip. Сценарій знає, що таке «порада дня», але не знає, які заголовки має запит. CLI знає, що користувач увів tip, але не знає, що таке HttpResponse.
Корисно тримати в голові невелику таблицю:
| Шар | Що робить | Чого НЕ робить |
|---|---|---|
| CLI | читає команди, друкує повідомлення | не будує HTTP‑запити, не розбирає JSON |
| Scenario | вирішує «що повернути користувачеві» | не друкує, не керує HttpClient |
| Network Client | ходить у мережу, повертає дані/помилки | не знає про команди й UI‑тексти |
Такий розклад добре поєднується з Kotlin‑кодом і звичними правилами розміщення: тримати повʼязані речі поруч, не розкидати методи за абеткою, а йти «згори вниз за змістом» — це відповідає загальним рекомендаціям щодо конвенцій оформлення.
3. Контракт мережевого шару: інтерфейс і явний результат
Перший практичний крок — домовитися, що саме повертає мережевий шар. Це ключовий момент: якщо клієнт повертає «голий HttpResponse», ви тягнете HTTP‑деталі вгору. Якщо клієнт робить println, ви втрачаєте контроль над виведенням. Тому найзручніший варіант для початківців — повертати явний результат, де успіх і помилки розділено типами.
Явний результат запиту: ApiResult
Почнімо з простого sealed class, який уміє розрізняти три випадки:
- успіх (дані отримано),
- HTTP‑помилка (відповідь є, але non‑2xx),
- мережева помилка (виняток, таймаут, недоступність тощо).
sealed class ApiResult<out T> {
data class Ok<T>(val value: T) : ApiResult<T>()
data class HttpError(val status: Int, val body: String) : ApiResult<Nothing>()
data class NetworkError(val message: String) : ApiResult<Nothing>()
}
Модель відповіді та інтерфейс віддаленого сервісу
Тепер визначимо модель відповіді для нашої команди tip. Нехай сервер повертає JSON такого вигляду:
{ "text": "Відкладайте 10 % доходу одразу після зарплати" }
Тоді Kotlin‑модель:
import kotlinx.serialization.Serializable
@Serializable
data class TipResponse(val text: String)
І ось головний елемент архітектури — інтерфейс, що описує можливості віддаленого сервісу:
interface TipsApi {
suspend fun fetchDailyTip(): ApiResult<TipResponse>
}
Зверніть увагу на дві речі.
По-перше, це suspend — мережа в нас асинхронна, і ми вже звикли до цього завдяки корутинам.
По-друге, CLI тепер не зобовʼязаний знати, що таке Ktor, HttpResponse і як улаштовані заголовки. CLI знає лише: «є TipsApi, у нього можна попросити пораду дня».
Такий підхід відповідає ідеї «вузького контракту»: ви описуєте те, що потрібно програмі, а не те, яким саме способом ви це дістаєте (Ktor сьогодні, інший клієнт завтра). Ktor загалом чудово лягає в таку модель: він може бути реалізацією, а не змістом програми.
4. Реалізація клієнта на Ktor: технічні деталі живуть тут
Тепер зробімо реалізацію TipsApi, яка використовує Ktor. Тут якраз доречно все «технічне»: HttpClient, URL, перевірка статусу, читання тіла, JSON‑десеріалізація, обробка винятків.
Фабрика HttpClient
Спочатку — фабрика клієнта (щоб створювати його в одному місці й щоразу однаково налаштовувати):
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
fun buildHttpClient(): HttpClient =
HttpClient(CIO) {
install(HttpTimeout) { requestTimeoutMillis = 3_000 }
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
}
Реалізація TipsApi через Ktor
Тепер реалізація API. Ми спеціально дотримуватимемося стилю «явно отримали HttpResponse → явно прочитали тіло», щоб код лишався читабельним:
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
class KtorTipsApi(
private val client: HttpClient,
private val baseUrl: String
) : TipsApi {
override suspend fun fetchDailyTip(): ApiResult<TipResponse> {
return try {
val response: HttpResponse = client.get("$baseUrl/tip")
if (response.status.value in 200..299) ApiResult.Ok(response.body())
else ApiResult.HttpError(response.status.value, response.bodyAsText())
} catch (e: Exception) {
ApiResult.NetworkError(e.message ?: "Мережева помилка")
}
}
}
Тут важливо відчути межу відповідальності. Цей клас:
- будує URL,
- робить запит,
- перевіряє статус,
- повертає зрозумілий ApiResult.
Але він не друкує і не вирішує, що сказати користувачеві. Він просто чесно повертає результат.
5. Сценарії та CLI: кожен робить своє
Якщо клієнт повертає ApiResult, нам потрібне місце, де цей результат перетворюється на «зрозумілу людську відповідь». Це і є сценарій. Він живе «вище» мережі, але «нижче» CLI.
Сценарій: перекладаємо ApiResult у сенс
Сценарій добрий тим, що в ньому зручно тримати правила на кшталт: «якщо HTTP 404 — скажи одне, якщо таймаут — інше, якщо успіх — покажи текст поради».
Зробімо сценарій, який повертає готовий рядок для CLI:
class GetDailyTipScenario(private val api: TipsApi) {
suspend fun execute(): String {
return when (val r = api.fetchDailyTip()) {
is ApiResult.Ok -> "Порада дня: ${r.value.text}"
is ApiResult.HttpError -> "Сервіс порад повернув HTTP ${r.status}"
is ApiResult.NetworkError -> "Не вдалося отримати пораду: ${r.message}"
}
}
}
Цей код виглядає «надто простим», але в цьому й суть. Сценарій — це місце, де програма стає програмою, а не набором протоколів.
Зауважте ще одну архітектурну дрібницю: сценарій залежить від інтерфейсу TipsApi, а не від KtorTipsApi. Це дає нам змогу підміняти реалізацію (наприклад, на «заглушку» для розробки), не змінюючи сценарій і CLI.
CLI: команда → сценарій → виведення
Тепер найприємніше: main() не зобовʼязаний розбиратися в HTTP, JSON і статус‑кодах. Він просто керує «введенням/виведенням» і життєвим циклом ресурсів.
Зробімо міні‑CLI, де в нас є команда tip і exit. Для простоти залишимо цикл читання команд, як ви робили багато разів раніше:
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val client = buildHttpClient()
try {
val api: TipsApi = KtorTipsApi(client, baseUrl = "https://example.com")
val scenario = GetDailyTipScenario(api)
while (true) {
print("> ")
when (readln().trim()) {
"tip" -> println(scenario.execute())
"exit" -> return@runBlocking
else -> println("Команди: tip, exit") // Команди: tip, exit
}
}
} finally {
client.close()
}
}
Зверніть увагу: main() тепер читається як інструкція, а не як стаття «як працює інтернет».
І так, try/finally навколо client.close() — це не параноя, а доросла звичка. Мережа любить ресурси, а ресурси люблять бути закритими.
6. Композиція залежностей: збираємо застосунок в одному місці
Коли застосунок зростає, зʼявляється спокуса «все створювати де завгодно»: HttpClient усередині API, API усередині сценарію, сценарій усередині обробника команди… і раптом у вас 17 клієнтів і 17 таймаутів, бо «воно ж саме створюється».
Щоб цього уникнути, застосуємо простий прийом: композиція залежностей в одному місці (інколи це називають «composition root»). У навчальному проєкті це може бути окрема функція або невеликий клас.
Наприклад, зробімо AppContext, який створює й зберігає все потрібне:
import io.ktor.client.HttpClient
class AppContext(baseUrl: String) : AutoCloseable {
private val client: HttpClient = buildHttpClient()
val tipsApi: TipsApi = KtorTipsApi(client, baseUrl)
val getDailyTipScenario = GetDailyTipScenario(tipsApi)
override fun close() = client.close()
}
І тоді main() стає ще чистішим:
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
AppContext(baseUrl = "https://example.com").use { ctx ->
println(ctx.getDailyTipScenario.execute())
// Порада дня: ...
}
}
Тут ми використовуємо той самий принцип, який ви вже бачили в темах про I/O: ресурс створюється в одному місці й там само закривається. У HttpClient є close(), а use зручно обгортає життєвий цикл.
7. Розширення без болю: новий endpoint і мінімум правок
Архітектура вважається вдалою не тоді, коли «все красиво по папках», а тоді, коли нову можливість можна додати локально. Перевірмо.
Уявімо, що завтра ви захочете команду ping, щоб швидко перевіряти, чи живий сервіс (або просто потренуватися). За невдалої архітектури ви залазите в main() і починаєте додавати туди запити. У нашій — ви додаєте метод в API, реалізацію в Ktor‑клієнт і сценарій. А CLI чіпаєте мінімально.
Оновлюємо контракт
Додаймо в контракт:
interface TipsApi {
suspend fun fetchDailyTip(): ApiResult<TipResponse>
suspend fun ping(): ApiResult<String>
}
Додаємо реалізацію в Ktor-клієнт
Реалізація (коротко, як текст):
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
override suspend fun ping(): ApiResult<String> {
return try {
val response = client.get("$baseUrl/ping")
if (response.status.value in 200..299) ApiResult.Ok(response.bodyAsText())
else ApiResult.HttpError(response.status.value, response.bodyAsText())
} catch (e: Exception) {
ApiResult.NetworkError(e.message ?: "Мережева помилка")
}
}
Додаємо сценарій
Сценарій:
class PingScenario(private val api: TipsApi) {
suspend fun execute(): String =
when (val r = api.ping()) {
is ApiResult.Ok -> "ping: ${r.value}"
is ApiResult.HttpError -> "ping: HTTP ${r.status}"
is ApiResult.NetworkError -> "ping: ${r.message}"
}
}
І лише в самому кінці — додаєте одну гілку в CLI. Це і є головна ознака того, що шари працюють: зміни «не розтікаються» по всьому коду.
8. Типові помилки
Помилка №1: створювати HttpClient усередині кожної функції запиту.
Таке часто роблять «щоб було простіше»: усередині fetchDailyTip() створюють HttpClient, роблять get, закривають. На практиці ви отримуєте зайві накладні витрати, вам складніше контролювати таймаути/плагіни, і в якийсь момент ви починаєте забувати закривати клієнт в одній із гілок. Правильніше створити клієнт один раз (у main або AppContext) і передавати його в реалізації API.
Помилка №2: повертати назовні HttpResponse — «нехай там розбираються».
Це виглядає як економія, але на ділі ви протягуєте HTTP‑деталі нагору, а потім status/bodyAsText() спливають у сценаріях і CLI. Саме цього ми й намагалися уникнути. Краще повертати ApiResult<T> або інший явний результат, щоб верхні шари працювали зі зрозумілими типами, а не з транспортом.
Помилка №3: робити println усередині мережевого клієнта.
Дуже хочеться «допомогти собі в налагодженні»: надрукувати URL, статус, тіло. Але потім ці повідомлення потрапляють у користувацьке виведення й заважають. Якщо вам потрібне логування — це окрема тема й окрема відповідальність. У межах поточного рівня тримаємо правило: клієнт повертає дані/помилки, а друкує лише CLI.
Помилка №4: намагатися розбирати body<T>() для будь-якого статус‑коду.
Якщо сервер повернув 400/500, тіло може бути взагалі не тим JSON, який ви очікуєте (або взагалі не JSON). Тоді ви отримаєте виняток під час десеріалізації й утратите сенс помилки. Тримайте дисципліну: спочатку перевіряємо статус, і лише в гілці успіху читаємо body<T>(), а в гілці помилки читаємо bodyAsText().
Помилка №5: «перехоплювати все» і зводити до одного тексту: «щось пішло не так».
Коли ви робите catch (Exception) і повертаєте одну й ту саму фразу, ви позбавляєте себе (і користувача) розуміння: зник інтернет, стався таймаут, неправильний URL чи сервер чесно сказав «у вас неправильні дані». Навіть якщо ви поки не робите тонкої класифікації, відокремлюйте хоча б HTTP‑помилки від мережевих винятків — це вже різко підвищує передбачуваність програми.
Помилка №6: змішувати сценарій і CLI: сценарій починає читати readln() або друкувати.
Іноді здається зручним: «ну сценарій же знає, про що спитати в користувача». Але тоді сценарій перестає бути шаром сценаріїв і перетворюється на другий main(). Тримайте межу: введення/виведення живе в CLI, сценарій живе в логіці, клієнт живе в мережі. Це нудно, але «нудно» часто означає «надійно» (у хорошому сенсі).
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ