1. Что строим сегодня: объект → JSON-строка → файл → объект
Когда впервые слышишь «объект в файл», мозг часто рисует магию уровня «засунул переменную в файл — достал завтра такой же». В реальности магии нет: файл умеет хранить байты/текст, а не Kotlin‑объекты. Поэтому мы будем делать очень честную цепочку: сначала превращаем объект в строку JSON, затем записываем строку в файл. Потом — читаем строку из файла и превращаем обратно в объект.
Чтобы примеры были связаны в одно приложение, продолжим наш условный консольный мини‑проект: учёт расходов (Expense Tracker). Пока без базы данных, без сетей и без “взрослой” архитектуры — нам сейчас важнее научиться сохранять и загружать данные так, чтобы завтра приложение не начинало жизнь с чистого листа и грустным println("Ну что, снова всё руками?").
Схема пайплайна
flowchart LR
A[Expense / List⟨Expense⟩
в памяти] --> B[encodeToString
JSON String]
B --> C[writeText
expenses.json]
C --> D[readText
JSON String]
D --> E[decodeFromString
List⟨Expense⟩]
2. Шаг 1: объект → JSON-строка
Когда мы говорим “сериализовать в JSON”, по сути мы говорим: “сделай из объекта строку, которую можно показать в консоли, записать в файл, отправить куда угодно”. И вот тут очень важно не перепутать: toString() тоже даёт строку, но это не формат данных, а просто техническое представление “для человека и отладки”. JSON — это формат, который библиотека умеет читать обратно и который можно считать контрактом.
Мини‑модель для нашего приложения
Предположим, в прошлой лекции (про @Serializable) мы уже сделали вот такую модель расхода:
import kotlinx.serialization.Serializable
@Serializable
data class Expense(
val id: Int,
val title: String,
val amount: Int,
val category: String,
val note: String? = null,
)
Здесь всё максимально приземлённо: id — целое, title — строка, amount — тоже целое (например, франки/доллары без копеек — не идеально, но достаточно для старта), category — строка, note — nullable, чтобы потренировать null.
Самое простое кодирование: один объект
Сначала без файлов и без списков — чтобы увидеть “что вообще получается”. Для кодирования в строку используется Json.encodeToString(...):
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
fun main() {
val e = Expense(id = 1, title = "Кофе", amount = 250, category = "Еда")
val jsonText = Json.encodeToString(e)
println(jsonText)
// {"id":1,"title":"Кофе","amount":250,"category":"Еда","note":null}
}
Обратите внимание: JSON получился “плотный”, без переносов строк. Это нормально. Делать его красивым для человека мы будем в следующей лекции про настройки Json { ... }, а сейчас нам важнее, чтобы он надёжно работал как формат хранения.
Кодирование списка расходов
Наше приложение хранит не один расход, а обычно список. JSON для списка будет выглядеть как массив [...], и библиотека это поддерживает напрямую:
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
fun main() {
val expenses = listOf(
Expense(1, "Кофе", 250, "Еда"),
Expense(2, "Проезд", 60, "Транспорт", note = "Метро"),
)
val jsonText = Json.encodeToString(expenses)
println(jsonText)
// [{"id":1,"title":"Кофе","amount":250,"category":"Еда","note":null},{"id":2,"title":"Проезд","amount":60,"category":"Транспорт","note":"Метро"}]
}
Здесь полезно держать в голове простое соответствие: List<Expense> превращается в JSON‑массив [...], а каждый Expense внутри — в JSON‑объект {...}.
3. Шаг 2: JSON-строка → объект
Теперь идём в обратную сторону. Идея десериализации простая: у нас есть строка JSON, и мы хотим получить Kotlin‑объект нужного типа. Важно, что тип нужно указать. JSON‑строка сама по себе не говорит “я User” или “я Expense”. Она просто данные, а тип — это решение вашего кода.
Чтобы не строить иллюзий, скажу честно: этот шаг может упасть с ошибкой, если JSON не соответствует модели. И это нормально. Мы как раз учимся жить в мире, где входные данные бывают “грязными”.
Для декодирования используется Json.decodeFromString<T>(...).
Декодируем один объект
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
fun main() {
val input = """{"id":10,"title":"Чай","amount":120,"category":"Еда","note":null}"""
val e = Json.decodeFromString<Expense>(input)
println(e)
// Expense(id=10, title=Чай, amount=120, category=Еда, note=null)
}
Обратите внимание на <Expense>: мы явно говорим “прочитай это как Expense”.
Декодируем список
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
fun main() {
val input = """[{"id":1,"title":"Кофе","amount":250,"category":"Еда","note":null}]"""
val expenses = Json.decodeFromString<List<Expense>>(input)
println(expenses.size) // 1
println(expenses[0].title) // Кофе
}
Если перепутать {} и [], будет ошибка. Это один из самых частых багов: вы думаете, что в файле один объект, а там массив, или наоборот. Поэтому полезно перед декодированием хотя бы “глазами” проверить, чем начинается строка: { или [.
4. Шаг 3: файл как транспорт для строки
На этом этапе мы делаем важную психологическую перестройку: файл не “хранит объекты”, файл “хранит строку”. Мы просто договорились, что эта строка будет JSON.
С точки зрения кода это означает, что операции делятся на две независимые части: сериализация (в строку) и I/O (в файл). Это разделение очень помогает отладке: если что-то сломалось, вы сразу понимаете — проблема в JSON или проблема в пути/правах/директории.
Запись JSON‑строки в файл
import java.io.File
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
fun main() {
val dir = File("data")
dir.mkdirs()
val file = File(dir, "expenses.json")
val text = Json.encodeToString(listOf(Expense(1, "Кофе", 250, "Еда")))
file.writeText(text)
println("Saved to ${file.path}") // Saved to data/expenses.json
}
Ключевой момент — dir.mkdirs(): если папки нет, запись может завершиться ошибкой. Да, компьютер иногда требует, чтобы вы сначала создали папку. Он упрямый, но честный.
Чтение JSON‑строки из файла
import java.io.File
fun main() {
val file = File("data/expenses.json")
val text = file.readText()
println(text) // содержимое JSON одной строкой
}
Здесь всё максимально прямолинейно: прочитали строку — и дальше можно отдавать её в decodeFromString.
5. Мини‑пайплайн: сохранить и загрузить список
Теперь склеим всё в два шага: сохранить список и потом загрузить. Чтобы код было проще читать и переиспользовать, вынесем в маленькие функции. Это не “архитектура на века”, а просто человеческая привычка: не писать один и тот же код в трёх местах.
Функция сохранения
import java.io.File
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
fun saveExpenses(file: File, expenses: List<Expense>) {
val text = Json.encodeToString(expenses)
file.parentFile?.mkdirs()
file.writeText(text)
}
Тут мы делаем маленькую защиту: file.parentFile?.mkdirs() создаст директорию, если она есть (и если parentFile не null). Это удобно, когда вы передаёте путь вроде "data/expenses.json".
Функция загрузки
import java.io.File
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
fun loadExpenses(file: File): List<Expense> {
if (!file.exists()) return emptyList()
val text = file.readText()
return Json.decodeFromString(text)
}
Здесь важный UX‑момент: если файла нет, это не “ошибка мира”. Это нормальная ситуация для первого запуска. Поэтому вместо падения мы возвращаем emptyList().
Обратите внимание: Json.decodeFromString(text) работает без явного <List<Expense>>, потому что компилятор может вывести тип из возвращаемого типа функции. Если бы loadExpenses возвращала Any, пришлось бы писать тип явно — но мы так делать не будем, мы же хотим дружить с компилятором, а не воевать.
Проверяем в main, что “туда‑обратно” работает
Теперь напишем небольшой main, который создаёт список расходов, сохраняет, загружает обратно и печатает результат. Это важный ритуал: сначала убедиться, что пайплайн работает в вакууме, а потом уже прикручивать к командам add/list/remove (которые вы могли делать в более ранних днях).
import java.io.File
fun main() {
val file = File("data/expenses.json")
val expenses = listOf(
Expense(1, "Кофе", 250, "Еда"),
Expense(2, "Проезд", 60, "Транспорт", note = "Метро"),
)
saveExpenses(file, expenses)
val loaded = loadExpenses(file)
println("Loaded: ${loaded.size}") // Loaded: 2
println(loaded[0]) // Expense(id=1, title=Кофе, amount=250, category=Еда, note=null)
}
Если этот пример работает, значит база готова: мы научились хранить данные “между запусками”. Это уже почти взрослая программа (осталось совсем чуть‑чуть: перестать ломаться от любого странного файла).
Встраиваем файл в CLI‑учёт расходов
Когда вы будете (или уже сделали) консольный интерфейс с командами типа add, list, remove, важно встроить файл правильно: загрузка должна происходить в начале программы, а сохранение — после изменений. Если сохранять в конце, а программа упала раньше — данные потерялись. Если сохранять после каждой команды, зато потерять почти невозможно (и да, это немного чаще пишет в файл, но для учебного проекта это нормально).
Ниже — очень упрощённый “скелет” main, без сложного парсинга команд. Он показывает именно идею “загрузили → поменяли → сохранили”.
import java.io.File
fun main() {
val file = File("data/expenses.json")
val expenses = loadExpensesSafe(file).toMutableList()
expenses.add(Expense(3, "Печенье", 90, "Еда"))
saveExpenses(file, expenses)
println("Now total: ${expenses.size}") // Now total: 3
}
Здесь важная мысль: в памяти мы работаем со списком (MutableList), но в файле мы храним JSON‑строку. Файл — не “умнее” списка. Он просто хранит результат сериализации.
6. Ошибки и диагностика: где падает загрузка
Любая операция “загрузить данные” живёт в суровом мире. Файл может отсутствовать, может быть пустым, может быть повреждён, может быть не JSON, может быть JSON, но не того формата. И если вы не разделяете этапы “прочитать строку” и “распарсить JSON”, диагностика превращается в гадание на кофейной гуще (в нашем приложении про расходы — это особенно символично).
Два класса проблем
В реальности проблемы почти всегда распадаются на две большие категории:
| Где сломалось | Как выглядит | Что это значит по-человечески |
|---|---|---|
| I/O (файл) | не найден, нет прав, директория вместо файла | “мы не смогли получить строку” |
| JSON/тип | ошибка парсинга, несовпадение типов | “строку получили, но она не совпадает с моделью” |
Минимальная диагностика через try/catch
Сделаем версию loadExpenses, которая не падает, а возвращает пустой список и печатает понятное сообщение. Это не идеальная стратегия для всех программ, но для учебного консольного приложения — отличный старт.
import java.io.File
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
fun loadExpensesSafe(file: File): List<Expense> {
if (!file.exists()) return emptyList()
return try {
val text = file.readText()
Json.decodeFromString(text)
} catch (e: Exception) {
println("Failed to load ${file.path}: ${e.message}")
emptyList()
}
}
Почему ловим Exception, а не что-то более точное? Потому что сейчас наша цель — научиться не падать и увидеть сообщение. Уточнять типы исключений можно позже, когда вы почувствуете уверенность. На этом уровне главное — понять принцип: “прочитать” и “распарсить” — разные шаги.
7. Типичные ошибки
Ошибка №1: пытаться “сохранить объект” через toString().
У data class очень удобный toString(), и он действительно выглядит “почти как данные”. Но это ловушка: это строка для отладки, а не формат хранения. Сегодня вы сохранили Expense(id=1, title=Кофе, ...), а завтра поменяли порядок полей или имя свойства — и “формат” внезапно стал другим. JSON же создаётся библиотекой и читается библиотекой, поэтому цикл “туда‑обратно” действительно имеет смысл.
Ошибка №2: смешивать I/O и JSON в одну кашу, а потом не понимать, где упало.
Если вы делаете в одной строке Json.decodeFromString(File("x").readText()), то при ошибке вы видите исключение, но не понимаете, проблема была в чтении файла или в парсинге. Когда же вы держите шаги отдельно, отладка становится почти скучной: “файл прочитали — строка вот она — парсинг упал — значит JSON не совпадает”.
Ошибка №3: не создавать директорию перед записью.
File("data/expenses.json").writeText(...) выглядит красиво, но если папки data нет, запись может не состояться. Из-за этого новички иногда думают “сериализация не работает”, хотя сериализация как раз сработала, а упала запись. Создавайте директорию через mkdirs() (или через file.parentFile?.mkdirs() внутри функции сохранения).
Ошибка №4: пытаться декодировать не тот верхний уровень: {} вместо [].
Если вы сохраняете список расходов, в файле будет JSON‑массив [...]. Если потом по ошибке попытаетесь декодировать это как один Expense, получите исключение. Самый простой способ самопроверки — посмотреть на первый символ JSON: { означает объект, [ означает список.
Ошибка №5: считать отсутствие файла ошибкой.
Первый запуск программы почти всегда происходит без файла. Если вы на этом месте падаете с исключением, вы делаете приложение, которое не умеет начинать жизнь. Лучше воспринимать “файла нет” как “данных пока нет” и возвращать emptyList() — это намного дружелюбнее к пользователю (и к вам, когда вы тестируете).
Ошибка №6: думать, что “если JSON распарсился — значит данные корректны”.
Парсинг проверяет структуру и типы, но не проверяет смысл. Например, amount = -999999 вполне может распарситься. Поэтому полезно помнить: сериализация/десериализация — это слой “форма данных”, а проверки корректности — отдельная логика вашего приложения. Сегодня мы эту границу просто фиксируем, чтобы не требовать от JSON невозможного.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ