PowerShell と XPath


* 会社で XML ファイルからデータを抽出する作業が発生
* 正規表現を駆使して抽出に成功
* ただ、構造化されたデータに正規表現で対応するは愚行
* XMLをテキストファイルではなく、きちんとXMLとして扱う方法を調査すべき

というわけで調べました。なお、会社で使えるのは PowerShell V2 のみなので、V2 の情報になります。



# XML データの処理

* XML文書は木構造
* 木構造で身近な例と言えばディレクトリツリー
* ディレクトリツリーと言えば `ls -l *.txt` みたいに [glob](https://en.wikipedia.org/wiki/Glob_(programming)) パターンが便利
* glob のように XML 文書のノードの集合を表す表現があったら便利なのでは？
* その便利な表現が XPath だ！

というわけで XPath 使います。

なお、[メモリ内の XML データの処理](https://msdn.microsoft.com/ja-jp/library/20esef39(v=vs.110).aspx)の記事を見ると、下記３つの方法があります。（Version 2.0 には Select-XML がないので、XML を扱うには .NET Framework を利用する必要があります。）

1. [DOM モデルを使用した XML データの処理](https://msdn.microsoft.com/ja-jp/library/t058x2df(v=vs.110).aspx)
2. [XPath データ モデルを使用した XML データの処理](https://msdn.microsoft.com/ja-jp/library/87274khy(v=vs.110).aspx)
3. [LINQ to XML を使用した XML データの処理](https://msdn.microsoft.com/ja-jp/library/bb669128(v=vs.110).aspx)

今回は 2 を選択しますが、[XPath ナビゲーションによるノードの選択](https://msdn.microsoft.com/ja-jp/library/d271ytdx(v=vs.110).aspx) にあるように 1 の Dom モデルでも XPath が使えます。


# XPath の使い方

だいたい次のコードで使えます。

```ps1
$xml_doc = [xml](cat $path -enc $encoding)
$xml_nav = $xml_doc.CreateNavigator()
$xml_nav.Select("XPath式") | %{$_.Value}
```

1. XML ファイルを読み込んで [XmlDocument](https://msdn.microsoft.com/ja-jp/library/system.xml.xmldocument(v=vs.110).aspx)にキャスト
2. `CreateNavigator()`メソッドで、[XPathNavigator](https://msdn.microsoft.com/ja-jp/library/system.xml.xpath.xpathnavigator(v=vs.110).aspx)オブジェクトを作成
3. `Select(String)`メソッドで、 XPath 式を元にノード（XPathNavigatorオブジェクト）の[イテレーター](https://msdn.microsoft.com/ja-jp/library/system.xml.xpath.xpathnodeiterator(v=vs.110).aspx)を選択
4. `%{$_.Value}` を噛ませて値を取得


# チートシート

[XPath の例](https://msdn.microsoft.com/ja-jp/library/ms256086(v=vs.120).aspx)を眺めてれば目的の式が見つかると思います。



# 使ってみる

XML ファイルからデータを抽出するコードを XPath で書いてみる


## 対象のXML ファイル

* 構成は下記のような感じ。
    * 要素`TargetElem1`が複数存在する。
    * 要素`TargetElem1`は入れ子にならない
     （要素`TargetElem1`に子要素`TargetElem1`は存在しない）
    * 要素`TargetElem1`は要素`TargetElem2`を０または１個含む。

```xml
<Root>
  ...
  <TargetElem1 ... TargetAttr1 = "foo" ... >
    ...
    <TargetElem2 ... TargetAttr1 = "baz" />
    ...
  </TargetElem1>
  ...
  <TargetElem1 ... TargetAttr1 = "bar" ... >
    ...
  </TargetElem1>
  ...
</Root>
```

## 出力形式

* 出力形式（抽出したい情報）は下記のデータをタブ区切りで１行ずつ列挙する
    * ファイル名
    * TargetAttr1の値
    * TargetAttr2の値（なければ記述不要）

```txt
file1.xml	foo	baz
file1.xml	bar
...
```


## ツール

* 使えるスクリプト言語のは最初から入ってる PowerShell V2 のみ
    * 会社のPC {OS : Windows7} はイントラネットのみの接続
    * ソフトウェアのインストールは会社で定められたもののみ、それ以外は不可


## コード

### 正規表現を使ったコード 

```ps1
filter my-grep
{
  Param ($Name, [Parameter(ValueFromPipeline=$True)] $RawLine)

  switch -regex ($RawLine)
  {
    "<TargetElem1" {$line = @($Name, $RawLine -replace ".*TargetAttr1 *= *`"(.*?)`".*", '$1')}
    "<TargetElem2" {$line += $RawLine -replace ".*TargetAttr2 *= *`"(.*?)`".*", '$1'}
    "</TargetElem" {$line -join "`t"}
  }
}

$xml_path = "xmlが格納されているディレクトリのパス"
$out_file = "結果のリダイレクト先のファイル名"

ls $xml_path | %{cat $_.fullname -enc utf8 | my-grep $_.name} > $out_file
```

### XPath を使ったコード 

```ps1
filter get-TargetElem1 ($path, $encoding)
{
  $xml_doc = [xml](cat $path -enc $encoding)
  $xml_nav = $xml_doc.CreateNavigator()
  $xml_nav.Select("//TargetElem1")
}

filter my-grep
{
  Param ($Name, [Parameter(ValueFromPipeline=$True)] $TargetElem1)

  $line = @($Name, $TargetElem1.GetAttribute("TargetAttr1", ""))
  $line += $TargetElem1.Select(".//TargetElem2/@TargetAttr2") | %{$_.Value}
  $line -join "`t"
}

$xml_path = "xmlが格納されているディレクトリのパス"
$out_file = "結果のリダイレクト先のファイル名"

ls $xml_path | %{get-TargetElem1 $_.fullname -enc utf8 | my-grep $_.name} > $out_file
```


# 感想

正規表現よりは断然使いやすいです。ただ、いちいち`%{$_.Value}`やら`GetAttribute`を噛ませるのも面倒なので、下記のように、`$xml_doc.SelectNodes("XPath式")`して、あとは DOM モデルでよしなにしたほうが楽な気もします。

```ps1
filter get-TargetElem1 ($path, $encoding)
{
  $xml_doc = [xml](cat $path -enc $encoding)
  xml_doc.SelectNodes("//TargetElem1")
}

filter my-grep
{
  Param ($Name, [Parameter(ValueFromPipeline=$True)] $TargetElem1)

  $line = @($Name, $TargetElem1.TargetAttr1)
  $line += $TargetElem1.SelectNodes(".//TargetElem2") | %{$_.TargetAttr2}
  $line -join "`t"
}

$xml_path = "xmlが格納されているディレクトリのパス"
$out_file = "結果のリダイレクト先のファイル名"

ls $xml_path | %{get-TargetElem1 $_.fullname -enc utf8 | my-grep $_.name} > $out_file
```