JavaRush /Курсы /Kotlin SELF /Базовый пайплайн encode

Базовый пайплайн encode/ decode + хранение JSON в файле

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

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 невозможного.

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