JavaRush /Курсы /C++ SELF /Маппинг модели: to_json/from_json

Маппинг модели: to_json/from_json

C++ SELF
65 уровень , 2 лекция
Открыта

1. Зачем нужен маппинг struct ↔ JSON

Когда вы только начинаете писать программу, кажется логичным работать “напрямую”: вот JSON-объект, вот ключ "name", вот j["name"]. И первое время это даже приятно: быстро, без лишних слоёв, компилируется, и вы чувствуете себя повелителем данных.

Проблема начинается на второй неделе жизни проекта (то есть примерно через 15 минут после первого успешного запуска). Ключи расползаются по коду, появляются “магические строки”, кто-то пишет "userName", кто-то "username", а кто-то "user_name" — и всё это одно и то же поле, но в разных вселенных. Маппинг — это способ собрать правила преобразования в одном месте и перестать играть в “найди опечатку”.

Цель лекции: научиться выбирать между двумя подходами и понимать критерии выбора, а не действовать по принципу “потому что так принято”.

Два подхода: вручную и через to_json/from_json

Важно сразу снять страх: оба подхода нормальные. Ручная сериализация — не “позор новичка”, а инструмент, который иногда реально лучше. А to_json/from_json — не “магия”, а просто дисциплина: вы договорились, где живут ключи и правила.

Разница примерно как между “готовлю по рецепту” и “готовлю на глаз”. На глаз иногда получается быстрее и вкуснее — но если вы захотите повторить блюдо через месяц, вы внезапно обнаружите, что “щепотка соли” у всех разная, а “поджарить до готовности” компилятор не проверяет.

Сначала посмотрим на ручной подход, потом на to_json/from_json, а затем соберём понятные критерии выбора.

2. Ручная сериализация

Ручная сериализация — это когда вы явно создаёте nlohmann::json и явно из него читаете. Обычно это выглядит как две функции: serializeXxx(...) и parseXxx(...). Плюс этого подхода в том, что он прямолинейный и легко отлаживается: вы буквально видите, что куда кладёте.

Минус проявляется позже: логика чтения и записи начинает дублироваться, ключи расползаются, а изменения формата требуют редактировать кучу мест. Но на маленьких структурах и в разовых форматах ручной подход часто выигрывает по простоте.

Мини-пример ручной сериализации Task

Представим, что у нас есть учебное приложение “TaskBox” — консольный менеджер задач, который мы развивали с тех пор, как научились делать struct, vector, файлы и проверки ввода. Пока без версий формата и без миграций (это будет дальше по дню), просто сохраняем список задач в JSON.


#include <nlohmann/json.hpp>
#include <string>

struct Task {
    int id{};
    std::string title;
    bool done{};
};

nlohmann::json serialize_task(const Task& t) {
    return nlohmann::json{{"id", t.id}, {"title", t.title}, {"done", t.done}};
}

Код короткий, понятный. Здесь легко поставить брейкпоинт и посмотреть JSON-дерево глазами.

Ручная десериализация

Теперь читаем обратно. И вот здесь появляется первая развилка: вы делаете строго (обязательные поля через at()), или более мягко (через value() с дефолтами). По плану дня про валидацию будет отдельная лекция, поэтому сейчас покажем “строго и просто”, без хитрых дефолтов.

#include <nlohmann/json.hpp>
#include <string>

Task parse_task_strict(const nlohmann::json& j) {
    Task t;
    t.id = j.at("id").get<int>();
    t.title = j.at("title").get<std::string>();
    t.done = j.at("done").get<bool>();
    return t;
}

Это работает, но есть нюанс: если структура не та или тип поля другой — будет исключение. На этапе “сделать, чтобы работало” это нормально. На этапе “сделать, чтобы не падало на мусоре” — нужна валидация (следующая лекция дня).

Где ручной подход начинает болеть

Боль начинается не из-за JSON как такового, а из-за человеческого фактора.

Вы добавили поле priority в Task. Потом забыли обновить serialize_task. Или обновили сериализацию, но забыли обновить парсинг. Или обновили всё, но в одном месте написали ключ "prio", потому что “я так быстрее”. И всё: данные начали сохраняться “как-то странно”.

Ручной подход становится хрупким, когда:

  • структура используется во многих местах,
  • формат живёт долго,
  • есть несколько сущностей,
  • есть вложенные модели (Project содержит vector<Task>),
  • есть опциональные поля, и нужно единое правило.

4. to_json/from_json: правила рядом с типом

Подход to_json/from_json у nlohmann::json — это способ сказать: “Для типа Task вот правила, как его превращать в JSON и обратно”. Тогда внешний код становится чище: вы можете писать json j = task; и Task t = j.get<Task>();.

Это не “автоматическая сериализация из воздуха”. Вы всё равно пишете правила руками, просто делаете это в стандартизированном месте и получаете единый интерфейс.

Ключевая идея: не размазывать ключи по проекту, а держать их рядом с моделью (или в модуле сериализации модели).

Пример to_json/from_json для Task

#include <nlohmann/json.hpp>
#include <string>

struct Task {
    int id{};
    std::string title;
    bool done{};
};

void to_json(nlohmann::json& j, const Task& t) {
    j = nlohmann::json{{"id", t.id}, {"title", t.title}, {"done", t.done}};
}

void from_json(const nlohmann::json& j, Task& t) {
    t.id = j.at("id").get<int>();
    t.title = j.at("title").get<std::string>();
    t.done = j.at("done").get<bool>();
}

Обратите внимание: логика почти та же, что и в ручном подходе. Разница в том, что теперь библиотека умеет вызывать эти функции автоматически.

Как это выглядит “снаружи”

#include <nlohmann/json.hpp>
#include <iostream>

int main() {
    Task t{1, "Write JSON lecture", false};

    nlohmann::json j = t;
    std::cout << j.dump() << '\n'; // {"done":false,"id":1,"title":"Write JSON lecture"}

    Task back = j.get<Task>();
    std::cout << back.id << ' ' << back.title << '\n'; // 1 Write JSON lecture
}

Это и есть главный плюс: внешний код перестаёт знать про ключи "id", "title", "done". Он работает в терминах Task.

5. Критерии выбора

В реальном проекте вы не выбираете “раз и навсегда”. Иногда вы делаете to_json/from_json для основных моделей, а ручную сериализацию — для каких-то технических “пакетов” или временных форматов.

Чтобы не гадать, полезно держать критерии в виде таблицы. Это не закон, а шпаргалка для мозга.

Критерий Ручная сериализация to_json/from_json
Размер проекта Хорошо для маленьких, “одноразовых” мест Хорошо для средних и больших
Кол-во моделей 1–2 модели ещё терпимо Чем больше моделей — тем выгоднее
Риск “магических строк” Высокий, ключи легко расползаются Низкий, ключи локализованы
Внешний код Часто вынужден знать ключи Работает с типами (Task, Project)
Тестирование Придётся тестировать функции вручную Тоже тестируете, но интерфейс единый (get<T>)
Гибкость Очень гибко Тоже гибко, но иногда хочется отдельные функции
Обработка ошибок Полностью на вас Тоже на вас, но точки входа стандартизированы

Теперь переведём таблицу на человеческий язык.

Ручной подход чаще выигрывает, когда вы пишете маленький скрипт/утилиту, у вас один JSON “на входе” и один “на выходе”, и вы уверены, что формат не будет жить годами. to_json/from_json выигрывает, когда формат становится частью приложения, когда данные сохраняются в файл и должны читаться завтра, послезавтра и после вашей летней сессии.

6. Полезные нюансы маппинга

Локализация ключей: одна правда на весь проект

Даже если вы используете to_json/from_json, остаётся проблема: ключи всё равно строки. Хорошая практика — сделать так, чтобы ключи жили в одном месте и использовались одинаково.

Это особенно важно, когда у вас несколько разработчиков или когда вы сами через неделю забудете, как именно называли поле (а вы забудете, не сомневайтесь — это не баг, это “особенность оперативной памяти”).

Приём: namespace keys

#include <string_view>

namespace task_keys {
    inline constexpr std::string_view id = "id";
    inline constexpr std::string_view title = "title";
    inline constexpr std::string_view done = "done";
}

Теперь в from_json и to_json вы используете task_keys::id, а не "id".

Использование ключей в from_json

#include <nlohmann/json.hpp>

void from_json(const nlohmann::json& j, Task& t) {
    t.id = j.at(task_keys::id).get<int>();
    t.title = j.at(task_keys::title).get<std::string>();
    t.done = j.at(task_keys::done).get<bool>();
}

Да, это на пару символов длиннее. Зато при переименовании ключа вы исправляете его в одном месте, а не в десяти.

optional и контракт: “поля нет” vs “поле = null”

С std::optional вы рано или поздно столкнётесь, потому что реальные данные не всегда полные. И тут важно не просто “как написать код”, а какой контракт вы выбираете.

В рамках маппинга у вас есть две разумные политики.

Первая политика: “если optional пуст — поля в JSON вообще нет”. Это обычно самый удобный и компактный вариант.

Вторая политика: “поле есть всегда, но иногда оно равно null”. Это иногда нужно, если вы хотите отличать “поля нет” от “явно сбросили поле”.

Сейчас (до лекции про валидацию и дефолты) мы зафиксируем только первую политику, потому что она проще для начала и хорошо работает для большинства прикладных форматов.

Пример: optional<std::string> note

#include <nlohmann/json.hpp>
#include <optional>
#include <string>

struct Task {
    int id{};
    std::string title;
    bool done{};
    std::optional<std::string> note;
};

void to_json(nlohmann::json& j, const Task& t) {
    j = nlohmann::json{{"id", t.id}, {"title", t.title}, {"done", t.done}};
    if (t.note) {
        j["note"] = *t.note;
    }
}

Чтение обратно

#include <nlohmann/json.hpp>
#include <optional>
#include <string>

void from_json(const nlohmann::json& j, Task& t) {
    t.id = j.at("id").get<int>();
    t.title = j.at("title").get<std::string>();
    t.done = j.at("done").get<bool>();

    if (j.contains("note")) {
        t.note = j.at("note").get<std::string>();
    } else {
        t.note.reset();
    }
}

Смысловой момент: contains("note") проверяет отсутствие поля. is_null() — другое состояние, и его мы отдельно будем обсуждать в теме “валидация и дефолты”.

Вложенные структуры и контейнеры: эффект домино

Когда вы настроили маппинг для одного типа, стандартная библиотека и nlohmann::json позволяют “масштабировать” это на контейнеры. Это очень приятный эффект: вы описали правила для Task, и внезапно vector<Task> тоже начинает сериализоваться почти сам.

Это похоже на домино, но без драматичного падения продакшена (хотя я бы не зарекался).

Модель “проект” с задачами

#include <string>
#include <vector>

struct Project {
    std::string name;
    std::vector<Task> tasks;
};

to_json/from_json для Project

#include <nlohmann/json.hpp>

void to_json(nlohmann::json& j, const Project& p) {
    j = nlohmann::json{{"name", p.name}, {"tasks", p.tasks}};
}

void from_json(const nlohmann::json& j, Project& p) {
    p.name = j.at("name").get<std::string>();
    p.tasks = j.at("tasks").get<std::vector<Task>>();
}

Обратите внимание на строку {"tasks", p.tasks} и на get<std::vector<Task>>(). Это работает, потому что для Task уже есть to_json/from_json.

7. Практический сценарий: сохранение и загрузка TaskBox

Соберём кусочки в цельную картинку. Мы не делаем “идеальный продакшн”, мы делаем учебную, читаемую версию: сохранить проект в JSON-файл и загрузить обратно. Файлы и потоки у нас уже были, JSON-парсинг тоже был, а сегодня мы делаем так, чтобы код не превратился в кашу из ключей.

Ниже будет несколько коротких функций — именно так в реальности и пишут: маленькими кусками, чтобы мозг не перегревался.

Сохранение Project в файл

#include <nlohmann/json.hpp>
#include <fstream>
#include <string>

bool save_project(const Project& p, const std::string& path) {
    std::ofstream out(path);
    if (!out) return false;

    nlohmann::json j = p;
    out << j.dump(2);
    return true;
}

Здесь красота в одной строке: nlohmann::json j = p;. Вся “грязь” ключей осталась внутри to_json.

Загрузка Project из файла

#include <nlohmann/json.hpp>
#include <fstream>
#include <string>

bool load_project(Project& p, const std::string& path) {
    std::ifstream in(path);
    if (!in) return false;

    nlohmann::json j;
    in >> j;

    p = j.get<Project>();
    return true;
}

Эта версия “оптимистичная”: предполагает, что формат корректен. Если формат некорректен, get<Project>() может бросить исключение — и вот это мы будем приводить в порядок в следующей лекции (валидация + дефолты).

Как бы выглядело без маппинга

Чтобы вы прочувствовали критерий выбора на практике, представьте, что load_project вместо j.get<Project>() делает так:

  • j.at("name")...
  • j.at("tasks")...
  • цикл по массиву
  • внутри цикла task.at("id")... и так далее

И всё это прямо внутри функции загрузки файла. Это работает, но функции становятся длинными, трудночитаемыми, и самое неприятное — “формат” оказывается размазан по коду. То есть формат — это уже не “контракт приложения”, а “нечаянный побочный эффект того, как вы сегодня написали цикл”.

Где размещать to_json/from_json в проекте

Очень хочется дать универсальный рецепт “как правильно по папкам”, но у нас курс идёт по шагам, и архитектура большими мазками будет отдельно. Сейчас нам важно не это, а базовая дисциплина: чтобы правила были в одном месте.

Для учебного проекта можно держать struct Task и его to_json/from_json рядом в одном .cpp (или даже в одном main.cpp, пока проект маленький). Позже, когда вы перейдёте к разбиению на .hpp/.cpp, логика станет такой: модель объявляется в заголовке, а сериализация либо тоже рядом (если это “часть публичного контракта”), либо в отдельном модуле сериализации (если вы хотите отделить доменную модель от формата хранения).

Важно только не допускать “половинчатого состояния”, когда to_json лежит в одном месте, from_json в другом, и никто не уверен, что они симметричны. Это как написать “вход” в лабиринт, но забыть выход.

8. Типичные ошибки

Ошибка №1: смешать сериализацию с бизнес-логикой.
Иногда возникает соблазн: “Раз уж я читаю JSON, давайте тут же проверю, что id > 0, и если нет — исправлю”. В итоге сериализация начинает принимать решения за предметную область: подменять значения, “лечить” данные, тихо вставлять дефолты. Это удобно ровно до момента, пока вы не пытаетесь понять, откуда в программе взялась задача с id = 0. Сериализация должна преобразовывать формат, а не менять смысл данных.

Ошибка №2: расползание ключей по коду («магические строки»).
Даже при наличии to_json/from_json разработчик может случайно начать читать JSON напрямую в других местах: “ну мне тут только "name" посмотреть”. Потом таких мест становится десять, потом вы переименовали ключ, обновили to_json, а половина кода продолжает читать старый ключ. Если уж выбрали маппинг — старайтесь, чтобы внешний код работал с struct, а не с ключами.

Ошибка №3: асимметрия “записали одно — читаем другое”.
Классика: в to_json вы записали "done", а в from_json читаете "is_done". Компилятор не ругается: обе строки валидны. Ошибка проявится только во время выполнения. Поэтому полезно держать ключи централизованно (хотя бы через namespace keys) и по возможности писать сериализацию и десериализацию рядом, чтобы глазами увидеть симметрию.

Ошибка №4: считать from_json валидацией.
from_json — это “переложить значения из JSON в поля”. Он может упасть, если типы не совпали, но он не обязан проверять, что строка не пустая, что id в диапазоне, что массив не слишком большой. Валидация — отдельная тема и отдельный слой ответственности. Если вы попытаетесь впихнуть всю валидацию в from_json, он раздуется и станет тяжёлым для чтения.

Ошибка №5: путаница “нет поля” и “поле = null” при optional.
Если сегодня вы пишете optional как “нет поля”, а завтра начинаете иногда писать null, то через неделю у вас появятся три состояния вместо двух, и никто не будет помнить, что они означают. Выберите контракт (например, “optional → поля нет”) и соблюдайте его в обе стороны. А если нужен null — это тоже допустимо, но тогда договоритесь об этом явно и везде одинаково.

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