C# で XML を取り扱う方法はいくつかあるが、その中でも特に推奨されるのが LINQ to XML を用いた操作である。
ここでは特に有用であろう、読み取りに限定した操作方法を記述する。書き込みが必要な場面というのはあまり無いような気もする。
そもそも XML とは何か
XML は、Extensive Markup Language のことを指し、HTML の兄弟規格として制定された、汎用的に使えるデータフォーマットである。要素の内容は自由に設定可能だが、一方で構造は重厚さがあり、堅牢に扱えるフォーマットだともいえる。
一般的なデータフォーマットとしてのシェアは人間が簡単に書ける JSON の方が高いが、それでも有用な場面が多いフォーマットである。特に XML Schema を使い、フォーマットの明確な定義ができる点は重要である。
Microsoft は XML を自社ソフトウェアのフォーマットに積極的に取り入れており、例えば C# のコード説明コメントは XML によるものだし、Office ファイル全般の実装も zip 内に各種 XML を梱包する形で実装されている。Office ファイルの規格名自体も OpenXML である。
LINQ to XML における XML の抽象
LINQ to XML を利用すると、XML ファイルを LINQ の方法で「比較的」簡単に扱えるが、ここでいくつかの抽象化が施されている。
以下、特によく利用するであろう XML 抽象のクラス (ここでいう「抽象」は C# におけるabstract
ではないので注意) を列挙する。
- XElement
- 任意のXML要素。
- XAttribute
- 任意のXML要素に付与された属性。
- XName
- 任意のXML要素の名前。
- XNamespace
- 任意のXML要素が持っている名前空間。
プログラマーは、LINQ to XML メソッドを使い、これらのクラスにアクセスする。
XML 要素を準備する
XML ファイルをすでに作っているなら、ファイルをStream
もしくはその継承先クラスで開き、XElement.Load(Stream)
を利用することでXElememt
に変換することができる。Excelファイル1を開く場合、
using (var file = File.OpenRead(xlsxFilePath)) // Excelファイルを開く
using (var zip = new ZipArchive(file, ZipArchiveMode.Read)) // Excelファイルはzipなので、zipとして扱う
{
var entry = zip.Entries.FirstOrDefault(etr =>
etr.FullName.Equals("xl/workbook.xml", StringComparison.OrdinalIgnoreCase));
using (var xmlFile = entry.Open())
{
var element = XElement.Load(xmlFile);
// elementに対する処理
}
}
XML から欲しいものを抽出する
子孫要素を見つける
XML の子孫要素を探るための LINQ to XML メソッドは以下のものがある。
XElement.Element(XName)
- 指定した名前の、当該要素直下の最初の子要素を取得する。
XElement.Elements(XName)
- 指定した名前の、当該要素直下の子要素の一覧を取得する。
引数を指定しなければ当該要素直下の全ての子要素の一覧を取得する。 XElement.Descendants(XName)
- 指定した名前の、当該要素の全ての子孫の子要素の一覧を取得する。
引数を指定しなければ当該要素の全ての子孫要素の一覧を取得する。
通常は Descendants
で事足りるが、構成が大事な場合はElement
を使うことになる。
ここで、引数がXName
型になっているが、名前空間の無い XML の場合は通常のstring
で問題無い。自動で型変換が入る。ここは C# でなかったら、いちいちroot.Element(new XName("Name"))
と書かねばならなかったり、XElement.Element()
のメソッド定義を複数作らねばならず煩雑だっただろう。
値を取得する
XML 要素が持つ値は、単純にValue
で取得可能だ。例えばこんな XML があったとする。
<Pikotaro>
<I>Pen</I>
<I>Apple</I>
</Pikotaro>
ここからI
が持つものを抽出する C# コードを書くと以下のようになるだろう。
// ここでpikotaroは上のXMLを何らかの方法で開いたものである
var iHave = pikotaro
.Elements("I")
.Select(i => i.Value)
.ToList();
属性を取得する
XML 要素の属性はを取得するメソッドは以下の通り。
XElement.Attribute(XName)
- 当該要素の指定した名前の属性を取得する。
XElement.Attributes()
- 当該要素の属性をすべて取得する。
XElement.Attributes(XName)
- 当該要素の指定した名前の属性を、コレクションとして取得する。
Attributes
はオーバーロードにより使い方が異なるため分割して表にした。XElement.Attributes(XName)
はおそらくLINQを使ったメソッドチェイン処理をしたい場合や、(遅延実行のため) 即座に取得させたくない場合に使うものだと思われる。
属性取得に関しても XML の例を作ろう。
<MobileSuit>
<Name ModelNumber="MS-06">ザクⅡ</Name>
<OperatedBy>ジオン公国軍</OperatedBy>
</MobileSuit>
ここからModelNumber
を抽出する C# コードは以下の通りだ。
// ここでmobileSuitは上のXMLを何らかの方法で開いたものである
var modelNumber = mobileSuit
.Element("Name")
.Attribute("ModelNumber")
.Value;
名前空間のある XML は要注意!
ただ、注意すべきは XML 自体に名前空間指定xmlns
が適用されていると、XElement.Element(XName)
の引数にstring
を使っても、指定した要素が選択されないことだ。つまりどういうことかというと、例えばExcelファイル内のworkbook.xml
の構成 (抜粋) はこんな感じである。
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<fileVersion appName="xl" lastEdited="7" lowestEdited="4" rupBuild="24326"/>
<workbookPr defaultThemeVersion="166925"/>
</workbook>
先ほどのコードの要領で、このXMLからfileVersionを抜き出す際にどうしてもこうしたくなるが。
// ここでelementはworkbook.xmlを開いているものとする
var fileVersion = element.Element("fileVersion") // この点は出ねえよ!
残念ながら、これではfileVersion
要素が (いや、それどころか何も) 抽出できない。名前空間が加味されていないからである。ではどうするか、誘惑振り切ってこうだ。
// ここでelementはworkbook.xmlを開いているものとする
var fileVersion = element.Element(element.Name.Namespace + "fileVersion")
こうすることで、要素の名前空間 (XNamespace
) と"fileVersion"が結合されたXName
が生成される。めでたしめでたし。
ここでさらに解説すれば、XElement.Name
の型はXName
であり、さらにXName.Namespace
はXNamespace
型である。XNamespace
とstring
の間に演算子+
が設定されており、その結果がXName
型になるという寸法だ。ここら辺も C# の強力な演算子多重定義が光る場面である。Java だったらelement.name.namespace.getElementName("fileVersion")
のような設計になっていただろう。ここで、メソッド名は Java に合わせてキャメルケースにした。
名前空間付きの要素はさらに注意!
さらに面倒なのは名前空間に属する要素である。例えば Excel ファイルの図面の XML (ファイル名は例えば drawing1.xml となる。この数字は1以上の任意の整数となる) はこんな感じだ2。
<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing"
xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
<xdr:twoCellAnchor editAs="oneCell">
<xdr:from>
<xdr:col>4</xdr:col>
<xdr:colOff>104775</xdr:colOff>
<xdr:row>8</xdr:row>
<xdr:rowOff>0</xdr:rowOff>
</xdr:from>
<xdr:to>
<xdr:col>4</xdr:col>
<xdr:colOff>2628900</xdr:colOff>
<xdr:row>8</xdr:row>
<xdr:rowOff>1009650</xdr:rowOff>
</xdr:to>
<xdr:pic>
<xdr:nvPicPr>
<xdr:cNvPr id="3" name="図 1">
<a:extLst>
<a:ext uri="{FF2B5EF4-FFF2-40B4-BE49-F238E27FC236}">
<a16:creationId
xmlns:a16="http://schemas.microsoft.com/office/drawing/2014/main"
id="{A6C559F0-7AC2-6DEF-2740-190054F72023}" />
</a:ext>
</a:extLst>
</xdr:cNvPr>
<xdr:cNvPicPr>
<a:picLocks noChangeAspect="1" />
</xdr:cNvPicPr>
</xdr:nvPicPr>
<xdr:blipFill>
<a:blip
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
r:embed="rId1" />
<a:stretch>
<a:fillRect />
</a:stretch>
</xdr:blipFill>
<xdr:spPr>
<a:xfrm>
<a:off x="5648325" y="1847850" />
<a:ext cx="2524125" cy="1009650" />
</a:xfrm>
<a:prstGeom prst="rect">
<a:avLst />
</a:prstGeom>
</xdr:spPr>
</xdr:pic>
<xdr:clientData />
</xdr:twoCellAnchor>
</xdr:wsDr>
ここで、a:blip
要素のr:embed
の値を抜き出したいとする。思わずこう書きたくなるだろう。
// ここでelementはdrawing1.xmlのルート要素「xdr:wsDr」を開いているものとする
var rId = element.Descendants(element.Name.Namespace + "a:blip")[0]
.Attribute("r:embed").Value;
だが、やはりこのコードは動かない。要素や属性の指定に ":" を含む文字列が使えないというエラーが出てコードが止まるからだ。ではこのコロンは何者か。
実は、これこそが要素の名前空間を表す記号なのである。つまり「a:blip
要素」というのは正確ではない (が、簡単のために以後もこの記法を使う)。「a
名前空間プレフィックスの blip
要素」だったのだ。このa
名前空間プレフィックスの定義はxdr:wsDr
でなされている。xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
というのがそれだ。つまり、先のコードは名前空間指定の時点で間違っていたのである。
この名前空間を指定するために、.NET チームはXElement.GetNamespaceOfPrefix(string)
というメソッドを用意した。ここで引数はプレフィックスの名前そのものである。つまり、先のコードのDescendants
指定は次のように書く。
var blips = element.Descendants(element.GetNamespaceOfPrefix("a") + "blip")
ここで、賢明な読者諸君は「なぜ.Attribute
まで取得しないのだろう」という疑問が湧いたに違いない。勘のいいガキは嫌いだよここがミソで、r
プレフィックスの定義はxdr:wsDr
に無く、a:blip
で定義されているのだ。つまり、こうしてもデータは取得できない。
// ここでelementはdrawing1.xmlのルート要素「xdr:wsDr」を開いているものとする
var rId = element.Descendants(element.GetNamespaceOfPrefix("a") + "blip")[0]
.Attribute(element.GetNamespaceOfPrefix("r") + "embed").Value;
というのも、 (くどい繰り返しになるが) element
はr
という名前空間プレフィックスを持っていないからである。これは地味に面倒だが、冷静になって考えれば、何らかの手段でa:blip
要素を取得できればいい話だ。つまり、
// ここでelementはdrawing1.xmlのルート要素「xdr:wsDr」を開いているものとする
var blip = element.Descendants(element.GetNamespaceOfPrefix("a") + "blip")[0];
var rId = blip.Attribute(blip.GetNamespaceOfPrefix("r") + "embed").Value;
とすれば、めでたくr:embed
の値が取得できる。2行に分かれるのがお気に召さないなら、LINQ が使える。
// ここでelementはdrawing1.xmlのルート要素「xdr:wsDr」を開いているものとする
var rId = element
.SelectMany(e => e.Descendants(e.GetNamespaceOfPrefix("a") + "blip"))
.Select(e => e.Attribute(e.GetNamespaceOfPrefix("r") + "embed").Value)
.FirstOrDefault();
ここで、Select
の戻り値はIEnumerable<string>
なのでインデクサーが使えないため、FirstOrDefault()
で代用した。
まとめ
さて、読者の皆さんは序盤の説明で「比較的」を鍵括弧でくくっていたことを覚えているだろうか? これはもちろん意図的だが、その理由は「名前空間がある場合の取り扱いの難しさ」にある。
- 名前空間が指定されていることを忘れて、要素名だけでアクセスしようとしがち。
- デフォルト名前空間は
.
を2回使わないとアクセスできないので面倒。 - 名前空間プレフィックス指定は要素を半ば再帰的に取り出さないといけないので、これも面倒。
という躓きポイントが多い仕様である。しかし、それさえ分かれば C# の XML 操作は強力無比であることも間違いないだろう。