1. Введение
Если вы только начали программировать, bodyAsText() кажется идеальным: запросили, получили строку, вывели в консоль — красота. Проблема появляется ровно в тот момент, когда вы хотите сделать хоть что-то полезное: взять поле id, показать title, сложить числа, проверить completed. Строка в этот момент превращается в «комок букв», с которым трудно работать без ошибок.
Представьте, что вам прислали посылку (JSON), а вы её не распаковали и пытаетесь угадать содержимое по шуму, когда трясёте коробку. Можно, но очень быстро начинает болеть голова. «Распаковка» — это и есть сериализация/десериализация.
В Kotlin‑мире у нас уже есть знакомый инструмент: kotlinx.serialization, а Ktor умеет подключаться к нему через плагин ContentNegotiation, чтобы автоматизировать «распаковку» и «упаковку».
Небольшая схема, куда мы идём:
flowchart LR
A[HttpResponse] --> B["bodyAsText()"]
B --> C["Json.decodeFromString⟨T⟩() вручную"]
A --> D["response.body⟨T⟩()"]
D --> E[Объект Kotlin]
Левая ветка — «вручную», правая — «по‑взрослому через Ktor».
Два подхода к JSON в Ktor
Сейчас будет важный момент: response.body<T>() не является магией. Это просто удобный слой, который Ktor подключает через плагины. В «голом» клиенте Ktor не обязан понимать JSON сам.
Сравним подходы в одной таблице — иногда мозгу проще, когда всё рядом.
| Подход | Что пишем | Что контролируем | Типичные ощущения |
|---|---|---|---|
| Вручную | val text = response.bodyAsText() + Json.decodeFromString<T>(text) | Всё: когда читаем строку, каким Json парсим | «Я всё понимаю, но кода многовато» |
| Через ContentNegotiation | val obj: T = response.body() | Настройка на уровне клиента, дальше читаем типами | «Ого, стало коротко. Теперь бы не забыть настройки» |
Сегодня мы целимся во второй вариант, но первый тоже держим в голове, потому что иногда он полезен для диагностики (например, посмотреть «сырой» ответ сервера как текст).
Модели данных: data class и @Serializable
Когда сервер отвечает JSON‑ом, он обычно выглядит как объект со свойствами. Чтобы это превратить в Kotlin‑объект, нам нужна модель. И в 99% случаев это будет data class, потому что это «контейнер данных», а не «поведение и бизнес‑логика».
Под капотом kotlinx.serialization работает так: вы помечаете класс аннотацией @Serializable, и компилятор генерирует код, который умеет читать/писать JSON для этого класса.
Давайте возьмём простой JSON‑пример, который многие публичные API используют для демо. Например, JSONPlaceholder отдаёт TODO в таком виде (примерно):
{
"userId": 1,
"id": 1,
"title": "delectus aut autem",
"completed": false
}
Опишем модель (полностью «плоскую», без усложнений):
import kotlinx.serialization.Serializable
@Serializable
data class TodoDto(
val userId: Int,
val id: Int,
val title: String,
val completed: Boolean
)
Обратите внимание: имена полей важны. По умолчанию сериализация сопоставляет имя поля Kotlin ↔ ключ JSON. Если сервер присылает userId, то и поле должно называться userId, иначе придётся включать дополнительные настройки или аннотации (например, @SerialName, но это уже тема из продвинутой сериализации).
2. Настраиваем HttpClient для JSON
Сейчас мы сделаем самый важный шаг лекции: создадим клиента, который умеет работать с JSON «как с типами».
Технически мы делаем две вещи:
- Подключаем плагин ContentNegotiation
- Внутри него подключаем поддержку JSON на базе kotlinx.serialization
Скелет функции‑фабрики клиента выглядит так:
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
fun buildJsonClient(): HttpClient =
HttpClient(CIO) {
install(ContentNegotiation) {
json(
Json {
ignoreUnknownKeys = true
}
)
}
}
Здесь настройка ignoreUnknownKeys = true — практически «обязательная» для жизни. Почему? Потому что серверы любят добавлять новые поля. А ваш учебный код не должен ломаться из-за того, что API стало чуть богаче. Эта настройка говорит: «Если в JSON есть поля, которых нет в нашей модели — просто игнорируем».
Ещё раз — смысл этого шага в том, что Ktor теперь может сделать:
- прочитать тело ответа
- понять, что это JSON (по Content-Type в ответе)
- превратить JSON в объект нужного типа
Полезные нюансы Json { ... }
Когда вы пишете Json { ... }, там довольно много опций. Новичку легко впасть в желание включить всё подряд, как в микроволновке: «пусть будет мощность 1000W, гриль и режим “пицца” одновременно». Но лучше понимать самые практичные.
Мы уже выбрали ignoreUnknownKeys = true как настройку «чтобы не ломалось от лишних полей».
Ещё одна опция, которая иногда полезна в учебных проектах:
val json = Json {
ignoreUnknownKeys = true
prettyPrint = true
}
prettyPrint влияет в первую очередь на сериализацию (когда вы кодируете объект в JSON) — JSON будет более красивым, с переносами строк. Для сервера обычно всё равно, но человеку в логах приятнее.
А вот isLenient, coerceInputValues, explicitNulls и прочие — это уже тонкие «режимы совместимости». Здесь мы их не трогаем: пока не столкнулись с проблемой, не усложняем.
3. Читаем JSON‑ответ как объект: response.body<T>()
Теперь соберём небольшой пример, где мы делаем GET и читаем TodoDto не руками, а через response.body().
Важно: чтобы использовать response.body<T>(), вам обычно нужен импорт io.ktor.client.call.* (или точечный импорт body).
Вот минимальный пример. Мы сознательно остаёмся в «простом» стиле: получили HttpResponse, потом читаем тело. Точка входа — main.
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.statement.HttpResponse
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val client = buildJsonClient()
try {
val response: HttpResponse =
client.get("https://jsonplaceholder.typicode.com/todos/1")
val todo: TodoDto = response.body()
println(todo) // TodoDto(userId=1, id=1, title=..., completed=false)
} finally {
client.close()
}
}
Что здесь происходит по шагам:
- client.get(...) возвращает HttpResponse (как и раньше).
- response.body<TodoDto>() (в нашем случае через val todo) просит Ktor: «Дай мне тело как TodoDto».
- Ktor находит подходящий конвертер (JSON), парсит и возвращает объект.
Почему response.body() выглядит без <TodoDto>
Потому что Kotlin умеет выводить тип из контекста: слева у нас val todo: TodoDto, значит T — это TodoDto. Вообще, body() — это обобщённая функция (generic), которая возвращает значение типа T.
Если вы хотите сделать тип более явным (иногда это полезнее для новичков), можно так:
import io.ktor.client.call.body
import io.ktor.client.statement.HttpResponse
suspend fun readTodo(response: HttpResponse): TodoDto {
return response.body<TodoDto>()
}
Как увидеть «сырой» ответ и не прочитать тело дважды
На практике вы часто захотите: «если что-то пошло не так — покажи мне сырой текст». Это нормальная привычка: когда JSON не парсится, проще всего увидеть исходную строку.
Но есть тонкость: тело ответа обычно можно прочитать один раз. Поэтому в идеале вы выбираете один путь: либо bodyAsText(), либо body<T>().
Как мы сделаем в учебном стиле (без глубокого разбора ошибок, потому что это следующая лекция): заведём отдельную функцию, которая читает как объект, а отладку оставим для отдельных запусков.
Например, «текстовый» вариант для отладки:
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
suspend fun debugPrintBody(response: HttpResponse) {
val text = response.bodyAsText()
println(text) // печатает JSON целиком
}
И «боевой» вариант:
import io.ktor.client.call.body
import io.ktor.client.statement.HttpResponse
suspend fun readTodoDto(response: HttpResponse): TodoDto {
return response.body()
}
Иногда разработчики делают так: сначала распечатывают bodyAsText(), потом пытаются body<T>() — и получают очень загадочные проблемы. Это не мистика, это просто «вы уже съели печеньку, второй раз её нет».
4. Отправка JSON на сервер: POST и setBody(...)
До этого мы «только читали». Но JSON‑интеграция становится особенно приятной, когда вы отправляете Kotlin‑объект, а он автоматически превращается в JSON‑тело запроса.
Сделаем простую модель запроса. Например, «создать задачу»:
import kotlinx.serialization.Serializable
@Serializable
data class CreateTodoRequest(
val title: String,
val completed: Boolean
)
Теперь отправим POST. Здесь нам нужен setBody(...) и важно указать Content-Type: application/json (чтобы сервер понял формат). В Ktor это делается через contentType(ContentType.Application.Json).
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val client = buildJsonClient()
try {
val response: HttpResponse =
client.post("https://jsonplaceholder.typicode.com/todos") {
contentType(ContentType.Application.Json)
setBody(CreateTodoRequest(title = "Learn Ktor", completed = false))
}
println("status=${response.status.value}") // status=201 (обычно для create)
println("body=${response.bodyAsText()}") // body=... (сервер вернёт JSON)
} finally {
client.close()
}
}
Что важно заметить:
- setBody(obj) работает типо‑безопасно: вы реально передаёте объект.
- ContentNegotiation + json(...) позволяют Ktor понять: «Ага, я умею сериализовать CreateTodoRequest в JSON».
- contentType(ContentType.Application.Json) — это контракт формата. Если не указать, сервер может интерпретировать тело как угодно (или вообще отказаться).
5. Мини‑клиент Todo Console
Сейчас будет небольшой кусок «практический» практики: мы сделаем мини‑приложение, которое умеет:
- спросить у пользователя id задачи
- сходить в сеть
- вывести title и completed
Без архитектурных слоёв и без продвинутой обработки ошибок — просто чтобы руками почувствовать, насколько приятнее работать с объектом, чем со строкой.
Напомню: Ktor — это библиотека для создания HTTP‑штук в Kotlin‑экосистеме, и её активно используют в практике.
Маленькая функция ввода Int
Сделаем максимально простую версию: читаем строку, парсим в Int (мы уже умеем это делать).
fun readIntPrompt(prompt: String): Int {
print(prompt)
return readln().trim().toInt()
}
Запрос: получить todo по id
import io.ktor.client.call.body
import io.ktor.client.request.get
suspend fun fetchTodo(client: io.ktor.client.HttpClient, id: Int): TodoDto {
val url = "https://jsonplaceholder.typicode.com/todos/$id"
val response = client.get(url)
return response.body()
}
Обратите внимание: сигнатура проста, потому что мы пока не обсуждаем ошибки сети. Но даже в таком виде идея читается: «взяли id → сделали GET → получили TodoDto».
Главный main
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val client = buildJsonClient()
try {
val id = readIntPrompt("Введите id задачи: ")
val todo = fetchTodo(client, id)
println("title=${todo.title}") // title=...
println("completed=${todo.completed}") // completed=false
} finally {
client.close()
}
}
И вот это — ключевой эффект сегодняшней лекции: вы не «парсите JSON», вы работаете с данными.
6. Типичные ошибки
Ошибка №1: забыли подключить ContentNegotiation и удивляются, что body<T>() не работает.
Это классика жанра: вы написали модель, написали response.body<MyDto>(), а дальше либо ошибка, либо странное поведение. Причина почти всегда одна: клиент не знает, как превращать JSON в объект. Лечится ровно одним местом — настройкой HttpClient { install(ContentNegotiation) { json(...) } }.
Ошибка №2: модель не совпадает с JSON, а вы думаете, что «Kotlin сам догадается».
Kotlin умный, но не телепат. Если сервер прислал userId, а вы назвали поле user_id, оно само не сопоставится. Если сервер прислал строку "1", а вы ждёте Int, будет боль. На первых шагах дисциплина простая: поле Kotlin = ключ JSON, и тип должен совпадать. Потом уже изучают @SerialName и более гибкие правила.
Ошибка №3: читают тело дважды: сначала bodyAsText(), потом body<T>().
Это как открыть сок, выпить, а потом предъявить претензию бутылке, что она пустая. Сетевой ответ обычно даёт поток данных, который читается один раз. Выбирайте: либо текст, либо объект. Для отладки лучше сделать отдельный запуск или отдельную функцию, которая читает текст и печатает.
Ошибка №4: отправляют объект через setBody(...), но не указывают Content-Type: application/json.
Для вас объект очевидно «JSON‑ный», а для сервера это просто «какие-то байты». Если вы не указали Content-Type, сервер может решить, что это form-данные, plain text, или вообще неизвестно что. В Ktor привычка простая: при POST с JSON всегда пишем contentType(ContentType.Application.Json) рядом с setBody(...).
Ошибка №5: не включили ignoreUnknownKeys = true, и проект начинает «падать» из‑за лишнего поля.
Это очень жизненная ситуация: API обновилось, добавило поле, а ваш клиент внезапно не может распарсить ответ. На учебных проектах это выглядит как «всё работало вчера, сегодня сломалось, я ничего не менял». Поэтому ignoreUnknownKeys = true — почти стандартная настройка для клиентов, особенно когда вы не контролируете сервер.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ