Ruby
XML
Nokogiri
fiber
SAX

nokogiriのSAXで良い感じにXMLをparse

はじめに

大規模なXMLを解析したい場面があり、それならDOMじゃなくてSAXなのかな、ということでnokogiriをFiberによるコルーチンと絡めて使ってみました。

なんでRubyにしたか

当初は普段使いのPerlで書こうとしたのですが、start_element等々のコールバックの中で状態遷移を管理して…、こんなのやってられるかあ!! と爆発しそうになったとき、RubyのFiberがあればすっきり書けるのではないかと思いいたったのです。
※Perlでもコルーチンを書けるモジュールはあるのですが…

方針

ということで、SAXの方で定義するstart_element(タグの開始)、characters(タグに挟まれたデータ)、end_element(タグの終了)では、あくまでコンテキストの取り回しに徹し、例えば次のようなxml構造があったとしたら、

<e1>
 <e2>
  <e3>...</e3>
  <e3>...</e3>
 </e2>
 <e2>
  ...
 </e2>
</e1>

コードの方もそれに合わせてブロックを構成することで対応することを目指します。

parser.enter(){ # e1タグの構造
  parser.enter(){ # e2タグの構造
    paser.enter(){ # 最内ループ
      # e3タグからデータを抽出
    }
  }
}

実装

主処理側

ということで実装例です。
nokogiriのSAXを利用するクラスMySAXParserを用意し、startによって処理を開始、タグの入れ子に応じて上の方針のようにenterでブロックの入れ子を構成します。
もう、単純さ命で、enter時に指定するのはコンテキストと言う名の処理対象のタグ名を保持するハッシュにします。タグの入れ子がある場合には 0、直接データを保持するなら 1 を指定し、ブロック側では対象のタグに辿りつく度に、そのコンテキストと、タグの名前、属性、データがあるならデータを受け取ります。

コンテキストも受け取るようにしているのは、その中身を変えることで、同じ階層で処理するタグを増減させられるようにです。

この例では、e1-e2-e3a,e3b という3階層構造のxmlを解析して、e2タグのa1属性の値、e3a,e3bタグの値を保持するハッシュをリストしたArrayを作るようにしています。
※なお、属性はSAXが返すそのままに、属性名+属性値のペア ( Array ) のArrayとして受け渡されます。

main.rb
require 'sax.rb'
parser = MySAXParser.new
parser.start(DATA.read)
r=[]
parser.enter({"e1"=>0}){|c1,n1,a1|
  parser.enter({"e2"=>0}){|c2,n2,a2|
    h={}
    parser.enter({"e3a"=>1,"e3b"=>1}){|c3,n3,a3,d3|
      h[n3]=d3
    }
    r.push([a2.find{|e|e[0]=="a1"}&.last,h])
  }
  c1.clear # e1タグは1組処理したら終了
}
p r
# =>
# [["xx2", {"e3a"=>"data1", "e3b"=>"data2"}], ["xx3", {"e3a"=>"data3", "e3b"=>"data4"}]]
__END__
<?xml version="1.0" encoding="utf-8" ?>
<e1 a1="xx1" a2="xx2">
  <e2 a1="xx2">
    <e3a>data1</e3a>
    <e3b>data2</e3b>
  </e2>
  <e2 a1="xx3">
    <e3a>data3</e3a>
    <e3b>data4</e3b>
  </e2>
</e1>

ライブラリ側

続いてライブラリ側です。
クラスMySAXParserでは、Nokogiri::XML::SAX::Documentを継承し、各コールバックを実装したDocumentサブクラスと、Fiberを利用しSAX側の処理と主処理側との遷移を管理する本体部分、両者の間で共有するコンテキストを管理するSharedサブクラス(実質タダのハコ)を設けます。

ライブラリ利用者側で指定したコンテキスト ( ハッシュ ) に照らし合わせて、処理対象に該当するタグがその階層で見つかった場合、SAX側の処理を中断して主処理側に遷移するようにコールバック群を構成します。

ライブラリの中心であるenterメソッドではブロックを受け取り、処理対象のタグが出てくるたびにそのブロックにデータを引き渡すループを担当する、という感じです。

sax.rb
class MySAXParser
  require 'nokogiri'
  class Shared
    attr_accessor :ctx,:wm,:cur,:sav,:att,:level
  end
  class Document < Nokogiri::XML::SAX::Document
    def initialize(shared)
      @shared=shared
    end
    def start_element(name,attr=nil)
      t=@shared.wm==@shared.level&&@shared.ctx[name]
      @shared.level+=1
      return unless t
      if t==0
        Fiber.yield(name,attr)
      else
        @shared.cur=name
        @shared.sav=nil
        @shared.att=nil
      end
    end
    def characters(string)
      @shared.sav=string if @shared.cur
    end
    def end_element(name)
      @shared.level-=1
      if @shared.cur
        t=@shared.cur
        @shared.cur=nil
        Fiber.yield(t,@shared.att,@shared.sav)
      else
        Fiber.yield(nil) unless @shared.wm&.<=@shared.level
      end
    end
  end
  def enter(ctx,&block)
    ctxbak=@shared.ctx
    @shared.ctx=ctx
    wmbak=@shared.wm
    @shared.wm=cur=@shared.level
    loop{
      break if @shared.level<cur
      n,a,d=@fiber.resume
      break if !n
      yield(ctx,n,a,d)
    }
    @shared.ctx=ctxbak
    @shared.wm=wmbak
  end
  def start(data)
    @shared=Shared.new
    @shared.level=0
    @fiber=Fiber.new{
      Nokogiri::XML::SAX::Parser.new(Document.new(@shared)).parse(data)
      @shared.level=-1
      Fiber.yield(nil)
    }
  end
end

おわりに

正直SAXを使うのが初めてなのでこんな感じにするのが適切なのかと言われると良く分からないのですが、自分としては書き易くなって良い感じなのではないかと思っています。
なお、ガっと書いて深くテストもしていないので、入り組んだ処理をさせた時に上手く動くかまでは分かっていません。もし参考にされる場合は十分にご注意ください。