← Tutti gli articoli

Come confrontare due file JSON: algoritmi e strumenti

Un diff di testo puro perde i riordini di chiave e il rumore degli spazi. Impara come funziona un diff JSON serio: diff di righe LCS, confronto semantico ad albero, normalizzazione delle chiavi e i compromessi di ogni approccio.

Due risposte JSON sembrano quasi identiche, ma qualcosa è cambiato fra i deploy e i tuoi test falliscono. Oppure stai facendo review di una pull request che tocca un grosso file di configurazione e devi sapere esattamente quali valori si sono mossi. Confrontare file JSON sembra semplice —— finché non ti rendi conto che un diff testuale ingenuo tratta il riordinamento delle chiavi come una modifica e che le strutture profondamente annidate richiedono un approccio più intelligente. Questo articolo spiega gli algoritmi che fanno funzionare per davvero un diff JSON.

Perché un diff testuale puro non basta

Il modo più semplice di confrontare due file è eseguire diff o confrontarli riga per riga. Per testo semplice funziona bene. Per JSON si rompe almeno in due modi.

Problema 1: ordine delle chiavi

Per specifica, gli oggetti JSON sono non ordinati. I due documenti seguenti sono semanticamente identici:

// Documento A
{ "name": "Ada", "plan": "pro", "active": true }

// Documento B
{ "active": true, "name": "Ada", "plan": "pro" }

Un diff testuale riga per riga segnalerebbe ogni riga come cambiata. Un diff JSON serio segnala zero differenze.

Problema 2: rumore di formattazione

Indentazione, spazi e se un array lungo è scritto su una riga o su più righe sono tutte cose irrilevanti per i dati. Un diff testuale tratta ogni differenza di spaziatura come una modifica.

La soluzione per entrambi i problemi è la stessa: parsa prima, poi fai il diff sulla struttura, non sul testo.

Passo 1 —— Parse e normalizzazione

Prima di qualsiasi confronto, entrambi i documenti vengono parsati in oggetti JavaScript con JSON.parse() e re-serializzati con chiavi ordinate e indentazione coerente:

function normalise(json) {
  const value = JSON.parse(json);
  return JSON.stringify(sortKeys(value), null, 2);
}

function sortKeys(value) {
  if (Array.isArray(value)) return value.map(sortKeys);
  if (value !== null && typeof value === 'object') {
    return Object.keys(value)
      .sort((a, b) => a.localeCompare(b))
      .reduce((acc, key) => {
        acc[key] = sortKeys(value[key]);
        return acc;
      }, {});
  }
  return value;
}

Dopo la normalizzazione, i documenti A e B qui sopra producono entrambi la stessa stringa:

{
  "active": true,
  "name": "Ada",
  "plan": "pro"
}

Ora un diff testuale fra le due stringhe normalizzate evidenzierà solo le vere differenze nei dati, non rumore di formato o di ordine delle chiavi.

Passo 2 —— Diff a livello di riga con LCS

Con il testo normalizzato, il problema diventa: date due sequenze di righe, trovare l'insieme minimo di modifiche (inserimenti e cancellazioni) che trasforma l'una nell'altra. È il classico problema della Longest Common Subsequence (LCS).

Che cos'è la LCS?

La LCS di due sequenze è la più lunga sottosequenza che appare in entrambe, nello stesso ordine, senza necessariamente essere contigua. Ad esempio:

Before: ["  active: true", "  name: Ada",   "  plan: pro" ]
After:  ["  active: true", "  name: Ada",   "  plan: team"]

LCS:    ["  active: true", "  name: Ada"]   // 2 righe in comune

// Risultato:
//   same:    "  active: true"
//   same:    "  name: Ada"
//   deleted: "  plan: pro"
//   added:   "  plan: team"

La LCS ci dà esattamente il diff che vogliamo: righe rimaste invariate, righe cancellate, righe aggiunte.

L'algoritmo DP

La LCS si risolve con programmazione dinamica. Per due array di lunghezza m e n si costruisce una tabella 2D in cui dp[i][j] è la lunghezza della LCS dei primi i elementi dell'array "prima" e dei primi j dell'array "dopo":

// Riempi la tabella DP (bottom-up)
for (let i = m - 1; i >= 0; i--) {
  for (let j = n - 1; j >= 0; j--) {
    dp[i][j] = before[i] === after[j]
      ? dp[i + 1][j + 1] + 1                    // le righe coincidono
      : Math.max(dp[i + 1][j], dp[i][j + 1]);   // prendi il ramo migliore
  }
}

// Ripercorri la tabella per ricostruire le operazioni del diff
let i = 0, j = 0;
while (i < m && j < n) {
  if (before[i] === after[j]) {
    ops.push({ type: 'same', line: before[i++] }); j++;
  } else if (dp[i + 1][j] >= dp[i][j + 1]) {
    ops.push({ type: 'del', line: before[i++] });
  } else {
    ops.push({ type: 'add', line: after[j++] });
  }
}

Complessità in tempo: O(m × n). Complessità in spazio: O(m × n). Per documenti JSON tipici è abbastanza veloce. Per documenti molto grandi (diciamo oltre 2000 righe ciascuno), la tabella può consumare parecchia memoria —— a quella scala vale la pena passare all'algoritmo di Myers, che gira in tempo O(n + d²) dove d è il numero di differenze.

Ottimizzazione memoria: typed array

Un array 2D di numeri in JavaScript ha un overhead per elemento sostanziale. Usare un Int32Array piatto riduce la memoria di circa 8× e migliora la località di cache:

const W  = n + 1;
const dp = new Int32Array((m + 1) * W); // buffer piatto

// Accedi a dp[i][j] come dp[i * W + j]
dp[i * W + j] = before[i] === after[j]
  ? dp[(i + 1) * W + (j + 1)] + 1
  : Math.max(dp[(i + 1) * W + j], dp[i * W + (j + 1)]);

Per un documento da 500 righe questo porta la tabella da ~2 MB di oggetti sull'heap a ~1 MB di memoria tipizzata contigua.

Passo 3 —— Accoppia modifiche adiacenti in righe „modificate“

L'output grezzo della LCS conosce solo cancellazioni e aggiunte. Ma in una vista diff affiancata è molto più leggibile mostrare una riga cancellata accoppiata con la riga aggiunta che l'ha sostituita —— una riga „modificata“:

// Op grezze dalla LCS:
del  "  plan: pro"
add  "  plan: team"

// Dopo l'accoppiamento:
modified  left: "  plan: pro"   right: "  plan: team"

L'algoritmo di accoppiamento raccoglie blocchi consecutivi di operazioni del e add e li chiude a cerniera. Le cancellazioni senza accoppiamento ricevono un placeholder vuoto a destra; le aggiunte senza accoppiamento un placeholder vuoto a sinistra:

// Raccogli un blocco consecutivo di del/add
const dels = [], adds = [];
while (ops[i].type !== 'same') {
  if (ops[i].type === 'del') dels.push(ops[i].line);
  else                        adds.push(ops[i].line);
  i++;
}

// Chiudi a cerniera in coppie modificate
const pairs = Math.min(dels.length, adds.length);
for (let k = 0; k < pairs; k++)
  rows.push({ type: 'modified', left: dels[k], right: adds[k] });

// Cancellazioni rimanenti senza coppia
for (let k = pairs; k < dels.length; k++)
  rows.push({ type: 'deleted', left: dels[k] });

// Aggiunte rimanenti senza coppia
for (let k = pairs; k < adds.length; k++)
  rows.push({ type: 'added', right: adds[k] });

Passo 4 —— Diff semantico per i conteggi del riepilogo

Il diff per riga ti dice quale testo è cambiato. Un diff semantico ti dice quali campi sono cambiati e come —— utile per un riepilogo del tipo „3 aggiunti, 1 rimosso, 2 modificati“.

Il diff semantico percorre ricorsivamente entrambi gli oggetti parsati in parallelo:

function diffValue(key, path, before, after) {
  if (before === undefined) return { status: 'added',   after  };
  if (after  === undefined) return { status: 'removed', before };

  if (isObject(before) && isObject(after)) {
    const keys     = union(Object.keys(before), Object.keys(after)).sort();
    const children = keys.map(k =>
      diffValue(k, path + '.' + k, before[k], after[k])
    );
    return {
      status: children.every(c => c.status === 'unchanged') ? 'unchanged' : 'changed',
      children,
    };
  }

  if (Array.isArray(before) && Array.isArray(after)) {
    const len      = Math.max(before.length, after.length);
    const children = Array.from({ length: len }, (_, i) =>
      diffValue(i, path + '[' + i + ']', before[i], after[i])
    );
    return {
      status: children.every(c => c.status === 'unchanged') ? 'unchanged' : 'changed',
      children,
    };
  }

  return deepEqual(before, after)
    ? { status: 'unchanged', before, after }
    : { status: 'changed',   before, after };
}

Percorrere l'albero risultante e contare i nodi foglia per stato dà i numeri del riepilogo. A differenza del diff per riga, il diff semantico è per costruzione agnostico all'ordine delle chiavi —— confronta valori allo stesso percorso di chiave, indipendentemente dall'ordine in cui appaiono nel JSON.

Mettere tutto insieme

Uno strumento completo di diff JSON concatena questi passi:

function jsonDiff(leftText, rightText) {
  // 1. Parse
  const leftVal  = JSON.parse(leftText);
  const rightVal = JSON.parse(rightText);

  // 2. Diff semantico → conteggi del riepilogo
  const summary = summarise(diffValue('root', '
#x27;, leftVal, rightVal)); // 3. Normalizza (ordina chiavi, indentazione coerente) const leftNorm = JSON.stringify(sortKeys(leftVal), null, 2); const rightNorm = JSON.stringify(sortKeys(rightVal), null, 2); // 4. Diff per riga con LCS const rows = buildLineDiff(leftNorm, rightNorm); return { summary, rows }; }

Deep-Equal vs diff strutturale

Una scorciatoia comune è confrontare due documenti JSON con deepEqual (il assert.deepStrictEqual di Node, isEqual di Lodash) —— veloce, ma solo booleano. Ti dice che i documenti differiscono; non ti dice dovecome.

Un diff strutturale percorre entrambi gli alberi e produce un report (conteggi, percorsi, valori prima/dopo) e, opzionalmente, un JSON Patch. Usa deepEqual quando la risposta è sì/no —— nei test, nell'invalidazione di cache. Usa un diff strutturale quando una persona deve agire sulle differenze.

Generare un JSON Patch dal diff

Una volta ottenuto l'albero del diff semantico, la stessa visita che conta i nodi aggiunti/rimossi/cambiati può emettere un JSON Patch (RFC 6902) —— un elenco portabile di operazioni add / remove / replace che trasforma il documento „prima“ in quello „dopo“. Ecco come si prende un diff e si invia come body di una richiesta HTTP PATCH, o lo si applica più tardi altrove.

function toJsonPatch(node, pointer = '') {
  const ops = [];
  if (node.status === 'added')   ops.push({ op: 'add',     path: pointer, value: node.after });
  if (node.status === 'removed') ops.push({ op: 'remove',  path: pointer });
  if (node.status === 'changed' && !node.children) {
    ops.push({ op: 'replace', path: pointer, value: node.after });
  }
  for (const child of node.children ?? []) {
    const seg = String(child.key).replace(/~/g, '~0').replace(/\//g, '~1');
    ops.push(...toJsonPatch(child, pointer + '/' + seg));
  }
  return ops;
}

I segmenti di percorso seguono le regole di JSON Pointer (RFC 6901) —— attenzione agli escape ~0 / ~1 per ~ e / nelle chiavi. Librerie come fast-json-patch producono la stessa cosa se preferisci non scriverlo a mano. Quando il patch viene inviato via HTTP usa il content type application/json-patch+json; per l'alternativa più semplice „overlay“ in cui null significa cancellare, vedi JSON Patch vs JSON Merge Patch.

Casi limite da conoscere

  • Riordinamento di elementi di array —— il diff semantico confronta gli array per posizione ([0] vs [0]), quindi se un array è ordinato in modo diverso fra versioni, ogni elemento può apparire „cambiato“ anche se i dati sono uguali. Gestirlo bene richiede una LCS a livello di array, il che aumenta parecchio la complessità.
  • Documenti molto grandi —— la tabella LCS O(m × n) può esaurire la memoria per documenti con decine di migliaia di righe. Euristica pratica: se m × n supera una soglia (diciamo 2 milioni), ripiega su mostrare tutte le righe come cambiate invece di calcolare il diff completo.
  • Precisione numerica —— JSON.parse() converte tutti i numeri in double IEEE 754. Gli interi molto grandi (oltre 2⁵³) perdono precisione in silenzio, quindi due documenti che differiscono solo nelle ultime cifre di un grande intero possono confrontarsi come uguali dopo il parse.

Domande frequenti

Come confronto due file JSON?

Parsa entrambi, normalizzali (chiavi ordinate, indentazione coerente) e poi lancia un diff strutturale invece di un diff testuale puro —— oppure incolla entrambi in JSON Diff, che lo fa e mostra una vista affiancata a colori.

Perché un diff testuale puro non funziona per JSON?

Perché gli oggetti JSON sono non ordinati e gli spazi sono insignificanti. Un diff testuale segna chiavi riordinate e riformattazioni come cambiamenti; un diff consapevole del JSON ignora entrambi e segnala solo le vere differenze nei dati.

Che cos'è un diff JSON semantico?

Uno che percorre le due strutture parsate e confronta i valori allo stesso percorso di chiave indipendentemente dall'ordine, producendo conteggi come „3 aggiunti, 1 rimosso, 2 cambiati“. Per costruzione agnostico all'ordine delle chiavi.

Confrontare JSON può perdere precisione numerica?

Sì —— JSON.parse() converte i numeri in double IEEE 754, quindi gli interi oltre 2⁵³ possono confrontarsi come uguali anche quando le ultime cifre differiscono. Le regole di canonicalizzazione di RFC 8785 affrontano problemi correlati.

Prova lo strumento JSON Diff

JSON Diff su fixjson.org implementa tutto ciò descritto sopra: parsa entrambi i documenti, normalizza con chiavi ordinate, esegue il diff per riga LCS e mostra una vista affiancata con aggiunte, cancellazioni e modifiche codificate a colori —— più una riga di riepilogo che mostra il numero di campi aggiunti, rimossi, cambiati e invariati. Supporta anche documenti YAML. Tutto gira nel tuo browser; nessun dato viene inviato a un server.