1. Введение
Когда вы впервые начинаете писать сетевой код, рука тянется к мечте: «хочу одну строчку, чтобы сразу пришёл ответ, сразу распарсился, сразу всё получилось, а если не получилось — ну… как-нибудь». Проблема в том, что сеть — чемпион по неожиданностям: сервер может ответить не тем, интернет может пропасть, API может вернуть HTML вместо JSON, а вы будете сидеть и думать, что это Kotlin «сломался».
Идея «без магии» означает: мы делаем все шаги явно. Мы явно получаем HttpResponse, явно смотрим статус, явно читаем тело (как текст), и только потом решаем, что делать дальше. Это делает код чуть длиннее, зато значительно проще отлаживать. И да: Ktor — это как раз один из популярных Kotlin‑инструментов для HTTP (и на клиенте, и на сервере). Он часто упоминается как Kotlin‑фреймворк для backend‑задач.
HttpClient: один объект, много запросов, одна ответственность
HttpClient в Ktor — это «главный пульт управления» сетевыми запросами. Его важнейшая идея: клиент создают один раз и переиспользуют. Если создавать новый клиент на каждый запрос, вы получите лишние подключения, лишние ресурсы и однажды — странные тормоза (или «почему оно зависает только на третьем запросе?»).
Ещё один ключевой момент: HttpClient надо закрывать. Он держит ресурсы (соединения, потоки), и если вы их не отпустили, программа в лучшем случае будет вести себя неаккуратно, а в худшем — вы увидите загадочные утечки и зависания. Поэтому мы с самого начала привыкаем к дисциплине: try/finally и client.close().
Для ориентира полезно держать в голове простую таблицу различий:
| Сущность | Что это | Живёт сколько | Зачем нужна |
|---|---|---|---|
|
«машина», которая умеет делать HTTP | обычно весь main/весь сценарий | настраиваем «как ходим в сеть» |
|
конкретный ответ на конкретный запрос | один запрос | читаем статус, заголовки, тело |
Движок и почему в примерах часто встречается CIO
На JVM Ktor Client может работать через разные «движки» (engine). Если говорить по‑человечески, engine — это «внутренняя реализация транспорта»: чем именно клиент будет реально устанавливать соединение и гонять байты. Для учебных примеров часто берут CIO, потому что он хорошо подходит как понятный дефолт для JVM.
Сейчас наша цель очень простая: научиться явно создать клиент, чтобы студент (то есть вы) не чувствовал, что половина работы сделана «по секрету».
Небольшая ремарка про зависимости: вам понадобятся модули ktor-client-core и engine для 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 created") // Client created
client.close()
println("Client closed") // Client closed
}
Этот пример кажется бесполезным, но он закрепляет два ключевых движения: «создал» и «закрыл». Если вы приучитесь к этому сейчас, дальше будет меньше боли.
Первый запрос «без магии»: получаем 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 Server (URL)
participant R as HttpResponse
M->>C: get(url)
C->>S: HTTP request (GET)
S-->>C: HTTP response (status + headers + body)
C-->>R: HttpResponse
M->>R: status
M->>R: bodyAsText()
R-->>M: String (body text)
Ключевая мысль: 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‑функции.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ