1. Введение
Когда вы впервые познакомились с kotlinx.serialization, могло появиться ощущение: «Отлично! Теперь любой JSON я превращаю в data class, и жизнь удалась». На практике жизнь, как обычно, слегка хитрее: иногда формат отчёта не совпадает с вашими моделями, иногда нужно отдать только часть полей, иногда — добавить агрегаты, которых в модели вообще нет. И вот тогда ручная сборка JSON превращается из “фу, костыли” в “о, удобный инструмент”.
Представьте наш практический консольный проект (условный трекер расходов): внутри программы у нас есть нормальные Kotlin-структуры (Expense, списки, мапы), и мы хотим сделать “экспорт отчёта” в JSON для интеграции с чем угодно. Но отчёт — это не один Expense, а структура вроде:
- общая сумма,
- суммы по категориям,
- список операций,
- служебная секция meta (версия формата, валюта, автор отчёта).
Если попытаться «просто сериализовать список расходов», мы потеряем агрегаты. Если попытаться «сделать один огромный data class Report», он начнёт разрастаться, а формат отчёта будет жить своей жизнью и ломать вашу доменную модель. Поэтому сегодня мы учимся строить JSON как конструктор: аккуратно, предсказуемо и без склейки строк (склейка строк — это путь в царство запятых, которые забыли поставить).
2. Ментальная модель: JSON-отчёт как пайплайн
Прежде чем писать код, полезно договориться с мозгом, что у нас тут два разных мира: «вычисления» и «упаковка в JSON». Если смешать их в одну гигантскую функцию, получится суп: вкусно не будет, а отлаживать придётся половником. Поэтому держим простой пайплайн: сначала считаем цифры в Kotlin, потом упаковываем в JSON-дерево.
Вот схема, которую будем повторять как мантру (и да, мантры программисты тоже любят, просто называют их “pattern”):
flowchart TD
A[Данные в Kotlin: List⟨Expense⟩] --> B[Вычисления: total, byCategory]
B --> C[Сборка JsonElement: buildJsonObject/buildJsonArray]
C --> D[Кодирование в строку: Json.encodeToString]
D --> E[Вывод/сохранение]
С точки зрения Kotlin, buildJsonObject { ... } и buildJsonArray { ... } — это DSL-строители на лямбдах с ресивером: внутри блока вы как будто “находитесь” в объекте-сборщике и вызываете его методы. Под капотом компилятор очень старается вывести типы так, чтобы вы писали меньше “лишних слов” — это тот же дух, что и у apply/with и других “идиомных” конструкций. А если копнуть глубже, то такие builder-DSL в Kotlin завязаны на механизмы вывода типов внутри лямбд (builder-style inference).
3. Подготовка: JsonElement, модели и конвертеры
Базовые кирпичики ручной сборки JSON
Чтобы собирать JSON вручную, важно перестать думать «я пишу JSON», и начать думать «я создаю JsonElement». Это сильно снижает шанс сделать синтаксическую ошибку, потому что ошибки становятся “типовыми” (компилятор ругается), а не “строковыми” (пользователь ругается).
Ниже — минимальный набор, который нам сегодня нужен:
| Что хотим получить | Чем это представляем |
|---|---|
JSON-объект |
→ |
JSON-массив |
→ |
| Строка/число/boolean | |
|
|
Начнём с совсем короткого примера, чтобы почувствовать синтаксис и не испугаться. Здесь нет доменной логики, только “собрали объект → распечатали”.
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
fun main() {
val obj: JsonObject = buildJsonObject {
put("status", JsonPrimitive("ok"))
put("count", JsonPrimitive(3))
put("note", JsonNull)
}
println(obj) // {"status":"ok","count":3,"note":null}
}
Обратите внимание: put принимает JsonElement, а значит “сырые” Int и String мы превращаем в JsonPrimitive. Да, это чуть более многословно — зато крайне прозрачно.
Модели проекта: Expense и данные для отчёта
Чтобы примеры не были “в вакууме”, давайте продолжим линию практического проекта. Пусть у нас есть расход: идентификатор, название, категория и сумма (в целых единицах валюты, чтобы не спорить с дробями и округлениями).
Если у вас в проекте поля называются немного иначе — не страшно: сегодня важен стиль, а не конкретные имена.
data class Expense(
val id: Int,
val title: String,
val category: String,
val amount: Int
)
А теперь набросаем маленький список расходов, чтобы отчёт можно было собрать прямо в main. В реальном проекте список будет приходить из вашего хранилища (файл/память), но нам сейчас важнее “как собрать JSON”.
fun sampleExpenses(): List<Expense> = listOf(
Expense(id = 1, title = "Кофе", category = "Еда", amount = 220),
Expense(id = 2, title = "Проезд", category = "Транспорт", amount = 75),
Expense(id = 3, title = "Пицца", category = "Еда", amount = 540)
)
JSON для одной сущности: expenseToJson(...)
Когда вы строите отчёты, почти всегда есть повторяющаяся часть: “как один элемент выглядит в JSON”. Если не вынести это в функцию, у вас по коду расползётся копипаста из put("id", JsonPrimitive(...)) — и потом вы обязательно обновите формат в одном месте, но забудете в другом. Это классическая ошибка “у меня две версии правды”, только без философии, но с багами.
Сделаем маленький конвертер одного расхода в JsonObject. Он не сериализует “магически”, а явно раскладывает поля — и это плюс, потому что вы полностью контролируете формат.
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
fun expenseToJson(e: Expense): JsonObject = buildJsonObject {
put("id", JsonPrimitive(e.id))
put("title", JsonPrimitive(e.title))
put("category", JsonPrimitive(e.category))
put("amount", JsonPrimitive(e.amount))
}
С этого момента отчёт станет собираться как LEGO: список расходов превращаем в массив JSON-объектов через expenseToJson, а не пишем поля заново каждый раз.
4. Собираем отчёт: агрегаты и массив элементов
Теперь самое интересное: отчёт обычно содержит “всё сразу”. И в этом нет ничего страшного, если вы не пытаетесь посчитать суммы прямо внутри buildJsonObject. Мы сделаем по-человечески: сначала посчитаем, потом упакуем.
Считаем агрегаты в Kotlin
Сначала общая сумма:
fun totalAmount(items: List<Expense>): Int =
items.sumOf { it.amount }
Потом суммы по категориям. Да, можно сделать это разными способами; возьмём простой и читаемый: сгруппировали, затем просуммировали каждую группу.
fun totalByCategory(items: List<Expense>): Map<String, Int> =
items.groupBy { it.category }
.mapValues { (_, list) -> list.sumOf { it.amount } }
Упаковываем агрегаты в JSON
Теперь собираем итоговый JsonObject. Внутри будет total, объект byCategory и массив items.
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
fun buildExpensesReport(items: List<Expense>): JsonObject {
val total = totalAmount(items)
val byCat = totalByCategory(items)
return buildJsonObject {
put("total", JsonPrimitive(total))
put("byCategory", buildJsonObject {
for ((category, amount) in byCat) {
put(category, JsonPrimitive(amount))
}
})
put("items", buildJsonArray {
for (e in items) add(expenseToJson(e))
})
}
}
Здесь важно почувствовать: byCategory — это вложенный JSON-объект, а items — вложенный JSON-массив. Мы не “печатаем JSON”, мы строим дерево элементов.
5. Нестандартные структуры отчёта
Отчёты редко бывают “копией базы данных”. Обычно формат хочет чего-то странного: переименованные ключи, объединение полей, условные секции, или вложенность, которой нет в модели. Ручная сборка как раз для этого и нужна: вы можете описать формат прямо в коде без попытки натянуть его на доменную модель.
Переименование ключей и “человеческие” поля
Допустим, мы хотим в отчёте не title, а name, и ещё поле amountText (строкой), потому что так удобнее показывать в UI (да, это спорно, но отчёты иногда делают и так).
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
fun expenseToPublicJson(e: Expense): JsonObject = buildJsonObject {
put("id", JsonPrimitive(e.id))
put("name", JsonPrimitive(e.title))
put("amount", JsonPrimitive(e.amount))
put("amountText", JsonPrimitive("${e.amount} ₣ "))
}
Обратите внимание: доменная модель не поменялась, а формат отчёта — поменялся. Это хороший признак: вы не “ломаете” бизнес-логику ради экспорта.
JsonNull vs отсутствие поля
Очень частая тонкость: “поле отсутствует” и “поле есть, но null” — это не одно и то же. Даже если вам кажется, что это одинаково, где-то на другом конце провода сидит человек (или сервис), который считает иначе. Поэтому мы должны уметь делать оба варианта.
Сымитируем поле comment: иногда оно есть, иногда нет.
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
fun expenseWithCommentJson(e: Expense, comment: String?): JsonObject = buildJsonObject {
put("id", JsonPrimitive(e.id))
put("title", JsonPrimitive(e.title))
if (comment == null) {
put("comment", JsonNull) // поле есть, но оно null
} else {
put("comment", JsonPrimitive(comment))
}
}
А теперь вариант “если комментария нет — поля нет вообще”. Это другой контракт.
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
fun expenseWithOptionalCommentJson(e: Expense, comment: String?): JsonObject = buildJsonObject {
put("id", JsonPrimitive(e.id))
put("title", JsonPrimitive(e.title))
if (comment != null) {
put("comment", JsonPrimitive(comment)) // поля нет, если comment == null
}
}
Снаружи эти JSON выглядят почти одинаково, но смысл отличается. И вот такие различия — одна из причин, почему ручная сборка иногда честнее, чем “автосериализация как получится”.
6. Гибридный подход: часть вручную, часть через encodeToJsonElement(...)
Иногда вы хотите собрать “обвязку” отчёта вручную, но какую-то внутреннюю секцию — сериализовать как есть, потому что там уже всё красиво описано @Serializable. Это нормальная стратегия: вы не обязаны выбирать между “только вручную” и “только data class”.
Сделаем маленькую мета-информацию отчёта (версия формата и валюта). Она простая и стабильная — отличный кандидат на @Serializable.
import kotlinx.serialization.Serializable
@Serializable
data class ReportMeta(
val formatVersion: Int,
val currency: String
)
Теперь упакуем meta через Json.encodeToJsonElement(...), а остальное — вручную.
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.encodeToJsonElement
fun buildReportWithMeta(items: List<Expense>): JsonObject {
val meta = ReportMeta(formatVersion = 1, currency = "RUB")
val total = totalAmount(items)
return buildJsonObject {
put("meta", Json.encodeToJsonElement(meta))
put("total", JsonPrimitive(total))
}
}
Почему это удобно? Потому что ReportMeta — это “маленькая модель данных”, и её можно переиспользовать. А сам отчёт — “нестандартная структура”, и её удобнее контролировать вручную.
7. Как превратить JsonElement в строку
Когда вы собрали JsonObject или JsonArray, возникает простой вопрос: “Окей, а как получить нормальный JSON-текст?”. В Kotlin хочется сделать .toString() — и да, он даст JSON-представление. Но почти всегда это будет компактная строка в одну линию. Для логов это нормально, но для отчётов, которые читает человек, обычно хочется “красиво”.
Сделаем два варианта: компактный и pretty.
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.serializer
fun main() {
val report: JsonObject = buildJsonObject { put("status", JsonPrimitive("ok")) }
println(report.toString()) // {"status":"ok"}
val prettyJson = Json { prettyPrint = true }
val text: String = prettyJson.encodeToString(JsonElement.serializer(), report)
println(text) // красивый многострочный JSON
}
Важный момент: мы кодируем именно JsonElement, потому что наш отчёт — это уже дерево JSON, а не @Serializable-класс. Это логично: сегодня мы строим JSON “как структуру”, а не “как модель”.
8. Типичные ошибки при ручной сборке JSON
Ошибка №1: склеивать JSON строками.
Когда руки тянутся написать "{\"total\": $total}", стоит вспомнить, что вы уже умеете строить JsonObject. Склейка строк ломается на кавычках, переносах, экранировании и “а здесь запятая нужна?”. С buildJsonObject у вас хотя бы компилятор — союзник, а не равнодушный наблюдатель.
Ошибка №2: смешивать расчёт отчёта и упаковку в JSON в одном месте.
Если внутри buildJsonObject { ... } вы начинаете делать groupBy, sumOf, сортировки и фильтры, код быстро превращается в “комбайн”. Сначала посчитайте всё в Kotlin-переменные (val total, val byCat), а потом спокойно упакуйте. Так вы легче отладите и расчёты, и формат.
Ошибка №3: путать “нет поля” и “поле = null”.
Если вы то опускаете ключ, то кладёте JsonNull, внешний потребитель данных может начать вести себя по-разному. Это не “мелочь”, это часть контракта формата. Решите правило один раз и придерживайтесь его везде.
Ошибка №4: дублировать имена ключей по проекту как строки.
Сегодня вы написали "total", завтра — "Total", послезавтра — "sum", и вот ваш отчёт “иногда читается”. Даже в маленьком учебном проекте лучше держать ключи в одном месте (например, object ReportKeys { const val TOTAL = "total" }) и использовать константы, чтобы не плодить случайные опечатки.
Ошибка №5: делать “почти JSON”, кладя в объект не JsonElement.
Иногда новички пытаются сделать put("count", 3) и удивляются, почему компилятор ругается. В ручной сборке вы работаете на уровне JsonElement, поэтому числа и строки превращайте в JsonPrimitive. Да, это на пару символов длиннее, зато формат становится строго типизированным на уровне API.
Ошибка №6: пытаться сделать отчёт “идеальным универсальным форматом” сразу.
Отчёт — это контракт. Сегодня вам нужен total и items, завтра — добавится byCategory, послезавтра — meta. Ручная сборка хороша тем, что формат можно менять точечно, но это не значит, что нужно запихнуть туда всё на свете “на всякий случай”. Делайте только то, что реально используется, и расширяйте по мере необходимости.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ