Confronto XML
Confronta due documenti. Nessuno esce dal browser.
Tutto viene eseguito in questa scheda. Nulla di ciò che incolli viene caricato, registrato o inviato da qualche parte. Apri il pannello di rete e verifica.
Incolla un documento a sinistra e l’altro a destra e le differenze compaiono riga per riga, con le aggiunte numerate sul documento di destra e le rimozioni su quello di sinistra. I lunghi tratti immutati si comprimono in un segnaposto, così le modifiche restano trovabili. Entrambi i documenti restano in questa scheda, cosa che non vale per la maggior parte degli strumenti ben posizionati su questa ricerca.
Il confronto è strutturale e non testuale. I due documenti vengono prima riformattati, con indentazione a due spazi e attributi ordinati alfabeticamente, e il diff gira sui risultati. Due documenti che differiscono solo per l’indentazione, o solo per l’ordine degli attributi, risultano identici, e il pannello dei risultati lo dice con queste parole.
È ciò che vuoi quando confronti un payload atteso con uno reale, ed è per questo che lo strumento esiste. È l’impostazione sbagliata quando gli spazi portano significato, e le sezioni qui sotto dicono esattamente dove passa quel confine.
Che cosa smette di essere una differenza
Entrambi i lati passano per lo stesso formattatore prima di qualsiasi confronto. XML non considera significativi né l’ordine degli attributi né l’indentazione fuori dal contenuto misto, quindi un diff che segnali l’uno o l’altro descrive il serializzatore e non i dati. Per questo un documento scritto da Jackson e gli stessi dati scritti da un XmlWriter di .NET si possono confrontare direttamente.
- Indentazione, a capo e posizione dei tag sulla riga.
- Ordine degli attributi: entrambi i lati vengono ordinati alfabeticamente per nome.
- Sintassi degli elementi vuoti: <status></status> e <status> </status> diventano entrambi <status/>.
- Spazio iniziale e finale in un elemento di solo testo, quindi <name> Priya </name> corrisponde a <name>Priya</name>.
- L’ordine di version, encoding e standalone nella dichiarazione.
Che cosa resta una differenza, di proposito
Il formattatore non cambia nulla che non possa cambiare in sicurezza. I valori degli attributi sono emessi esattamente come scritti, virgoletta originale compresa: rifare l’escape trasformerebbe & in &amp;, e decodificare è ugualmente impossibile, perché un valore che richiama un’entità dichiarata nella DTD come &companyName; ha bisogno di quella DTD.
Il contenuto misto, cioè un elemento che contiene sia testo sia elementi figli, viene riprodotto byte per byte, perché lì lo spazio fra testo e markup fa parte dei dati. È l’unico punto in cui l’indentazione compare davvero nel diff, e lì compare a ragione.
- Lo stile delle virgolette: id='A-991' contro id="A-991". Lo stesso vale per come è scritto un riferimento, & contro &.
- CDATA contro testo con escape: <note><![CDATA[a<b]]></note> e <note>a<b</note> consegnano caratteri identici ma vengono segnalati come diversi.
- I commenti, che vengono mantenuti invece che rimossi: in un file di configurazione un commento cambiato è spesso proprio la modifica che cercavi.
- I prefissi di namespace. Riassociare soap: a s: con la stessa URI è semanticamente identico e compare comunque come differenza sull’intero documento.
Quando normalizzare è l’impostazione sbagliata
Alcuni documenti sono byte, non struttura. La firma digitale XML calcola l’impronta di una sequenza di byte canonica, quindi qualunque modifica, compresa l’indentazione che questo strumento normalizza via, rompe la firma. Un diff che dichiara identiche due asserzioni firmate ti dice che il loro contenuto coincide, non che entrambe si verifichino ancora.
L’altro caso è un documento che ha dichiarato xml:space="preserve", o che porta testo preformattato. I sottoalberi di contenuto misto sono al sicuro, ma un elemento di solo testo il cui spazio iniziale conta non lo è: quello spazio viene tagliato. Usa un diff di testo semplice quando il tuo documento ci fa affidamento.
Atteso contro reale
Il caso per cui esiste: un test di integrazione fallisce, hai la fixture che si aspettava e il payload che il servizio ha davvero restituito, e sono formattati in modo diverso perché uno è stato scritto a mano e l’altro è arrivato minificato dalla rete. Un diff di testo semplice fra i due è inutilizzabile; normalizzarli entrambi prima lo riduce alle tre righe che sono cambiate davvero.
Entrambi i documenti devono prima essere ben formati. Se uno dei due non si analizza, lo strumento si ferma e lo dice, e gli errori del documento di sinistra vengono marcati nell’editor con riga e colonna: una risposta troncata viene quindi diagnosticata sul posto invece di sembrare un cambiamento di struttura.
È un diff di righe, non di albero
Il confronto è un diff sulla più lunga sottosequenza comune di righe, lo stesso algoritmo che usa git. Spostare un elemento dentro il suo genitore appare quindi come una rimozione in un punto e un’aggiunta in un altro, non come uno spostamento. Riordinare dei fratelli compare come modifica anche dove lo schema considera l’ordine irrilevante, perché in XML l’ordine degli elementi è significativo per impostazione predefinita. I node matcher di XMLUnit, nel codice qui sotto, sono la risposta quando questo conta.
C’è un tetto. Oltre le 3.000 righe dopo la formattazione lo strumento rifiuta e ti chiede di confrontare una sezione per volta: la tabella della più lunga sottosequenza comune è quadratica, e due documenti grandi bloccherebbero la scheda invece di essere soltanto lenti.
Farlo da codice
La stessa idea in una suite di test o in uno step di build: canonicalizza entrambi i lati, poi confronta. Ognuno di questi esempi disattiva la risoluzione delle entità esterne, perché di solito li punti su un payload che non hai prodotto tu.
// Browsers do not resolve external entities, so DOMParser is safe here. It
// does not throw on malformed input: it returns a document containing a
// <parsererror> element, which is why so much code accepts broken XML.
function parse(source, label) {
const doc = new DOMParser().parseFromString(source, 'application/xml');
const err = doc.querySelector('parsererror');
if (err) throw new Error(label + ': ' + err.textContent.trim());
return doc;
}
// Canonical text: two spaces per level, attributes sorted by name, empty
// elements written one way. This is what makes the comparison structural.
function canonicalise(node, depth, out) {
const pad = ' '.repeat(depth);
if (node.nodeType === Node.TEXT_NODE) {
const t = node.data.trim();
if (t) out.push(pad + t);
return out;
}
if (node.nodeType !== Node.ELEMENT_NODE) return out;
const attrs = Array.from(node.attributes)
.sort((a, b) => a.name.localeCompare(b.name))
.map((a) => ' ' + a.name + '="' + escapeAttr(a.value) + '"')
.join('');
const kids = Array.from(node.childNodes).filter(
(c) =>
c.nodeType === Node.ELEMENT_NODE ||
(c.nodeType === Node.TEXT_NODE && c.data.trim() !== ''),
);
if (kids.length === 0) {
out.push(pad + '<' + node.nodeName + attrs + '/>');
return out;
}
out.push(pad + '<' + node.nodeName + attrs + '>');
for (const c of kids) canonicalise(c, depth + 1, out);
out.push(pad + '</' + node.nodeName + '>');
return out;
}
function escapeAttr(s) {
return s.replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"');
}
const left = canonicalise(parse(expected, 'expected').documentElement, 0, []);
const right = canonicalise(parse(actual, 'actual').documentElement, 0, []);
// Equality is now a string compare. For a rendered diff, feed the two arrays
// to a line differ; this page runs an LCS over exactly these lines.
console.log(left.join('\n') === right.join('\n') ? 'identical' : 'different');from lxml import etree
import difflib
# resolve_entities=False and no_network=True are the two that matter: without
# them a payload from an untrusted source can read local files (XXE).
PARSER = etree.XMLParser(resolve_entities=False, no_network=True,
load_dtd=False, huge_tree=False)
def canonical_lines(path: str) -> list[str]:
with open(path, 'rb') as fh:
doc = etree.parse(fh, PARSER)
# C14N 2.0 sorts attributes, normalises namespace declarations and writes
# empty elements one way. strip_text drops insignificant whitespace, which
# is what makes indentation irrelevant to the comparison. Note that it
# strips whitespace inside mixed content too, which is lossy.
canon = etree.canonicalize(etree.tostring(doc), strip_text=True)
reparsed = etree.fromstring(canon.encode(), PARSER)
etree.indent(reparsed, space=' ') # lxml 4.5 and later
return etree.tostring(reparsed, encoding='unicode').splitlines(keepends=True)
diff = difflib.unified_diff(
canonical_lines('expected.xml'),
canonical_lines('actual.xml'),
fromfile='expected.xml',
tofile='actual.xml',
)
for line in diff:
print(line, end='')// XMLUnit 2 compares trees, not lines, so it can tell you "attribute 'total'
// differs at /order[1]/total[1]" rather than showing two lines and leaving
// you to spot it.
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;
import org.xmlunit.builder.DiffBuilder;
import org.xmlunit.builder.Input;
import org.xmlunit.diff.DefaultNodeMatcher;
import org.xmlunit.diff.Diff;
import org.xmlunit.diff.ElementSelectors;
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
dbf.setXIncludeAware(false);
dbf.setNamespaceAware(true);
Diff diff = DiffBuilder.compare(Input.fromFile("expected.xml"))
.withTest(Input.fromFile("actual.xml"))
.withDocumentBuilderFactory(dbf)
.ignoreComments()
.ignoreWhitespace() // drops whitespace-only text nodes
.normalizeWhitespace() // collapses runs inside the text that remains
// byNameAndText pairs repeated elements up by content rather than by
// position, so a reordered list is not reported as every row changing.
.withNodeMatcher(new DefaultNodeMatcher(ElementSelectors.byNameAndText))
.checkForSimilar() // "similar" ignores attribute order and prefixes
.build();
if (diff.hasDifferences()) {
diff.getDifferences().forEach(d -> System.out.println(d));
System.exit(1);
}using System.IO;
using System.Linq;
using System.Xml;
using System.Xml.Linq;
// Load() without LoadOptions.PreserveWhitespace drops insignificant
// whitespace, so indentation never reaches the comparison. Prohibiting DTDs
// and nulling the resolver closes XXE.
static XDocument LoadSafely(string path)
{
var settings = new XmlReaderSettings
{
DtdProcessing = DtdProcessing.Prohibit,
XmlResolver = null,
};
using var reader = XmlReader.Create(path, settings);
return XDocument.Load(reader);
}
var expected = LoadSafely("expected.xml");
var actual = LoadSafely("actual.xml");
// XNode.DeepEquals already ignores attribute order. Element order is
// significant to it, as it is to XML itself.
if (XNode.DeepEquals(expected, actual))
{
Console.WriteLine("Identical.");
return;
}
// Not equal: write both out normalised so a text diff is readable.
static void SortAttributes(XElement e)
{
var sorted = e.Attributes()
.OrderBy(a => a.Name.NamespaceName)
.ThenBy(a => a.Name.LocalName)
.ToList();
e.RemoveAttributes();
e.Add(sorted);
foreach (var child in e.Elements()) SortAttributes(child);
}
SortAttributes(expected.Root!);
SortAttributes(actual.Root!);
File.WriteAllText("expected.norm.xml", expected.ToString());
File.WriteAllText("actual.norm.xml", actual.ToString());
Console.Error.WriteLine("Documents differ. Diff the two .norm.xml files.");# xmllint ships with libxml2 and is almost certainly already installed.
# --c14n implements Canonical XML 1.0: attributes sorted, empty elements
# expanded to a start/end pair, namespace declarations normalised.
# --nonet stops it fetching a DTD the document points at.
xmllint --nonet --c14n expected.xml > /tmp/a.c14n
xmllint --nonet --c14n actual.xml > /tmp/b.c14n
# C14N does not re-indent, so pretty-print afterwards or the whole document
# arrives on one line and the diff is useless. --format leaves an element
# alone when it contains text of its own, so mixed content is not reflowed.
xmllint --nonet --format /tmp/a.c14n > /tmp/a.xml
xmllint --nonet --format /tmp/b.c14n > /tmp/b.xml
diff -u /tmp/a.xml /tmp/b.xml
# Exit status 1 from diff means "they differ" and is not an error. Guard for
# it explicitly in CI, or set -e will kill the job on a successful comparison.
# C14N converts to UTF-8 and drops the XML declaration, so this will not tell
# you the two files declared different encodings. Check that with head -c 100.Vale la pena nominare la distinzione: canonicalizzazione più diff di righe dà qualcosa che un essere umano può leggere, e un confronto ad albero come quello di XMLUnit dà a un test qualcosa su cui asserire, con un messaggio di errore utile.
Domande frequenti
I miei due documenti vengono caricati per essere confrontati?
No. I due editor, il formattatore e l’algoritmo di diff sono JavaScript che gira in questa scheda, e non esiste alcun componente lato server a cui inviare qualcosa.
È per questo che lo strumento esiste. Il payload reale è traffico di produzione vero: nomi di clienti veri, importi d’ordine veri, spesso un token in un header SOAP. Diversi strumenti ben posizionati su questa ricerca prendono entrambi i file tramite upload. Apri il pannello di rete mentre incolli e resterà vuoto.
Entrambi i documenti sono conservati nel localStorage di questo browser così un ricaricamento non perde il lavoro. Non lascia mai la tua macchina, e Svuota li rimuove entrambi all’istante.
Perché dice che due documenti sono identici quando chiaramente non lo sono?
Perché confronta la struttura, non i byte. Entrambi i lati vengono prima riformattati alla stessa indentazione con gli attributi ordinati, quindi un documento minificato e gli stessi dati formattati a quattro spazi escono uguali, così come <order id="A-991" total="64.85"> e <order total="64.85" id="A-991">.
XML non considera significativi né l’ordine degli attributi né l’indentazione fuori dal contenuto misto, quindi un diff che segnali l’uno o l’altro descrive il serializzatore e non i dati. Se ti serve un confronto a livello di byte, e per un documento firmato ti serve, usa un diff di testo semplice. Il banner sopra il risultato dichiara sempre che il confronto è stato fatto dopo aver normalizzato indentazione e ordine degli attributi: non accade mai in silenzio.
I due documenti devono essere XML valido?
Devono essere ben formati, che non è la stessa cosa di validi. Ben formato significa che la sintassi è corretta: tag chiusi e annidati correttamente, un elemento radice, caratteri speciali con escape, valori degli attributi fra virgolette. Valido significa in più corrispondere a uno schema, e qui nessuno schema è coinvolto.
Il confronto non può girare su un documento che non si analizza, perché non c’è struttura da normalizzare. Se uno dei due lati fallisce, lo strumento si ferma e lo dice, invece di ripiegare su un diff testuale e darti un risultato che sembra significativo. Già questo intercetta una classe reale di bug: una risposta troncata compare come errore di analisi con riga e colonna anziché come un muro di rosso.
Può confrontare documenti che usano prefissi di namespace diversi?
Li segnalerà come diversi, ed è un limite reale. soap:Envelope e s:Envelope associati alla stessa URI sono identici per qualunque consumatore consapevole dei namespace, ma il prefisso fa parte del nome dell’elemento così com’è scritto, e il formattatore non riscrive i prefissi.
Riscrivere non è sicuro in generale: un prefisso può comparire dentro valori di attributo, in un xsi:type, in un’espressione XPath di un foglio di stile, in un QName di un WSDL, dove un formattatore non lo vede. Quando sono proprio le differenze di prefisso che devi superare, usa un confronto canonicalizzante: l’esempio XMLUnit qui sopra con checkForSimilar se ne occupa, così come C14N negli esempi shell e Python.
Quanto possono essere grandi i due documenti?
Fino a 3.000 righe ciascuno, misurate dopo la formattazione e non come le hai incollate: un documento minificato che si espande in 8.000 righe supera quindi il limite anche se è arrivato su una riga sola.
Il tetto è voluto. Un diff sulla più lunga sottosequenza comune costruisce una tabella proporzionale al prodotto dei due conteggi di righe, quindi due documenti da 20.000 righe richiederebbero centinaia di milioni di celle e bloccherebbero la scheda invece di essere soltanto lenti. Per file di quelle dimensioni, git diff sull’output di xmllint --format, o la ricetta shell qui sopra, fa ciò che nessuna scheda di browser dovrebbe tentare.