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 ?: "Network error")
        }
    }
}

Здесь важно почувствовать границу ответственности. Этот класс:

  • строит 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 ?: "Network error")
    }
}

Добавляем сценарий

Сценарий:

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
Задача
Kotlin SELF, 58 уровень, 4 лекция
Недоступна
Результат вызова
Результат вызова
1
Задача
Kotlin SELF, 58 уровень, 4 лекция
Недоступна
Фейковый сервис
Фейковый сервис
1
Задача
Kotlin SELF, 58 уровень, 4 лекция
Недоступна
Сценарий подсказки
Сценарий подсказки
1
Задача
Kotlin SELF, 58 уровень, 4 лекция
Недоступна
Ktor клиент
Ktor клиент
1
Опрос
Сеть, 58 уровень, 4 лекция
Недоступен
Сеть
Сеть
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ