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 = "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. Ручне складання добре тим, що формат можна змінювати точково, але це не означає, що треба запихати туди все на світі «про всяк випадок». Робіть лише те, що справді використовується, і розширюйте за потреби.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ