JavaRush /Курсы /C++ SELF /Версионирование и миграции JSON

Версионирование и миграции JSON

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

1. Почему нужна версия формата

Представьте, что вы сохранили users.json, порадовались жизни и пошли пить чай. Проходит неделя — вы добавили поле email, переименовали name в fullName, а ещё решили, что active теперь обязателен. И вот старый файл внезапно становится «археологическим артефактом»: программа его читает, хмурится и падает. Версионирование формата — это способ честно признать, что данные живут дольше наших дизайнерских решений и иногда даже дольше наших надежд на «идеальную структуру с первого раза».

Ключевая мысль: версия — это не «декорация», а часть контракта. Если программа умеет читать только формат v2, она должна уметь понять, что перед ней v1, и либо аккуратно преобразовать, либо внятно отказать.

Версия формата vs версия программы

Когда говорят «версия», новички часто представляют номер релиза приложения: 1.0, 1.1, 2.0. Но нам сегодня важнее другое: версия формата данных. Формат — это то, как выглядит JSON и что означает каждое поле. Программа может обновляться хоть десять раз, но если она по-прежнему пишет и читает один и тот же формат, версия формата не меняется.

Чтобы мозг не кипел, полезно держать простую модель: программа эволюционирует часто, формат — редко, но болезненнее. Версию формата мы вводим именно для того, чтобы пережить «болезненные» изменения структуры.

Где хранить версию формата

Ниже — небольшая таблица, где обычно хранят версию:

Где хранить версию Как выглядит Плюсы Минусы
В корне документа
{"version": 2, ...}
Просто, явно, легко мигрировать Нужно договориться «корень — объект»
Внутри каждого объекта
{"user": {..., "version": 2}}
Иногда удобно при потоковых данных Обычно лишнее, дублирование
В имени файла
users_v2.json
Быстро «на глаз» Переименовали файл — и всё «сломалось»
«Эвристикой по полям» «если есть full_name, значит v1» Кажется удобно Ненадёжно, превращается в гадание на кофейной гуще

Практически почти всегда выигрывает вариант «версия в корне JSON-объекта».

2. Обратная совместимость и безопасные изменения

Когда вы меняете формат, вы не просто меняете JSON — вы меняете смысл, а смысл обычно мстителен. Обратная совместимость означает, что новая программа умеет читать старые данные. Это не обязательно означает, что старая программа умеет читать новые данные (это уже «прямая совместимость», и она часто дороже).

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

Чтобы легче ориентироваться, вот ещё одна табличка (она не «закон природы», но хорошая шпаргалка):

Изменение формата Обычно совместимо? Что делать при чтении
Добавили поле (опционально) Да value("x", default) или contains() + дефолт
Добавили поле (обязательное) Нет Либо миграция добавляет поле, либо чтение падает с понятной ошибкой
Переименовали поле Нет Миграция или fallback: читать старое имя, записывать новое
Поменяли тип поля ("42" 42) Обычно нет Нужна явная миграция с проверками
Убрали поле Иногда Либо игнорировать старое поле, либо миграция удалит
Поменяли смысл поля (самое опасное) Почти нет Версию повышаем обязательно, миграция должна менять данные, а не только ключи

Особенно коварный случай — «тип тот же, но смысл другой». Например, price раньше был в долларах, а теперь в центах. JSON выглядит почти одинаково, а пользователь потом удивляется, почему «кофе стоит 3000$». Такие изменения требуют версии почти всегда.

3. Миграция до from_json: общий конвейер

Если вы попытаетесь «впихнуть» поддержку всех версий прямо в from_json, он быстро превратится в комбайн, который умеет всё и поэтому его никто не понимает. Гораздо спокойнее держать понятный конвейер: прочитали JSON → узнали версию → при необходимости преобразовали JSON к текущему виду → провалидировали → только потом сделали j.get<Model>().

Это похоже на ремонт квартиры: сначала выравниваем стены (миграция), потом проверяем, что стены вообще существуют (валидация), и только потом заносим мебель (построение struct). Если заносить мебель раньше, вы будете носить шкаф по кругу.

Вот типичный поток обработки:

flowchart TD
    A[Чтение текста/файла] --> B[json::parse / in >> j]
    B --> C["Определить версию: j.value('version', 1)"]
    C --> D[Миграция к latest: v1->v2->...]
    D --> E[Валидация структуры latest-формата]
    E --> F["j.get<Model>() / from_json"]
    F --> G[Работа программы]
    G --> H["Сохранение в latest (с version)"]

Главный бонус: после миграции остальной код живёт в одной версии реальности. Это экономит нервы и уменьшает количество if по проекту.

4. Практический пример форматов v1 и v2

Чтобы не говорить в вакууме, давайте продолжим наш учебный мини-проект: файл users.json с массивом пользователей. Мы будем развивать формат так, будто проект живёт и меняется. Пускай в первой версии (v1) у пользователя было поле full_name, а версия в файле вообще не хранилась (классический «ну потом добавим»).

Пример данных v1 (заметьте: нет version, и имя хранится как full_name):

#include <iostream>
#include <string>

int main() {
    const std::string v1 = R"({"users":[{"id":1,"full_name":"Ann"}]})";
    std::cout << v1 << '\n'; // {"users":[{"id":1,"full_name":"Ann"}]}
}

Во второй версии (v2) мы делаем три изменения. Мы добавляем version: 2 в корень, переименовываем full_name в name, а ещё вводим поле active (пусть по умолчанию true). Старые файлы про active ничего не знают, значит миграция должна его добавить.

Вот как выглядит v2:

#include <iostream>
#include <string>

int main() {
    const std::string v2 = R"({"version":2,"users":[{"id":1,"name":"Ann","active":true}]})";
    std::cout << v2 << '\n'; // {"version":2,"users":[{"id":1,"name":"Ann","active":true}]}
}

И вот теперь у нас появляется задача: новая программа должна уметь прочитать и v1, и v2, но внутри работать так, будто существует только v2.

5. Миграция шагами: v1 → v2

Сейчас будет важный момент: миграция — это не обязательно «огромная функция на 300 строк». Наоборот, лучше делать миграции маленькими и последовательными: migrate_v1_to_v2, migrate_v2_to_v3 и так далее. Тогда, когда через месяц вы добавите v3, вам не придётся переписывать всё заново — вы просто добавите ещё один шаг.

Начнём с функции, которая достаёт версию. Если поля нет, считаем, что это v1:

#include <nlohmann/json.hpp>

int read_version(const nlohmann::json& j) {
    if (!j.is_object()) return 0;            // 0 = «совсем не то»
    return j.value("version", 1);            // если нет version, считаем v1
}

Теперь миграция v1→v2. Мы будем ожидать, что в корне есть users, а в каждом пользователе может быть full_name. Мы создадим name, добавим active, выставим version = 2, и удалим full_name, чтобы не тащить «реликты» дальше.

#include <nlohmann/json.hpp>

void migrate_v1_to_v2(nlohmann::json& j) {
    j["version"] = 2;

    for (auto& u : j.at("users")) {
        if (u.contains("full_name") && !u.contains("name")) {
            u["name"] = u["full_name"];
            u.erase("full_name");
        }
        if (!u.contains("active")) {
            u["active"] = true;
        }
    }
}

Обратите внимание на две вещи. Во-первых, мы используем at("users"): миграция предполагает, что структура хотя бы примерно ожидаемая. Если users отсутствует, это не «версия 1», а «данные сломаны», и лучше упасть валидацией. Во-вторых, мы меняем JSON «на месте», потому что это удобно для последовательных шагов.

Осталось собрать функцию migrate_to_latest. Пусть latest = 2:

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

constexpr int kLatestVersion = 2;

nlohmann::json migrate_to_latest(nlohmann::json j) {
    const int v = read_version(j);
    if (v == 1) migrate_v1_to_v2(j);
    if (read_version(j) != kLatestVersion) throw std::runtime_error("Unsupported version");
    return j;
}

Да, тут выглядит чуть «в лоб». Но для учебного проекта это отлично: ясно видно, что мы делаем. В реальном коде вы бы добавили обработку v > kLatestVersion более диагностично и, возможно, делали бы while (v < kLatestVersion).

6. Чтение и сохранение: читаем всё, пишем latest

Когда разработчик впервые делает миграции, у него часто появляется соблазн: «О, я умею читать v1 и v2 — значит, могу и писать в v1, если вход был v1». Это почти всегда плохая идея: у вас появятся два «живых» формата, и вы будете поддерживать их параллельно. Гораздо спокойнее правило: на запись всегда используем только текущую версию формата.

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

Мини-функция чтения из файла (без углубления в потоковые нюансы):

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

nlohmann::json load_json_file(const std::string& path) {
    std::ifstream in(path);
    if (!in) throw std::runtime_error("Cannot open file");
    nlohmann::json j;
    in >> j;
    return j;
}

И сохранение (в красивом виде через dump(2)):

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

void save_json_file(const std::string& path, const nlohmann::json& j) {
    std::ofstream out(path);
    if (!out) throw std::runtime_error("Cannot write file");
    out << j.dump(2) << '\n';
}

Теперь кусочек «склейки» в main: прочитали → мигрировали → (тут могли бы валидировать и делать get<UserDb>()) → сохранили в latest:

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

int main() {
    try {
        nlohmann::json j = load_json_file("users.json");
        j = migrate_to_latest(std::move(j));
        save_json_file("users.json", j);
        std::cout << "OK\n"; // OK
    } catch (const std::exception& e) {
        std::cerr << "Error: " << e.what() << '\n';
        return 1;
    }
}

Здесь мы получили очень практичный эффект: если пользователь дал старый файл v1, программа «подлечит» его до v2 и сохранит обратно уже в новом виде. И с этого момента его данные живут в текущем формате.

7. Стратегия обновлений и политика поддержки версий

Когда проект маленький, кажется, что «да ладно, мы просто добавим поле и всё». Когда проект растёт, выясняется, что JSON-файлы, конфиги и сохранения — это маленькая машина времени: они переносят прошлое в настоящее. Поэтому стратегия обновлений — это договорённость с самим собой (и с командой), как именно вы меняете формат, чтобы изменения были предсказуемы.

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

Дальше полезно думать в терминах «лестницы миграций». Каждая миграция — это шаг на одну ступеньку, а не прыжок через пять ступеней разом. Тогда чтение выглядит так: v1 → v2 → v3 → latest. И вы можете в любой момент выкинуть поддержку «слишком старых» версий, если договоритесь о политике (например, «поддерживаем последние 3 версии формата»).

Для визуализации удобно представить это как цепочку состояний:

stateDiagram-v2
    [*] --> V1
    V1 --> V2: migrate_v1_to_v2
    V2 --> V3: migrate_v2_to_v3
    V3 --> V4: migrate_v3_to_v4
    V4 --> [*]

И ещё один тонкий, но очень практичный момент: определитесь, что вы делаете с «будущими версиями». Если файл содержит version: 999, это не «ошибка парсинга», и не «ошибка структуры» — это ситуация «данные новее, чем программа». В таком случае обычно честнее всего вывести понятную ошибку вроде «Ваш файл создан более новой версией программы, обновите приложение», а не пытаться угадывать.

8. Типичные ошибки при версионировании и миграциях

Ошибка №1: версия существует, но её никто не проверяет.
Иногда разработчик добавляет "version": 2, но при чтении всё равно делает j.at("name") и удивляется падениям на старых файлах. Версия в таком случае превращается в наклейку «спорт», которая не делает машину быстрее. Если версия есть, чтение обязано на неё реагировать: мигрировать или отказывать.

Ошибка №2: миграция и валидация смешаны в одну кашу.
В миграции начинают проверять допустимость значений (age >= 0, name не пустой) и одновременно менять ключи. В результате становится непонятно, где «чиню структуру», а где «проверяю корректность». Гораздо спокойнее: миграция приводит JSON к текущей форме, а валидация уже проверяет текущую форму как контракт.

Ошибка №3: миграция не идемпотентна и ломает данные при повторном запуске.
Например, миграция каждый раз добавляет суффикс к имени или без проверки перетирает поле. Тогда повторный запуск программы «портит» файл всё сильнее. Хорошая миграция обычно устроена так, что повторное применение либо ничего не меняет, либо хотя бы не ухудшает ситуацию: есть проверки contains() и аккуратные условия.

Ошибка №4: попытка определить версию «по наличию полей».
Сегодня вы решили: «если есть full_name, это v1». Завтра добавили full_name как второе имя в v3 — и эвристика сломалась. Эвристики почти всегда превращаются в логику «угадай мелодию по двум нотам», поэтому лучше явное поле version.

Ошибка №5: пишем старые версии “потому что так пришло на входе”.
Так вы закапываете себя в поддержку нескольких форматов навсегда. Намного проще правило: читаем много, пишем один (latest). Тогда система постепенно сама «оздоравливает» данные при каждом сохранении.

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