はじめに
大規模な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として受け渡されます。
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
メソッドではブロックを受け取り、処理対象のタグが出てくるたびにそのブロックにデータを引き渡すループを担当する、という感じです。
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を使うのが初めてなのでこんな感じにするのが適切なのかと言われると良く分からないのですが、自分としては書き易くなって良い感じなのではないかと思っています。
なお、ガっと書いて深くテストもしていないので、入り組んだ処理をさせた時に上手く動くかまでは分かっていません。もし参考にされる場合は十分にご注意ください。