XML-Vergleich

Zwei Dokumente vergleichen. Keines verlässt den Browser.

Original
Geändert
WartetFügen Sie ein Dokument ein. Die Prüfung läuft während der Eingabe.

Alles läuft in diesem Tab. Nichts, was Sie einfügen, wird hochgeladen, protokolliert oder irgendwohin gesendet. Öffnen Sie Ihr Netzwerkpanel und prüfen Sie es.

Fügen Sie links das eine Dokument ein und rechts das andere, und die Unterschiede erscheinen Zeile für Zeile: Hinzufügungen nummeriert nach dem rechten Dokument, Entfernungen nach dem linken. Lange unveränderte Strecken klappen zu einer Marke zusammen, damit die Änderungen auffindbar bleiben. Beide Dokumente bleiben in diesem Tab – was auf die meisten Werkzeuge, die für diese Suche ranken, nicht zutrifft.

Der Vergleich ist strukturell, nicht textuell. Beide Dokumente werden zuerst neu formatiert, auf zwei Leerzeichen Einrückung mit alphabetisch sortierten Attributen, und der Diff läuft über die Ergebnisse. Zwei Dokumente, die sich nur in der Einrückung oder nur in der Attributreihenfolge unterscheiden, kommen als identisch heraus, und das Ergebnisfeld sagt das mit genau diesen Worten.

Das ist, was Sie wollen, wenn Sie eine erwartete Nutzlast mit einer tatsächlichen vergleichen – dafür ist dieses Werkzeug gebaut. Es ist die falsche Voreinstellung, wenn Leerraum Bedeutung trägt, und die Abschnitte unten sagen genau, wo diese Grenze verläuft.

Was aufhört, ein Unterschied zu sein

Beide Seiten gehen durch denselben Formatierer, bevor irgendetwas verglichen wird. XML hält weder die Attributreihenfolge noch die Einrückung außerhalb gemischten Inhalts für bedeutungstragend, ein Diff, der eines von beiden meldet, beschreibt also den Serialisierer und nicht die Daten. Deshalb lassen sich ein von Jackson geschriebenes Dokument und dieselben Daten aus einem .NET-XmlWriter direkt vergleichen.

  • Einrückung, Zeilenumbrüche und wo die Tags in der Zeile stehen.
  • Attributreihenfolge: Beide Seiten werden alphabetisch nach Namen sortiert.
  • Schreibweise leerer Elemente: <status></status> und <status> </status> werden beide zu <status/>.
  • Führender und abschließender Leerraum in einem reinen Textelement, <name> Priya </name> stimmt also mit <name>Priya</name> überein.
  • Die Reihenfolge von version, encoding und standalone in der Deklaration.

Was absichtlich ein Unterschied bleibt

Der Formatierer ändert nichts, was er nicht sicher ändern kann. Attributwerte werden exakt so ausgegeben, wie sie geschrieben sind, samt ursprünglichem Anführungszeichen: Erneutes Maskieren würde &amp; zu &amp;amp; machen, und Dekodieren ist ebenfalls unmöglich, denn ein Wert, der eine in der DTD deklarierte Entity wie &companyName; referenziert, braucht diese DTD.

Gemischter Inhalt, also ein Element mit Text und Kindelementen zugleich, wird Byte für Byte reproduziert, weil Leerraum zwischen Text und Markup dort Teil der Daten ist. Das ist die einzige Stelle, an der Einrückung wirklich im Diff auftaucht, und dort taucht sie zu Recht auf.

  • Der Anführungsstil: id='A-991' gegen id="A-991". Ebenso die Schreibweise einer Referenz, &amp; gegen &#38;.
  • CDATA gegen maskierten Text: <note><![CDATA[a<b]]></note> und <note>a&lt;b</note> liefern identische Zeichen, werden aber als verschieden gemeldet.
  • Kommentare, die erhalten statt entfernt werden: In einer Konfigurationsdatei ist ein geänderter Kommentar oft genau die Änderung, die Sie suchten.
  • Namensraum-Präfixe. soap: mit derselben URI auf s: umzubinden ist semantisch identisch und erscheint dennoch als Unterschied über das ganze Dokument.

Wann Normalisieren die falsche Voreinstellung ist

Manche Dokumente sind Bytes und keine Struktur. Die digitale XML-Signatur bildet die Prüfsumme über eine kanonische Bytefolge, jede Änderung – auch die Einrückung, die dieses Werkzeug wegnormalisiert – bricht die Signatur. Ein Diff, der zwei signierte Assertions für identisch erklärt, sagt Ihnen, dass ihr Inhalt übereinstimmt, nicht dass beide noch verifizieren.

Der andere Fall ist ein Dokument, das xml:space="preserve" deklariert hat oder vorformatierten Text trägt. Teilbäume mit gemischtem Inhalt sind sicher, ein reines Textelement, bei dem der führende Leerraum zählt, ist es nicht: Dieser Leerraum wird gekürzt. Nehmen Sie einen reinen Textvergleich, wenn Ihr Dokument davon abhängt.

Erwartet gegen tatsächlich

Der Fall, für den es das gibt: Ein Integrationstest scheitert, Sie haben die erwartete Fixture und die Nutzlast, die der Dienst wirklich zurückgab, und beide sind unterschiedlich formatiert, weil die eine von Hand geschrieben wurde und die andere minimiert über die Leitung kam. Ein reiner Textvergleich davon ist unbrauchbar; beide zuerst zu normalisieren schrumpft ihn auf die drei Zeilen, die sich wirklich geändert haben.

Beide Dokumente müssen zuerst wohlgeformt sein. Scheitert eines am Parsen, hält das Werkzeug an und sagt es, und die Fehler im linken Dokument werden im Editor mit Zeile und Spalte markiert – eine abgeschnittene Antwort wird also an Ort und Stelle diagnostiziert, statt wie eine strukturelle Änderung auszusehen.

Es ist ein Zeilen-Diff, kein Baum-Diff

Der Vergleich ist ein Diff über die längste gemeinsame Teilfolge von Zeilen, derselbe Algorithmus, den git benutzt. Ein Element innerhalb seines Elternteils zu verschieben erscheint deshalb als Entfernung an einer Stelle und als Hinzufügung an einer anderen, nicht als Verschiebung. Geschwister umzuordnen zeigt sich als Änderung, selbst wo das Schema die Reihenfolge für unbedeutend hält, denn die Elementreihenfolge in XML ist standardmäßig bedeutungstragend. Die Node Matcher von XMLUnit im Code unten sind die Antwort, wenn das zählt.

Es gibt eine Obergrenze. Über 3.000 Zeilen nach dem Formatieren lehnt das Werkzeug ab und bittet Sie, abschnittsweise zu vergleichen: Die LCS-Tabelle wächst quadratisch, und zwei große Dokumente würden den Tab blockieren statt bloß langsam zu sein.

Dasselbe in Code

Dieselbe Idee in einer Testsuite oder einem Build-Schritt: beide Seiten kanonisieren, dann vergleichen. Jedes dieser Beispiele schaltet die Auflösung externer Entities ab, denn Sie richten sie meist auf eine Nutzlast, die Sie nicht selbst erzeugt haben.

// 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, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
}

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.

Die Trennung lohnt es, benannt zu werden: Kanonisierung plus Zeilen-Diff gibt Ihnen etwas, das ein Mensch lesen kann, und ein Baumvergleich wie XMLUnit gibt einem Test etwas, worauf er assertieren kann – mit einer brauchbaren Fehlermeldung.

Häufige Fragen

Werden meine beiden Dokumente zum Vergleichen hochgeladen?

Nein. Beide Editoren, der Formatierer und der Diff-Algorithmus sind JavaScript, das in diesem Tab läuft, und es gibt keine serverseitige Komponente, an die sich etwas senden ließe.

Deshalb gibt es dieses Werkzeug. Die tatsächliche Nutzlast ist echter Produktionsverkehr: echte Kundennamen, echte Bestellwerte, oft ein Bearer-Token in einem SOAP-Header. Mehrere Werkzeuge, die für diese Suche ranken, nehmen beide Dateien per Upload. Öffnen Sie beim Einfügen Ihr Netzwerkpanel – es bleibt leer.

Beide Dokumente liegen im localStorage dieses Browsers, damit ein Neuladen Ihre Arbeit nicht vernichtet. Das verlässt Ihren Rechner nie, und „Leeren“ entfernt beide sofort.

Warum sagt es, zwei Dokumente seien identisch, obwohl sie es offensichtlich nicht sind?

Weil es Struktur vergleicht, nicht Bytes. Beide Seiten werden zuerst auf dieselbe Einrückung neu formatiert und die Attribute sortiert, ein minimiertes Dokument und dieselben Daten mit vier Leerzeichen kommen also gleich heraus, ebenso <order id="A-991" total="64.85"> und <order total="64.85" id="A-991">.

XML hält weder die Attributreihenfolge noch die Einrückung außerhalb gemischten Inhalts für bedeutungstragend, ein Diff, der eines davon meldet, beschreibt also den Serialisierer und nicht die Daten. Brauchen Sie einen Vergleich auf Byte-Ebene – und bei einem signierten Dokument brauchen Sie ihn –, nehmen Sie einen reinen Textvergleich. Das Banner über dem Ergebnis nennt immer, dass nach Normalisierung von Einrückung und Attributreihenfolge verglichen wurde; still geschieht das also nie.

Müssen beide Dokumente gültiges XML sein?

Sie müssen wohlgeformt sein, was nicht dasselbe ist wie gültig. Wohlgeformt heißt, die Syntax stimmt: geschlossene und korrekt verschachtelte Tags, ein Wurzelelement, maskierte Sonderzeichen, Attributwerte in Anführungszeichen. Gültig heißt zusätzlich, einem Schema zu entsprechen, und hier ist kein Schema beteiligt.

Der Vergleich kann auf einem Dokument, das sich nicht parsen lässt, nicht laufen, denn es gibt keine Struktur zu normalisieren. Scheitert eine Seite, hält das Werkzeug an und sagt es, statt auf einen Textvergleich auszuweichen und Ihnen ein Ergebnis zu geben, das bedeutsam aussieht. Das fängt für sich genommen schon eine echte Fehlerklasse: Eine abgeschnittene Antwort erscheint als Parse-Fehler mit Zeile und Spalte statt als Wand aus Rot.

Kann es Dokumente mit unterschiedlichen Namensraum-Präfixen vergleichen?

Es meldet sie als verschieden, und das ist eine echte Einschränkung. soap:Envelope und s:Envelope, an dieselbe URI gebunden, sind für jeden namensraumbewussten Konsumenten identisch, aber das Präfix gehört zum Elementnamen, wie er geschrieben steht, und der Formatierer schreibt Präfixe nicht um.

Umschreiben ist im Allgemeinen nicht sicher: Ein Präfix kann in Attributwerten stehen, in einem xsi:type, in einem XPath-Ausdruck in einem Stylesheet, in einem QName in einer WSDL – dort, wo ein Formatierer es nicht sieht. Wenn Sie über Präfixunterschiede hinwegsehen müssen, nehmen Sie einen kanonisierenden Vergleich: Das XMLUnit-Beispiel oben mit checkForSimilar erledigt das, ebenso C14N in den Shell- und Python-Beispielen.

Wie groß darf das Dokumentenpaar sein?

Bis zu 3.000 Zeilen je Seite, gemessen nach dem Formatieren und nicht so, wie Sie sie eingefügt haben – ein minimiertes Dokument, das sich auf 8.000 Zeilen ausdehnt, liegt also über der Grenze, obwohl es als eine Zeile ankam.

Die Grenze ist Absicht. Ein Diff über die längste gemeinsame Teilfolge baut eine Tabelle proportional zum Produkt der beiden Zeilenzahlen, zwei Dokumente mit je 20.000 Zeilen bräuchten also Hunderte Millionen Zellen und würden den Tab blockieren statt bloß langsam zu sein. Für Dateien dieser Größe erledigt git diff über die Ausgabe von xmllint --format, oder das Shell-Rezept oben, was kein Browser-Tab versuchen sollte.

Verwandte Werkzeuge

Zum Weiterlesen