XML 差分

2 つの文書を比較。どちらもブラウザーの外には出ません。

変更前
変更後
待機中文書を貼り付けると検査します。入力中にそのまま検証されます。

すべてこのタブ内で実行されます。貼り付けた内容がアップロード・記録・送信されることはありません。 ネットワークパネルを開いて確認する.

左に一方の文書を、右にもう一方を貼り付けると、差分が行単位で現れます。追加は右の文書の行番号、削除は左の文書の行番号で示されます。変更のない長い区間はマーカーに畳まれるので、変更点は見つけやすいままです。両方の文書はこのタブに留まります。この検索で上位に出るツールの多くは、そうではありません。

比較は文字列ではなく構造に対して行います。まず両方の文書を同じ形(スペース 2 個のインデント、属性はアルファベット順)に整形し、その結果どうしで差分を取ります。インデントだけ、あるいは属性の順序だけが違う 2 つの文書は「同一」と出ますし、結果パネルはまさにその言葉でそう伝えます。

期待するペイロードと実際のペイロードを比べるときに欲しいのはそれであり、このツールはそのために作られています。空白に意味がある場合には、この既定は間違った選択です。その境界がどこにあるかは、以下の節がはっきり書きます。

差分でなくなるもの

比較の前に、両側が同じフォーマッターを通ります。XML は属性の順序も、混在内容の外側のインデントも意味を持つものとは扱いません。ですから、そのどちらかを差分として報告するツールは、データではなくシリアライザーの性質を語っていることになります。Jackson が書いた文書と、同じデータを .NET の XmlWriter が書いた文書を直接比べられるのはこのためです。

  • インデント、改行、タグが行のどこに置かれているか。
  • 属性の順序:両側とも名前のアルファベット順に並べ替えます。
  • 空要素の書き方:<status></status> も <status> </status> も <status/> になります。
  • テキストのみの要素の先頭と末尾の空白。ですから <name> Priya </name> は <name>Priya</name> と一致します。
  • 宣言における version、encoding、standalone の並び順。

意図して差分として残すもの

フォーマッターは、安全に変えられないものを変えません。属性値は元の引用符ごと、書かれたとおりに出力します。再エスケープすれば &amp; が &amp;amp; になってしまいますし、デコードも不可能です。&companyName; のような DTD 宣言済み実体を参照する値には、その DTD が必要だからです。

混在内容、つまりテキストと子要素の両方を持つ要素は、バイト単位でそのまま複製します。そこではテキストとマークアップのあいだの空白がデータの一部だからです。インデントが差分に本当に現れるのはこの 1 か所だけであり、そこでは正しく現れています。

  • 引用符のスタイル:id='A-991' と id="A-991"。参照の書き方も同様で、&amp; と &#38; は別物です。
  • CDATA とエスケープ済みテキスト:<note><![CDATA[a<b]]></note> と <note>a&lt;b</note> は同じ文字列を届けますが、差分として報告されます。
  • コメント。削除せず残します。設定ファイルでは、変わったコメントこそが見たかった変更であることがよくあります。
  • 名前空間プレフィックス。soap: を同じ URI のまま s: に付け替えるのは意味的には同一ですが、文書全体の差分として表示されます。

正規化が間違った既定になる場面

文書によっては、実体は構造ではなくバイト列です。XML デジタル署名は正規化されたバイト列をダイジェストするので、どんな変更でも、このツールが取り除くインデントであっても、署名を壊します。署名済みのアサーション 2 つを「同一」と言う差分が伝えているのは、内容が一致するということであって、両方がまだ検証を通るということではありません。

もうひとつは、xml:space="preserve" を宣言した文書や、整形済みテキストを持つ文書です。混在内容の部分木は安全ですが、先頭の空白に意味があるテキストのみの要素は安全ではありません。その空白は削られます。文書がそれに依存しているなら、素のテキスト差分を使ってください。

期待値と実際値

このツールが存在する理由そのものの場面です。結合テストが落ちた。手元には期待していたフィクスチャと、サービスが実際に返したペイロードがある。しかし一方は手書き、もう一方は回線から来た圧縮済みで、書式がまるで違う。その 2 つの素のテキスト差分は使い物になりません。先に両方を正規化すれば、本当に変わった 3 行にまで縮みます。

両方の文書は、まず整形式でなければなりません。どちらかが解析できなければツールは止まってその旨を伝え、左側の文書のエラーは行と列つきでエディター上に印が付きます。途中で切れたレスポンスは、構造の変更のように見えるのではなく、その場で診断されます。

これは行の差分であって木の差分ではない

比較は行に対する最長共通部分列の差分で、git が使うのと同じアルゴリズムです。ですから要素を親の中で移動させると、一方で削除、他方で追加として現れ、「移動」としては現れません。兄弟の並べ替えも、スキーマが順序を無意味と扱う場面であっても変更として表示されます。XML では要素の順序が既定で意味を持つからです。それが問題になるときの答えは、下のコードにある XMLUnit のノードマッチャーです。

上限があります。整形後に 3,000 行を超えると、ツールは処理を断り、区間ごとに比べるよう促します。最長共通部分列の表は二次的に大きくなるので、大きな文書 2 つでは「遅い」ではなくタブが固まってしまうからです。

コードで同じことをする

テストスイートやビルド工程での同じ考え方です。両側を正規化してから比較します。どの例も外部実体の解決を無効にしています。たいていの場合、自分が作ったわけではないペイロードに向けて使うからです。

// 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.

区別を明示しておく価値があります。正規化と行差分の組み合わせは、人が読める結果を与えます。XMLUnit のような木の比較は、テストがアサートできる対象と、役に立つ失敗メッセージを与えます。

よくある質問

比較のために 2 つの文書はアップロードされますか。

いいえ。2 つのエディターも、フォーマッターも、差分アルゴリズムも、すべてこのタブで動く JavaScript です。何かを送るサーバー側の仕組みはありません。

このツールがある理由がそれです。実際のペイロードは本物の本番トラフィックです。本物の顧客名、本物の注文金額、しばしば SOAP ヘッダーのベアラートークン。この検索で上位に出るツールのいくつかは、両方のファイルをアップロードで受け取ります。貼り付けながらネットワークパネルを開いてみてください。空のままです。

両方の文書は、再読み込みで作業が消えないようこのブラウザーの localStorage に保存されます。それはあなたのマシンから出ませんし、クリアで両方とも即座に消えます。

明らかに違うのに「同一」と言われるのはなぜですか。

バイト列ではなく構造を比べているからです。両側をまず同じインデントに整形し、属性を並べ替えます。ですから圧縮済みの文書と、同じデータをスペース 4 個で整形したものは同じ結果になりますし、<order id="A-991" total="64.85"> と <order total="64.85" id="A-991"> も同じになります。

XML は属性の順序も混在内容の外側のインデントも意味を持つものとは扱わないので、そのどちらかを差分として報告するツールは、データではなくシリアライザーを語っています。バイト単位の比較が必要なら、そして署名済み文書では必要ですが、素のテキスト差分を使ってください。結果の上のバナーは、インデントと属性順を正規化したうえで比較したことを常に明記するので、これが黙って行われることはありません。

2 つの文書は妥当な XML である必要がありますか。

必要なのは整形式であることで、妥当であることとは別です。整形式とは構文が正しいこと。タグが閉じて正しく入れ子になっている、ルート要素が 1 つ、特殊文字がエスケープされている、属性値が引用符で囲まれている。妥当とはさらにスキーマに適合することですが、ここにスキーマは関与しません。

解析できない文書に対して比較は実行できません。正規化すべき構造が存在しないからです。どちらかが失敗すればツールは止まってその旨を伝えます。テキスト差分に退避して、意味ありげに見える結果を返したりはしません。それだけでも実在するバグの一群を捕まえます。途中で切れたレスポンスは、真っ赤な差分の壁ではなく、行と列つきの解析失敗として現れます。

名前空間プレフィックスが違う文書どうしを比較できますか。

差分として報告します。そしてこれは正真正銘の限界です。同じ URI に束縛された soap:Envelope と s:Envelope は、名前空間を理解する利用側にとっては同一ですが、プレフィックスは書かれたままの要素名の一部であり、フォーマッターはプレフィックスを書き換えません。

書き換えは一般には安全ではありません。プレフィックスは属性値の中、xsi:type の中、スタイルシートの XPath 式の中、WSDL の QName の中にも現れ、そこはフォーマッターからは見えません。プレフィックスの違いを越えて見たいときは、正規化を伴う比較を使ってください。上の XMLUnit の例の checkForSimilar がそれを扱いますし、シェルと Python の例の C14N も同様です。

どのくらいの大きさの文書ペアを扱えますか。

それぞれ 3,000 行までです。貼り付けたときではなく整形後の行数で測るので、1 行で届いた圧縮済み文書でも展開して 8,000 行になれば上限超過です。

この上限は意図したものです。最長共通部分列の差分は 2 つの行数の積に比例する表を作るので、20,000 行の文書 2 つでは数億セルが必要になり、「遅い」ではなくタブが固まります。その大きさのファイルには、xmllint --format の出力に対する git diff か、上のシェルのレシピが、ブラウザーのタブが試みるべきでない仕事を引き受けてくれます。

関連ツール

関連する解説