CodeGym /Corsi /C++ SELF /Versionamento e migrazioni JSON

Versionamento e migrazioni JSON

C++ SELF
Livello 65, Lezione 4
Disponibile

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
{"version": 2, ...}
Semplice, esplicito, facile da migrare Occorre concordare che «la radice è un oggetto»
All'interno di ogni oggetto
{"user": {..., "version": 2}}
Talvolta comodo per dati in streaming Di solito ridondante
Nel nome del file
users_v2.json
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.

1
Compito
C++ SELF, livello 65, lezione 4
Bloccato
Versione del formato
Versione del formato
1
Compito
C++ SELF, livello 65, lezione 4
Bloccato
Aggiornamento della versione
Aggiornamento della versione
1
Compito
C++ SELF, livello 65, lezione 4
Bloccato
Migrazione degli utenti
Migrazione degli utenti
1
Compito
C++ SELF, livello 65, lezione 4
Bloccato
Scala delle versioni
Scala delle versioni
1
Sondaggio/quiz
Serializzazione JSON, livello 65, lezione 4
Non disponibile
Serializzazione JSON
Serializzazione JSON
Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION