Comparar XML
Compare dois documentos. Nenhum sai do seu navegador.
Tudo roda nesta aba. Nada do que você colar é enviado, registrado ou transmitido para lugar nenhum. Abra o painel de rede e confira.
Cole um documento à esquerda e o outro à direita e as diferenças aparecem linha a linha, com as adições numeradas pelo documento da direita e as remoções pelo da esquerda. Trechos longos sem alteração se recolhem num marcador para que as mudanças continuem fáceis de achar. Os dois documentos ficam nesta aba, o que não é verdade na maioria das ferramentas bem posicionadas nesta busca.
A comparação é estrutural, não textual. Os dois documentos são reformatados primeiro, para indentação de dois espaços e atributos ordenados alfabeticamente, e o diff roda sobre os resultados. Dois documentos que diferem só na indentação, ou só na ordem dos atributos, saem idênticos, e o painel de resultado diz isso com essas palavras.
É o que você quer ao comparar um payload esperado com um real, que é para isso que a ferramenta foi feita. É o padrão errado quando o espaço em branco carrega significado, e as seções abaixo dizem exatamente onde fica essa linha.
O que deixa de ser diferença
Os dois lados passam pelo mesmo formatador antes de qualquer comparação. O XML não trata como significativos nem a ordem dos atributos nem a indentação fora do conteúdo misto, então um diff que reporte qualquer um dos dois está descrevendo o serializador e não os dados. É por isso que um documento escrito pelo Jackson e os mesmos dados escritos por um XmlWriter do .NET podem ser comparados diretamente.
- Indentação, quebras de linha e onde as tags ficam na linha.
- Ordem dos atributos: os dois lados são ordenados alfabeticamente por nome.
- Sintaxe de elemento vazio: <status></status> e <status> </status> viram ambos <status/>.
- Espaço no começo e no fim de um elemento só de texto, então <name> Priya </name> casa com <name>Priya</name>.
- A ordem de version, encoding e standalone na declaração.
O que continua sendo diferença, de propósito
O formatador não muda nada que não possa mudar com segurança. Valores de atributo são emitidos exatamente como foram escritos, aspas originais inclusive: re-escapar transformaria & em &amp;, e decodificar também é impossível, porque um valor que referencia uma entidade declarada na DTD, como &companyName;, precisa daquela DTD.
Conteúdo misto, um elemento que contém texto e elementos filhos ao mesmo tempo, é reproduzido byte a byte, porque ali o espaço entre texto e marcação faz parte dos dados. É o único lugar em que a indentação realmente aparece no diff, e ali ela está aparecendo com razão.
- Estilo de aspas: id='A-991' contra id="A-991". O mesmo para a grafia de uma referência, & contra &.
- CDATA contra texto escapado: <note><![CDATA[a<b]]></note> e <note>a<b</note> entregam caracteres idênticos mas são reportados como diferentes.
- Comentários, que são mantidos em vez de removidos: num arquivo de configuração, um comentário alterado costuma ser justamente a mudança que você queria.
- Prefixos de namespace. Reassociar soap: para s: com a mesma URI é semanticamente idêntico e mesmo assim aparece como diferença no documento inteiro.
Quando normalizar é o padrão errado
Alguns documentos são bytes, não estrutura. A assinatura digital XML resume uma sequência canônica de bytes, então qualquer mudança, inclusive a indentação que esta ferramenta normaliza, quebra a assinatura. Um diff dizendo que duas asserções assinadas são idênticas informa que o conteúdo delas bate, não que as duas ainda verificam.
O outro caso é um documento que declarou xml:space="preserve", ou que carrega texto pré-formatado. Subárvores de conteúdo misto estão seguras, mas um elemento só de texto cujo espaço inicial importa não está: esse espaço é aparado. Use um diff de texto puro quando o seu documento depender disso.
Esperado contra real
O caso para o qual isto existe: um teste de integração falha, você tem a fixture que ele esperava e o payload que o serviço realmente devolveu, e os dois estão formatados de jeitos diferentes porque um foi escrito à mão e o outro veio minificado pela rede. Um diff de texto puro desses dois é inutilizável; normalizar os dois primeiro reduz tudo às três linhas que de fato mudaram.
Os dois documentos precisam estar bem formados antes. Se algum não fizer parsing, a ferramenta para e diz, e os erros do documento da esquerda são marcados no editor com linha e coluna, então uma resposta truncada é diagnosticada no lugar em vez de parecer uma mudança estrutural.
É um diff de linhas, não de árvore
A comparação é um diff por maior subsequência comum sobre linhas, o mesmo algoritmo que o git usa. Mover um elemento dentro do próprio pai aparece, portanto, como remoção num lugar e adição em outro, não como movimento. Reordenar irmãos aparece como mudança mesmo onde o esquema trata a ordem como insignificante, porque a ordem dos elementos em XML é significativa por padrão. Os node matchers do XMLUnit, no código abaixo, são a resposta quando isso importa.
Há um teto. Acima de 3.000 linhas depois da formatação a ferramenta recusa e pede para comparar uma seção por vez: a tabela da maior subsequência comum é quadrática, e dois documentos grandes travariam a aba em vez de apenas ficarem lentos.
Fazendo isso em código
A mesma ideia numa suíte de testes ou num passo de build: canonicalize os dois lados e então compare. Cada um destes desliga a resolução de entidades externas, porque normalmente você os aponta para um payload que não foi você quem produziu.
// 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 nomear a divisão: canonicalização mais um diff de linhas dá algo que um humano consegue ler, e uma comparação de árvore como a do XMLUnit dá a um teste algo sobre o que asserir, com uma mensagem de falha útil.
Perguntas frequentes
Meus dois documentos são enviados para serem comparados?
Não. Os dois editores, o formatador e o algoritmo de diff são JavaScript rodando nesta aba, e não existe componente de servidor para onde mandar nada.
É por isso que a ferramenta existe. O payload real é tráfego de produção real: nomes de clientes reais, valores de pedido reais, muitas vezes um token num cabeçalho SOAP. Várias ferramentas bem posicionadas nesta busca pedem os dois arquivos por upload. Abra o painel de rede enquanto cola e ele vai continuar vazio.
Os dois documentos ficam no localStorage deste navegador para que um recarregamento não perca seu trabalho. Isso nunca sai da sua máquina, e Limpar remove os dois na hora.
Por que ele diz que dois documentos são idênticos quando claramente não são?
Porque compara estrutura, não bytes. Os dois lados são reformatados primeiro para a mesma indentação com os atributos ordenados, então um documento minificado e os mesmos dados formatados com quatro espaços saem iguais, assim como <order id="A-991" total="64.85"> e <order total="64.85" id="A-991">.
O XML não trata como significativos nem a ordem dos atributos nem a indentação fora do conteúdo misto, então um diff que reporte qualquer um dos dois está descrevendo o serializador e não os dados. Se você precisa de comparação em nível de bytes, e para um documento assinado precisa, use um diff de texto puro. O aviso acima do resultado sempre diz que a comparação foi feita depois de normalizar indentação e ordem de atributos, então isso nunca é silencioso.
Os dois documentos precisam ser XML válido?
Precisam ser bem formados, que não é o mesmo que válidos. Bem formado significa que a sintaxe está correta: tags fechadas e aninhadas certo, um elemento raiz, caracteres especiais escapados, valores de atributo entre aspas. Válido significa, além disso, casar com um esquema, e nenhum esquema está envolvido aqui.
A comparação não roda sobre um documento que não faz parsing, porque não há estrutura para normalizar. Se algum lado falha, a ferramenta para e diz, em vez de cair para um diff de texto e devolver um resultado que parece significativo. Isso já pega sozinho uma classe real de bug: uma resposta truncada aparece como falha de parsing com linha e coluna em vez de um muro de vermelho.
Ele consegue comparar documentos com prefixos de namespace diferentes?
Ele vai reportá-los como diferentes, e essa é uma limitação genuína. soap:Envelope e s:Envelope vinculados à mesma URI são idênticos para qualquer consumidor ciente de namespaces, mas o prefixo faz parte do nome do elemento como está escrito, e o formatador não reescreve prefixos.
Reescrever não é seguro em geral: um prefixo pode aparecer dentro de valores de atributo, num xsi:type, numa expressão XPath dentro de uma folha de estilo, num QName de um WSDL, onde um formatador não enxerga. Quando é justamente das diferenças de prefixo que você precisa passar por cima, use uma comparação canonicalizante: o exemplo do XMLUnit acima com checkForSimilar dá conta, assim como o C14N nos exemplos de shell e Python.
Que tamanho de par de documentos ele aguenta?
Até 3.000 linhas cada, medidas depois da formatação e não como você colou, então um documento minificado que se expande para 8.000 linhas passa do limite mesmo tendo chegado em uma linha só.
O teto é deliberado. Um diff por maior subsequência comum monta uma tabela proporcional ao produto das duas contagens de linhas, então dois documentos de 20.000 linhas precisariam de centenas de milhões de células e travariam a aba em vez de apenas ficarem lentos. Para arquivos desse tamanho, git diff sobre a saída de xmllint --format, ou a receita de shell acima, faz o que nenhuma aba de navegador deveria tentar.