プログラマーのための YAML 入門 (探索編)

書いた人: kwatch

はじめに

YAML (YAML Ain't Markup Language) とは、構造化されたデータを表現するためのフォーマットです。本来はデータシリアライゼーション用のフォーマットですが、人にとって読みやすく書きやすいフォーマットなので、設定ファイルやデータ定義ファイルなどに使用されます。「構造化されたデータを表現する」という点では XML と同じですが、XML と比べて読みやすい、書きやすい、わかりやすいという特徴があります。

今回は YPath について説明します。YPath とは、YAML ドキュメントの中からある特定のデータを指定したり検索するための規格です (XML には XPath という規格がありますが、それの YAML 版だと考えていただいて結構です)。YPath を使うと、例えば次のようなことができます。

  • アドレス帳のなかから名前が XXX である人のデータを取り出す。
  • クラス名簿のなかから性別が女の子である子供の名前と電話番号だけを取り出す。

なお YPath の仕様は議論中で、まだ固まっていません。また Syck (YAML 用ライブラリ) での実装も中途半端で、XPath と比べると見劣りします。このような事情を踏まえて、本稿では YPath の基本的な機能だけを説明します。

なおプログラムの動作は Ruby 1.8.4 で確認しています。1.8.2 以前と 1.8.3 以降では Syck の仕様がかなり変わっているので、1.8.2 以前を使っている方は 1.8.4 をインストールしてください。

目次

事前準備

YPath を説明する前に、次のスクリプト「show-ypath.rb」を用意してください。これは YAML ドキュメントの中から、YPath で指定されたデータを抜き出して表示するスクリプトです。使い方は、第 1 引数で YPath を、第 2 引数で YAML ドキュメントのファイル名を指定します。

show-ypath.rb {{attach_pre('show-ypath.rb', 'inline=true')}}NoMethodError (undefined method `escape' for "0013-YAML":String): inline plugin

YPath を使ったスクリプトは本連載の第 2 回でも説明しましたが、もう一度説明します。

  • 「YAML.parse(input)」は、文字列または IO オブジェクトを読み込んで、ツリー形式に変換します。
  • ツリーはノード (YAML::Syck::Node オブジェクト) で構成されます*1。ノードを Ruby のオブジェクト (Array、Hash、String、Fixnum など) に変換するには、YAML::Syck::Node#transform() を使います。
  • ツリーを探索するメソッドには以下の 3 つがあります*2。これらはどれも、文字列 ypath をもとにツリーを探索するという点では同じですが、戻り値が異なります。
    • Node#search(ypath) は、マッチしたノードのパスの配列を返します。
    • Node#select(ypath) は、マッチしたノードの配列を返します。
    • Node#select!(ypath) は、マッチしたノードに対して transform() を呼び出し、その結果のオブジェクトの配列を返します。

また検索対象となるサンプルドキュメントとして、次のような YAML ドキュメント「datafile.yaml」を用意してください。

datafile.yaml {{attach_pre('datafile.yaml', 'inline=true')}}NoMethodError (undefined method `escape' for "0013-YAML":String): inline plugin

YPath の基本

パス

YPath は、ファイルや URL のパスと同じように、パス要素と区切り文字から構成されます。

  • パス要素は「シーケンスのインデックス番号」または「マッピングのキー」です。
  • パス要素の区切り文字は「/」です。

例えばサンプルのデータファイルでは、「/teams/0/name」という YPath を指定すると「Akudaman」という文字列が検索されます。

 $ ruby show-ypath.rb '/teams/0/name' datafile.yaml
 #--- search('/teams/0/name') ---
 "/teams/0/name"
 #--- select('/teams/0/name') ---
 "Akudaman"

また「/teams/0/members」という YPath を指定すると、メンバーのデータを表すシーケンスが検索されます。

 $ ruby show-ypath.rb '/teams/0/members' datafile.yaml
 #--- search('/teams/0/members') ---
 "/teams/0/members"
 #--- select('/teams/0/members') ---
 [{"name"=>"Mujo", "leader"=>true, "age"=>24},
  {"name"=>"Tobokkee", "age"=>25},
  {"name"=>"Donjuro", "age"=>30}]

ルート

ツリーのルートとなるノードは、「/.」という YPath で表されます。ここで「.」は現在のノードを表します*3

 $ ruby show-ypath.rb '/.' datafile.yaml
 #--- search('/.') ---
 "/"
 #--- select('/.') ---
 {"teams"=>
   [{"name"=>"Akudaman",
     "members"=>
      [{"name"=>"Mujo", "leader"=>true, "age"=>24},
       {"name"=>"Tobokkee", "age"=>25},
       {"name"=>"Donjuro", "age"=>30}]},
    {"name"=>"Doronboo",
     "members"=>
      [{"name"=>"Doronjo", "leader"=>true, "age"=>24},
       {"name"=>"Boyakkie", "age"=>25},
       {"name"=>"Tonzuraa", "age"=>30}]}]}

任意の要素

「*」はすべてのパス要素 (シーケンスならインデックス番号、マッピングならキー) にマッチします。

例えばサンプルのデータファイルにおいて、「/teams/*/name」を指定すればすべてのチーム名が、また「/teams/*/members/*/name」を指定すればすべてのメンバー名が検索されます。

「/teams/*/name」

 $ ruby show-ypath.rb '/teams/*/name' datafile.yaml
 #--- search('/teams/*/name') ---
 "/teams/0/name"
 "/teams/1/name"
 #--- select('/teams/*/name') ---
 "Akudaman"
 "Doronboo"

「/teams/*/members/*/name」

 $ ruby show-ypath.rb '/teams/*/members/*/name' datafile.yaml
 #--- search('/teams/*/members/*/name') ---
 "/teams/0/members/0/name"
 "/teams/0/members/1/name"
 "/teams/0/members/2/name"
 "/teams/1/members/0/name"
 "/teams/1/members/1/name"
 "/teams/1/members/2/name"
 #--- select('/teams/*/members/*/name') ---
 "Mujo"
 "Tobokkee"
 "Donjuro"
 "Doronjo"
 "Boyakkie"
 "Tonzuraa"

再帰的な検索

再帰的な検索を行うには「//」を使用します。 例えば「//name」を指定すると、すべてのマッピングの中から「name」をキーとする値を表示します。サンプルデータでなら、チーム名とメンバー名がすべて表示されます。

「//name」

 $ ruby show-ypath.rb '//name' datafile.yaml
 #--- search('//name') ---
 "/teams/0/name"
 "/teams/0/members/0/name"
 "/teams/0/members/1/name"
 "/teams/0/members/2/name"
 "/teams/1/name"
 "/teams/1/members/0/name"
 "/teams/1/members/1/name"
 "/teams/1/members/2/name"
 #--- select('//name') ---
 "Akudaman"
 "Mujo"
 "Tobokkee"
 "Donjuro"
 "Doronboo"
 "Doronjo"
 "Boyakkie"
 "Tonzuraa"

「//」は、YPath の途中に現れても構いません。例えば「/teams//name」という指定をすれば、「/teams」以下のノードを探索し「//name」にマッチするものが検索されます。

選択

複数の要素を指定するには、「|」で区切ります。通常は「(foo|bar|baz)」のように丸括弧でくくります。

例えばサンプルのデータファイルでは、「//members/*/(name|age)」という YPath を指定すると、すべてのメンバーの名前と年齢が検索できます。

「//members/*/(name|age)」

 $ ruby show-ypath.rb '//members/*/(name|age)' datafile.yaml
 #--- search('//members/*/(name|age)') ---
 "/teams/0/members/0/name"
 "/teams/0/members/1/name"
 "/teams/0/members/2/name"
 "/teams/1/members/0/name"
 "/teams/1/members/1/name"
 "/teams/1/members/2/name"
 "/teams/0/members/0/age"
 "/teams/0/members/1/age"
 "/teams/0/members/2/age"
 "/teams/1/members/0/age"
 "/teams/1/members/1/age"
 "/teams/1/members/2/age"
 #--- select('//members/*/(name|age)') ---
 "Mujo"
 "Tobokkee"
 "Donjuro"
 "Doronjo"
 "Boyakkie"
 "Tonzuraa"
 24
 25
 30
 24
 25
 30

条件

「[]」を使うと、簡単な探索条件を指定できます。今のところ、Syck が対応しているのは次の 2 つのようです。

  • 指定されたキーを持つマッピング
  • キーの値が指定された値と同じもの

「//members/*[leader]」は、すべてのメンバーからキー「leader」を持つノードを検索します。

「//members/*[leader]」

 $ ruby show-ypath.rb '//members/*[leader]' datafile.yaml
 #--- search('//members/*[leader]') ---
 "/teams/0/members/0"
 "/teams/1/members/0"
 #--- select('//members/*[leader]') ---
 {"name"=>"Mujo", "leader"=>true, "age"=>24}
 {"name"=>"Doronjo", "leader"=>true, "age"=>24}

「//members/*/age[.=25]」は、キー「age」の値が 25 であるものを検索します (今のところ、「以上」や「以下」などは指定できません)。「.=」のピリオドは「現在のノード」を表します。

「//members/*/age[.=25]」

 $ ruby show-ypath.rb '//members/*/age[.=25]' datafile.yaml
 #--- search('//members/*/age[.=25]') ---
 "/teams/0/members/1/age"
 "/teams/1/members/1/age"
 #--- select('//members/*/age[.=25]') ---
 25
 25

ここで「age の値が 25 であるようなマッピングのノード」を抽出できればいいのですが、Syck がサポートしている YPath だけではできないので、次のようなコードを使ってください*4

 ## YAML ファイルを読み込み、ツリーに変換する
 str = ARGF.read()
 tree = YAML.parse(str)

 ## ypath にマッチしたノードのパスを取得する
 ypath = '//age[.=25]'         # 「age が 25である」ことを表す YPath
 paths = tree.search(ypath)    # paths はパスの配列

 ## パスから最後の要素を取り除く
 ## (ex. "/teams/0/members/1/age" => "/teams/0/members/1")
 paths = paths.collect { |path| File.dirname(path) }

 ## ノードを表示する
 nodes = tree.select(ypath)    # nodes はノードの配列
 nodes.each do |node|
   pp node.transform
 end

応用例:YAML ドキュメント検索ツール

コマンド「yamlgrep」

YPath の応用例として、YPath を使って YAML ドキュメントを検索するツール「yamlgrep」を作成してみます。ちょうど grep が正規表現でテキストファイルを検索するように、yamlgrep では YPath で YAML ファイルを検索します。

yamlgrep {{attach_pre('yamlgrep', 'inline=true')}}NoMethodError (undefined method `escape' for "0013-YAML":String): inline plugin

使い方

yamlgrep の基本的な使い方は、「yamlgrep YPathパターン [ファイル名 ...]」です。ファイル名が省略された場合は標準入力が使われます。例えば「Doronjo」という名前のメンバーがいるかどうかは次のようにして検索できます (実行例のデータファイルは前のセクションと同じものです)。

実行例: 名前が「Doronjo」であるデータを表示

 $ ruby yamlgrep '//members/*/name[.=Doronjo]' datafile.yaml
 ## /teams/1/members/0/name
 - Doronjo

オプション「-u[N]」を使うと、マッチしたパスの親をたどります。例えば上の例では「Doronjo」という名前しか表示されませんでしたが、オプション「-u1」をつけるとメンバーのデータをすべて表示できます。

実行例: メンバー「Doronjo」のデータを表示

 $ ruby yamlgrep -u1 '//members/*/name[.=Doronjo]' datafile.yaml
 ## /teams/1/members/0
 - name: Doronjo
   leader: true
   age: 24

オプション「-z YPath」を使うと、マッチしたパスの末尾に YPath を追加できます。例えば上の例に「-z '/(name|age)'」をつけると、名前と年齢だけが表示されます。

実行例: メンバー「Doronjo」の名前と年齢だけを表示

 $ ruby yamlgrep -u1 -z '/(name|age)' '//members/*/name[.=Doronjo]' datafile.yaml
 ## /teams/1/members/0/(name|age)
 - Doronjo
 - 24

オプション「-q」をつけると、余分な出力 (マッチしたパスおよび空行) を出力しないようにします。例えば全メンバーの名前を検索するには YPath として「//members/*/name」を指定しますが、そのままだとマッチしたパスおよび空行が表示されます。

実行例: 全メンバーの名前を出力 (「-q」なし)

 $ ruby yamlgrep '//members/*/name' datafile.yaml
 ## /teams/0/members/0/name
 - Mujo

 ## /teams/0/members/1/name
 - Tobokkee

 ## /teams/0/members/2/name
 - Donjuro

 ## /teams/1/members/0/name
 - Doronjo

 ## /teams/1/members/1/name
 - Boyakkie

 ## /teams/1/members/2/name
 - Tonzuraa

ここでオプション「-q」をつけると、マッチしたパスおよび空行が表示されなくなります。コマンド「wc -l」でカウントするときなどに便利です。

実行例: 全メンバーの名前を出力 (「-q」あり)

 $ ruby yamlgrep -q '//members/*/name' datafile.yaml
 - Mujo
 - Tobokkee
 - Donjuro
 - Doronjo
 - Boyakkie
 - Tonzuraa

なお yamlgrep では出力も YAML 形式になっているので、yamlgrep の出力を yamlgrep で処理することも可能です。

実行例

ほかの実行例をいくつか示します。

実行例: チーム名の一覧を表示

 $ ruby yamlgrep '/teams/*/name'  datafile.yaml
 ## /teams/0/name
 - Akudaman

 ## /teams/1/name
 - Doronboo

実行例: Akudaman一味を表示

 $ ruby yamlgrep -u1 '/teams/*/name[.=Akudaman]'  datafile.yaml
 ## /teams/0
 - name: Akudaman
   members:
   - name: Mujo
     leader: true
     age: 24
   - name: Tobokkee
     age: 25
   - name: Donjuro
     age: 30

実行例: Mujoさまが所属するチームの名前を表示

 $ ruby yamlgrep -u3 -z '/name' '//members/*/name[.=Mujo]'  datafile.yaml
 ## /teams/0/name
 - Akudaman

実行例: チームリーダをすべて表示

 $ ruby yamlgrep -u1 '//leader[.=yes]'  datafile.yaml
 ## /teams/0/members/0
 - name: Mujo
   leader: true
   age: 24

 ## /teams/1/members/0
 - name: Doronjo
   leader: true
   age: 24

実行例: チームリーダの名前だけを表示

 $ ruby yamlgrep -u1 -z '/name' '//leader[.=yes]'  datafile.yaml
 ## /teams/0/members/0/name
 - Mujo

 ## /teams/1/members/0/name
 - Doronjo

実行例: チームリーダの名前と年齢を表示

 $ ruby yamlgrep -u1 -z '/(name|age)' '//leader[.=yes]'  datafile.yaml
 ## /teams/0/members/0/(name|age)
 - Mujo
 - 24

 ## /teams/1/members/0/(name|age)
 - Doronjo
 - 24

おわりに

今回は YPath について説明しました。YPath とは、YAML ドキュメント中のデータをパス形式で指定したり検索するための仕様です。仕様はまだ議論の最中であり固まっていませんが、Syck では最低限の機能は実装されているので、興味のある方は試してみてください。

なお本連載はこれにて終了です。本当は「車輪の再発明編」と題して YAML パーサの作り方をやろうかと思ったのですが、書いてみると趣味丸出しになってしまったので、苦情がくる前に自主規制しておきます。長いことお付き合いくださりありがとうございました。

著者について

名前:kwatch。三流プログラマー。親戚の子供にお年玉をあげてなかったら、「お年玉あげられないほど貧乏なの? 人生負け組だね。」と 6 歳児にいわれ、かなり鬱。最近のお気に入りは「ダ・ビンチ・コード」。

Last modified:2006/02/20 19:48:03
Keyword(s):[YAML] [YPath] [XPath]
References:[Rubyist Magazine 0013 号] [0013 号 巻頭言] [各号目次] [prep-0013]

*1 実際には YAML::Syck::Node のサブクラスである YAML::Syck::Seq、YAML::Syck::Map、YAML::Syck::Serial が使用されます。

*2 これらのメソッドは YAML::BaseNode モジュールで定義されており、これを YAML::Syck::Node クラスが include しています。

*3 本当なら「.」で現在のノードを、「..」で親のノードを表すはずなのですが、「..」は今のところ動作しないようです。

*4 本来なら「//members/*[age=25]」のように書けるとよいのですが、YPath の仕様が決まってないこともあり、Syck ではこのような探索条件がサポートされていません。