1. Perché serve la versione del formato
Immaginate di aver salvato users.json, esservi compiaciuti e andare a prendere un tè. Passa una settimana — avete aggiunto il campo email, rinominato name in fullName, e avete deciso che active è ora obbligatorio. Ecco che il file vecchio diventa un «reperto archeologico»: il programma lo legge, storce il naso e va in crash. Il versionamento del formato è un modo onesto per riconoscere che i dati vivono più a lungo delle nostre decisioni di design e a volte più a lungo delle nostre speranze per una «struttura perfetta al primo colpo».
Idea chiave: la versione non è una «decorazione», ma parte del contratto. Se il programma sa leggere solo il formato v2, deve essere in grado di capire che davanti a sé c'è v1 e o trasformarlo con cura o rifiutare con un errore chiaro.
Versione del formato vs versione del programma
Quando si parla di «versione», i principianti spesso pensano al numero di rilascio dell'app: 1.0, 1.1, 2.0. Ma qui ci interessa altro: la versione del formato dei dati. Il formato è come appare il JSON e cosa significa ogni campo. Il programma può aggiornarsi dieci volte, ma se continua a scrivere e leggere lo stesso formato, la versione del formato non cambia.
Per non impazzire, è utile mantenere un modello semplice: il programma evolve spesso, il formato raramente ma con impatto maggiore. Introduciamo la versione del formato proprio per superare i cambiamenti «dolorosi» della struttura.
Dove conservare la versione del formato
Sotto — una breve tabella con i posti più comuni dove conservare la versione:
| Dove conservare la versione | Aspetto | Vantaggi | Svantaggi |
|---|---|---|---|
| Alla radice del documento | |
Semplice, esplicito, facile da migrare | Occorre concordare che «la radice è un oggetto» |
| All'interno di ogni oggetto | |
Talvolta comodo per dati in streaming | Di solito ridondante |
| Nel nome del file | |
Rapido da capire «a vista» | Rinominando il file tutto può «rompersi» |
| «Euristica basata sui campi» | «se c'è full_name, allora è v1» | Sembra comodo | Inaffidabile, diventa come leggere le foglie del caffè |
Quasi sempre vince l'opzione «versione alla radice dell'oggetto JSON».
2. Retrocompatibilità e cambiamenti sicuri
Quando si cambia il formato non si cambia solo il JSON — si cambia il significato, e il significato tende a vendicarsi. La retrocompatibilità significa che il programma nuovo sa leggere i dati vecchi. Non è detto che il programma vecchio sappia leggere i dati nuovi (quella è la «compatibilità diretta», spesso più costosa).
Ci sono cambiamenti che generalmente passano indolori. Per esempio, aggiungere un nuovo campo opzionale con un valore di default: i file vecchi non lo hanno, ma il programma nuovo può applicare il default e andare avanti. Rinominare un campo invece è rischioso: il file vecchio contiene full_name, il programma nuovo si aspetta name, e senza un «ponte» non si incontrano.
Per orientarsi meglio, ecco un'altra tabella (non una legge immutabile, ma una buona guida):
| Cambiamento del formato | Generalmente compatibile? | Cosa fare alla lettura |
|---|---|---|
| Aggiunto campo (opzionale) | Sì | value("x", default) o contains() + default |
| Aggiunto campo (obbligatorio) | No | O la migrazione aggiunge il campo, o la lettura fallisce con errore chiaro |
| Rinominato campo | No | Migrazione o fallback: leggere il nome vecchio, scrivere il nuovo |
| Cambiato il tipo di un campo ("42" → 42) | Di solito no | Serve una migrazione esplicita con controlli |
| Rimosso campo | A volte | O ignorare il campo vecchio, o la migrazione lo rimuove |
| Cambiato il significato di un campo (il più pericoloso) | Quasi mai | Incrementare la versione obbligatoriamente; la migrazione deve cambiare i dati, non solo le chiavi |
Caso particolarmente insidioso — «stesso tipo, significato diverso». Per esempio, price prima era in dollari e ora in centesimi. Il JSON sembra quasi identico, e l'utente poi si sorprende perché «il caffè costa 3000$». Questi cambiamenti richiedono quasi sempre una versione.
3. Migrazione prima di from_json: pipeline generale
Se provate a mettere tutto il supporto per tutte le versioni direttamente in from_json, diventerà rapidamente un mostro che fa tutto e che nessuno capisce. È molto più tranquillo tenere una pipeline chiara: leggere il JSON → determinare la versione → se necessario trasformare il JSON alla forma corrente → validare → solo allora chiamare j.get<Model>().
È come ristrutturare un appartamento: prima raddrizzi i muri (migrazione), poi controlli che i muri esistano (validazione), e solo dopo porti dentro i mobili (costruzione dello struct). Se porti i mobili prima, finirai per girare il guardaroba in tondo.
Ecco il flusso tipico di elaborazione:
flowchart TD
A[Lettura del testo/file] --> B[json::parse / in >> j]
B --> C["Determinare la versione: j.value('version', 1)"]
C --> D[Migrare a latest: v1->v2->...]
D --> E[Validazione della struttura nel formato latest]
E --> F["j.get<Model>() / from_json"]
F --> G[Lavoro del programma]
G --> H["Salvataggio in latest (con version)"]
Vantaggio principale: dopo la migrazione il resto del codice vive in una sola versione della realtà. Questo salva nervi e riduce il numero di if nel progetto.
4. Esempio pratico dei formati v1 e v2
Per non parlare in astratto, proseguiamo con il nostro mini-progetto didattico: il file users.json con un array di utenti. Evolveremo il formato come se il progetto vivesse e cambiasse. Nella prima versione (v1) l'utente aveva il campo full_name, e la versione nel file non era salvata (classico «poi la aggiungeremo»).
Esempio di dati v1 (nota: non c'è version, e il nome è 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"}]}
}
Nella seconda versione (v2) facciamo tre cambiamenti. Aggiungiamo version: 2 alla radice, rinominiamo full_name in name, e introduciamo il campo active (di default true). I file vecchi non conoscono active, quindi la migrazione deve aggiungerlo.
Ecco come appare 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}]}
}
Ora il nostro obiettivo è: il programma nuovo deve saper leggere sia v1 sia v2, ma internamente lavorare come se esistesse solo v2.
5. Migrazione a passi: v1 → v2
Momento importante: la migrazione non deve essere per forza una «enorme funzione da 300 righe». Al contrario, è meglio avere migrazioni piccole e sequenziali: migrate_v1_to_v2, migrate_v2_to_v3 e così via. Così quando tra un mese aggiungerete v3, non dovrete riscrivere tutto — aggiungerete semplicemente un altro step.
Iniziamo con una funzione che legge la versione. Se il campo manca, consideriamo che sia v1:
#include <nlohmann/json.hpp>
int read_version(const nlohmann::json& j) {
if (!j.is_object()) return 0; // 0 = "non è proprio quello"
return j.value("version", 1); // se manca version, consideriamo v1
}
Ora la migrazione v1→v2. Ci aspettiamo che alla radice ci sia users, e che ogni utente possa avere full_name. Creeremo name, aggiungeremo active, imposteremo version = 2, e rimuoveremo full_name per non portare avanti «reperti».
#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;
}
}
}
Nota su due aspetti. Primo: usiamo at("users"): la migrazione presuppone che la struttura sia almeno approssimativamente quella attesa. Se users manca, non è «versione 1» ma «dati corrotti», e conviene fallire in validazione. Secondo: modifichiamo il JSON «in place», perché è comodo per passi successivi.
Rimane da raccogliere la funzione migrate_to_latest. Sia 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;
}
Sì, qui appare un po' «diretto». Ma per un progetto didattico va benissimo: si vede chiaramente cosa succede. Nel codice reale aggiungereste gestione più diagnostica per v > kLatestVersion e forse usereste un while (v < kLatestVersion).
6. Lettura e salvataggio: leggiamo tutto, scriviamo solo latest
Quando uno sviluppatore inizia a fare migrazioni spesso cede alla tentazione: «Posso leggere v1 e v2 — quindi posso anche scrivere in v1 se l'input era v1». Di solito è una pessima idea: vi ritrovate con due formati «vivi» da mantenere. Regola più semplice: in scrittura usiamo sempre solo la versione corrente del formato.
È come aggiornare moduli in ufficio: si possono leggere i vecchi moduli, ma stampare di nuovo è meglio farlo con il template corrente, altrimenti non passerete mai al nuovo standard.
Mini-funzione per leggere da file (senza entrare nei dettagli di streaming):
#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;
}
E la scrittura (in forma leggibile con 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';
}
Adesso un pezzo di «colla» in main: letto → migrato → (qui si potrebbe validare e chiamare get<UserDb>()) → salvato in 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;
}
}
Qui si ottiene un effetto molto pratico: se l'utente fornisce un file vecchio v1, il programma lo «cura» fino a v2 e lo riscrive nel nuovo formato. Da quel momento i suoi dati vivono nel formato corrente.
7. Strategia di aggiornamento e politica di supporto delle versioni
Quando il progetto è piccolo sembra che «va bene, aggiungiamo il campo e basta». Quando il progetto cresce si scopre che i file JSON, i config e i salvataggi sono una piccola macchina del tempo: portano il passato nel presente. La strategia di aggiornamento è un accordo con se stessi (e con il team) su come cambiare il formato in modo che le modifiche siano prevedibili.
La regola più importante suona noiosa ma salva vite: la versione cambia solo quando cambia il modo di leggere/interpretare. Se avete cambiato solo l'ordine dei campi o aggiunto un campo opzionale ignorabile — forse non serve incrementare la versione. Ma se avete rinominato una chiave, reso un campo obbligatorio o cambiato il tipo — allora aumentate la versione.
È utile poi pensare in termini di «scala di migrazioni». Ogni migrazione è un gradino, non un salto di cinque gradini insieme. Così la lettura diventa: v1 → v2 → v3 → latest. E potete in qualsiasi momento eliminare il supporto per versioni «troppo vecchie» se concordate una policy (per esempio: «supportiamo le ultime 3 versioni del formato»).
Per visualizzare, immaginate una catena di stati:
stateDiagram-v2
[*] --> V1
V1 --> V2: migrate_v1_to_v2
V2 --> V3: migrate_v2_to_v3
V3 --> V4: migrate_v3_to_v4
V4 --> [*]
Un altro dettaglio sottile ma pratico: decidete cosa fare con le «future version». Se un file contiene version: 999, non è un «errore di parsing» né un «errore di struttura» — è una situazione in cui i dati sono più recenti del programma. In questo caso di solito è più onesto mostrare un errore chiaro tipo «Il file è stato creato con una versione più nuova del programma, aggiornare l'applicazione», invece di cercare di indovinare.
8. Errori tipici nel versionamento e nelle migrazioni
Errore n.1: la versione esiste, ma nessuno la controlla.
A volte lo sviluppatore aggiunge "version": 2, ma alla lettura continua a fare j.at("name") e si stupisce dei crash sui file vecchi. La versione in questo caso diventa un adesivo decorativo che non cambia il comportamento. Se la versione c'è, la lettura deve rispondervi: migrare o rifiutare.
Errore n.2: migrazione e validazione mescolate insieme.
Nella migrazione si iniziano a verificare i valori (age >= 0, name non vuoto) e contemporaneamente si cambiano le chiavi. Diventa poi poco chiaro dove «sistemo la struttura» e dove «controllo la correttezza». Molto più pulito: la migrazione porta il JSON alla forma corrente; la validazione controlla la forma corrente come contratto.
Errore n.3: la migrazione non è idempotente e rovina i dati a ogni esecuzione.
Per esempio, la migrazione aggiunge ogni volta un suffisso al nome o sovrascrive un campo senza controlli. Allora a ogni esecuzione il file peggiora sempre più. Una buona migrazione è fatta in modo che una riapplicazione non cambi nulla o almeno non peggiori la situazione: ci sono controlli con contains() e condizioni prudenti.
Errore n.4: cercare di determinare la versione «dalla presenza di campi».
Oggi decidete: «se c'è full_name, allora è v1». Domani aggiungete full_name come secondo nome in v3 — e l'euristica si rompe. Le euristiche quasi sempre diventano logica di tipo «indovina la melodia da due note», quindi meglio un campo esplicito version.
Errore n.5: scrivere versioni vecchie «perché è arrivato in input così».
Così vi seppellite nel supporto di più formati per sempre. Molto più semplice: leggere molti, scrivere uno (latest). Così il sistema si «sanifica» gradualmente ad ogni salvataggio.
GO TO FULL VERSION