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 = "UAH")
    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. Ручне складання добре тим, що формат можна змінювати точково, але це не означає, що треба запихати туди все на світі «про всяк випадок». Робіть лише те, що справді використовується, і розширюйте за потреби.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ