1. Чому потрібна версія формату
Уявіть, що ви зберегли users.json, пораділи життю й пішли пити чай. Минає тиждень — ви додаєте поле email, перейменовуєте name на fullName, а ще вирішуєте, що active тепер обовʼязкове. І раптом старий файл стає «археологічним артефактом»: програма його читає, хмуриться й падає. Версіонування формату — це спосіб чесно визнати, що дані живуть довше за наші рішення в проєкті, а іноді — навіть довше за нашу віру в «ідеальну структуру з першого разу».
Ключова думка проста: версія — не «декорація», а частина контракту. Якщо програма вміє читати лише формат v2, вона має розпізнати, що перед нею v1, і або коректно перетворити дані, або зрозуміло відмовити.
Версія формату vs версія програми
Коли кажуть «версія», новачки часто уявляють номер релізу застосунку: 1.0, 1.1, 2.0. Але зараз для нас важливіше інше: версія формату даних. Формат — це те, який вигляд має JSON і що означає кожне поле. Програма може оновлюватися хоч десять разів, але якщо вона, як і раніше, записує й читає один і той самий формат, версія формату не змінюється.
Щоб не ускладнювати собі життя, корисно тримати в голові просту модель: програма еволюціонує часто, формат — рідше, але болючіше. Саме тому ми й вводимо версію формату: щоб пережити «болючі» зміни структури.
Де зберігати версію формату
Нижче — невелика таблиця з найпоширенішими варіантами:
| Де зберігати версію | Як виглядає | Плюси | Мінуси |
|---|---|---|---|
| У корені документа | |
Просто, явно, легко мігрувати | Потрібно заздалегідь домовитися, що «корінь — це обʼєкт» |
| Усередині кожного обʼєкта | |
Іноді зручно для потокових даних | Зазвичай зайве, бо створює дублювання |
| В імені файлу | |
Швидко видно «на око» | Перейменували файл — і все «зламалося» |
| Евристикою за полями | «якщо є full_name, то це v1» | На перший погляд зручно | Ненадійно, швидко перетворюється на ворожіння на кавовій гущі |
На практиці майже завжди перемагає варіант «версія в корені JSON-обʼєкта».
2. Зворотна сумісність і безпечні зміни
Коли ви змінюєте формат, то змінюєте не просто JSON, а його сенс, а сенс зазвичай мстивий. Зворотна сумісність означає, що нова програма вміє читати старі дані. Це не обовʼязково означає, що стара програма вміє читати нові дані. Це вже «пряма сумісність», і вона часто обходиться дорожче.
Є зміни, які зазвичай минають спокійно. Наприклад, ви додаєте нове необовʼязкове поле зі значенням за замовчуванням: старі файли його не містять, але нова програма може підставити це значення й працювати далі. А от перейменування поля — уже ризик: старий файл містить full_name, нова програма очікує name, і без «моста» вони не зустрінуться.
Щоб було легше орієнтуватися, ось ще одна табличка. Це не «закон природи», але добра шпаргалка:
| Зміна формату | Зазвичай сумісно? | Що робити під час читання |
|---|---|---|
| Додали поле (необовʼязкове) | Так | value("x", default) або contains() + значення за замовчуванням |
| Додали поле (обовʼязкове) | Ні | Або міграція додає поле, або читання завершується зі зрозумілою помилкою |
| Перейменували поле | Ні | Міграція або сумісне читання: читати старе імʼя, записувати нове |
| Змінили тип поля ("42" → 42) | Зазвичай ні | Потрібна явна міграція з перевірками |
| Прибрали поле | Іноді | Або ігнорувати старе поле, або міграція видалить його |
| Змінили сенс поля (найнебезпечніше) | Майже ні | Версію підвищуємо обовʼязково, а міграція має змінювати не лише ключі, а й самі дані |
Особливо підступний випадок — «тип той самий, але сенс інший». Наприклад, price раніше був у доларах, а тепер — у центах. JSON виглядає майже однаково, а користувач потім дивується, чому «кава коштує 3 000 $». Такі зміни майже завжди вимагають нової версії.
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("Непідтримувана версія");
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("Не вдалося відкрити файл");
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("Не вдалося записати файл");
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 << "Гаразд\n"; // Гаразд
} catch (const std::exception& e) {
std::cerr << "Помилка: " << 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). Тоді система поступово сама «оздоровлює» дані під час кожного збереження.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ