JavaRush /Курси /Kotlin SELF /Архітектура клієнта та сценаріїв: без «мережі в CLI»

Архітектура клієнта та сценаріїв: без «мережі в CLI»

Kotlin SELF
Рівень 58 , Лекція 4
Відкрита

1. Чому «мережа в CLI» перетворюється на проблему

Коли ви пишете першу мережеву програму, дуже хочеться зробити все прямо в main: прочитали команду, тут же зробили client.get(...), одразу розібрали JSON, одразу println. На маленькому прикладі це навіть виглядає «нормально» — як локшина швидкого приготування: залили окропом і готово. Проблема в тому, що після кількох нових команд така «локшина» починає жити своїм життям і боляче мститися за спрощення.

Уявімо, що в нас уже є консольний застосунок із минулих днів — умовний трекер витрат. Нехай він уміє зберігати витрати локально, а сьогодні ми хочемо додати команду tip, яка отримує «фінансову пораду дня» з віддаленого сервера. Звучить нешкідливо — рівно до моменту, коли ви спробуєте зробити це «просто в CLI».

Мережа в CLI зазвичай одразу тягне за собою три проблеми.

По-перше, змішуються відповідальності: в одному місці у вас і розбір команд, і HTTP‑заголовки, і JSON‑моделі, і обробка помилок. По-друге, незрозуміло, що саме тестувати: «команду» чи «мережу»? По-третє, будь-яка зміна (наприклад, інший URL або новий формат відповіді) змушує втручатися в «священний main», який уже страшно чіпати, бо він, як давній артефакт, тримається на чесному слові.

Щоб із цього вийти, ми розділимо програму на два нові шари:

  1. Шар клієнта — «як ходимо в мережу».
  2. Шар сценаріїв — «навіщо ходимо в мережу й що робимо з результатом».

А 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, який уміє розрізняти три випадки:

  1. успіх (дані отримано),
  2. HTTP‑помилка (відповідь є, але non‑2xx),
  3. мережева помилка (виняток, таймаут, недоступність тощо).
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, сценарій живе в логіці, клієнт живе в мережі. Це нудно, але «нудно» часто означає «надійно» (у хорошому сенсі).

1
Опитування
Мережа, рівень 58, лекція 4
Недоступний
Мережа
Мережа
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ