JavaRush /Курсы /Kotlin SELF /Построение JSON вручную — отчёты и нестандартные структур...

Построение JSON вручную — отчёты и нестандартные структуры

Kotlin SELF
49 уровень , 3 лекция
Открыта

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-объект
{...}
buildJsonObject { ... }
JsonObject
JSON-массив
[...]
buildJsonArray { ... }
JsonArray
Строка/число/boolean
JsonPrimitive(...)
null
JsonNull

Начнём с совсем короткого примера, чтобы почувствовать синтаксис и не испугаться. Здесь нет доменной логики, только “собрали объект → распечатали”.

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. Ручная сборка хороша тем, что формат можно менять точечно, но это не значит, что нужно запихнуть туда всё на свете “на всякий случай”. Делайте только то, что реально используется, и расширяйте по мере необходимости.

1
Задача
Kotlin SELF, 49 уровень, 3 лекция
Недоступна
Статус операции
Статус операции
1
Задача
Kotlin SELF, 49 уровень, 3 лекция
Недоступна
Анкета профиля
Анкета профиля
1
Задача
Kotlin SELF, 49 уровень, 3 лекция
Недоступна
Экспорт расходов
Экспорт расходов
1
Задача
Kotlin SELF, 49 уровень, 3 лекция
Недоступна
Отчёт категорий
Отчёт категорий
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ