XmlElementとXElementとXPathNavigatorと

.NETでXMLを扱う方法は色々あります。それぞれ使い方とか癖とかあるんでしょうけど、とりあえず気になるのは書き方よね、というわけで比較してみました。
今回のお題はこちら。

<body xmlns='MyNS'>
    <p />
    <p>foo</p>
    <!--p...-->
    <a href='piyo' />
    <p>
        <a href='hoge' />
    </p>
    <div>
        <span>hey</span>ya!
    </div>
</body>

このHTMLっぽいなにかから次の値を取得します。

  • ルート直下、最初に見つけたdiv要素のInnerText → Heyya!
  • ルート直下、最初に見つけたdiv要素のInnerXml → heyya!
  • ルート直下、2番目のp要素のInnerText → foo
  • どこかにある、p要素直下のa要素のhref属性 → hoge

ポイントとしては、

  • 名前空間が指定されている
  • p要素もa要素も複数ある

といったところでしょうか。
これをタイトルの3つの方法で取得してみます。

下準備

string xml = "<body xmlns='MyNS'><p /><p>foo</p><!--p...--><a href='piyo'/><p><a href='hoge' /></p><div><span>hey</span>ya!</div></body>";

XmlDocument xmldocument = new XmlDocument();
xmldocument.LoadXml(xml);
XmlElement xmldoc=xmldocument.DocumentElement;

XElement xdoc = XDocument.Parse(xml).Root;
XNamespace xns = xdoc.GetDefaultNamespace();

XPathNavigator xpath = new XPathDocument(new StringReader(xml)).CreateNavigator();
xpath.MoveToFirstChild();
XmlNamespaceManager nsmgr = new XmlNamespaceManager(xpath.NameTable);
nsmgr.AddNamespace("ns", xpath.NamespaceURI);

3つともルート要素までは用意しておきます。
XmlElement以外は名前空間を無視できないので一つずつ変数が増えています。
XElementの場合は要素名などを指定するときにXNamespaceのインスタンスを組み合わせます。
XPathではプレフィックスに割り当てないといけないので、XPathNavigatorにあらかじめマッピングを登録しておきます。これをSelect系のメソッドに指定することでプレフィックスを通して名前空間が使えます。

ルート直下、最初に見つけたdiv要素のInnerText

Console.WriteLine("Expected: Heyya!");
Console.WriteLine(xmldoc["div"].InnerText);
Console.WriteLine(xdoc.Element(xns + "div").Value);
Console.WriteLine(xpath.SelectSingleNode("ns:div", nsmgr).Value);

XmlElementが驚異的に簡潔ですね。VBでは言語拡張のおかげで似たような書き方も出来るのですが、C#ではElementメソッドを呼ばないといけないので若干長くなります。

ルート直下、最初に見つけたdiv要素のInnerXml

Console.WriteLine("Expected: <span>hey</span>ya!");
Console.WriteLine(xmldoc["div"].InnerXml);
Console.WriteLine(xdoc.Element(xns + "div").Nodes().Select((e) => e.ToString()).Aggregate(string.Concat));
Console.WriteLine(xpath.SelectSingleNode("ns:div", nsmgr).InnerXml);

XmlElementとXPathNavigatorにはInnerXmlがあるので簡単ですが、XElementでは自前で取得する必要があります。XElement.ToString()で自分を含むXMLは取れるので、子ノード(要素+テキスト)のXMLを結合しています。

ルート直下、2番目のp要素のInnerTex

Console.WriteLine("Expected: foo");
Console.WriteLine(xmldoc.ChildNodes.OfType<XmlElement>().Where((n) => n.Name == "p").Skip(1).First().InnerText);
Console.WriteLine(xdoc.Elements(xns + "p").Skip(1).First().Value);
Console.WriteLine(xpath.SelectSingleNode("ns:p[2]", nsmgr).Value);

XmlElementには直下にある要素を要素名ごとに取得するメソッドがありません。なので直下要素を全て列挙しつつ、名前が一致したものを取り出しています。なおChildNodesは非ジェネリックのIEnumerableなので、OfTypeを通さないとWhereなどの拡張メソッドが使えません。
何番目、という条件が増えるとXPathが有利になりそうですね。

どこかにある、p要素直下のa要素のhref属性

Console.WriteLine("Expected: hoge");
Console.WriteLine(xmldoc.GetElementsByTagName("p").OfType<XmlElement>().Where((e) => e["a"] != null).First()["a"].Attributes["href"].Value)
//Console.WriteLine(xmldoc.GetElementsByTagName("p").OfType<XmlElement>().Select((e) => e["a"]).Where((e) => e != null).First().Attributes["href"].Value);
Console.WriteLine(xdoc.Descendants(xns + "p").Elements(xns + "a").First().Attribute("href").Value);
Console.WriteLine(xpath.SelectSingleNode("//ns:p/ns:a/@href", nsmgr).Value);

えーなんていうか、XPathのために作ったような条件です。はい。
例によってXmlElementは自力で絞り込んでいます。コメントに別解も書きましたが、個人的にはnullを除外するためだけにWhereを使うのがどうも好きになれません。*1
XElement、子孫要素を列挙するのはこれしかないのでいいんですが、直下の要素を対象にしたときに.Elementと.Elementsにはまりました。この例で言うと、pは最終的に一つだから複数返す必要ないよねー、なんて思っていて.Element使っていたというお話。

感想

実はLINQを毛嫌いしていた時期がありまして、その流れでXmlElementばかり使っていたんですね。あとXPathは全然知らなかった。
で、HTMLをスクレイピングするプログラムを書いていて面倒になっていろいろ調べた結果がこれです。XPathすごい!
それにしても、こんな簡単に書けるのは楽しいなあ。

*1:でもこのほうが要素を探す回数は減ると思う。一回ほど。