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 ?: "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, сценарий живёт в логике, клиент живёт в сети. Это скучно, но скучно — значит надёжно (в хорошем смысле).
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ