libxml2 の XmlTextReader インタフェースで XML をパースする

libxml2 のドキュメントを眺めていたら,DOM インタフェースや SAX インタフェースだけではなく,XmlTextReader インタフェースというのもありました。

これはドキュメントをパースしながら(ストリーミング)処理をしていくという点で SAX インタフェースに似ているのですが,SAX は callback インタフェースであるのに対して,こちらは能動的に情報を pull するという点でプログラミングがしやすいです。またストリーミング処理なので,(逐一ノード情報を preserve するのでなければ)DOM インタフェースと比べてメモリ消費の点でも優しいです。

PerlXML::LibXML で使ってみました。C での使い方や概念については Libxml2 XmlTextReader Interface tutorial に載っています。

基本的な使い方

use strict;
use warnings;

use XML::LibXML::Reader qw( :types );

my $reader
    = XML::LibXML::Reader->new( location => 'test.xml' );

while ($reader->read()) {
    printf "%d %s%s\n",
        $reader->depth,
        $reader->nodeType == XML_READER_TYPE_END_ELEMENT ? q{/} : q{},
        $reader->name;
}

手順としては,

  1. new()インスタンスを作成する
  2. read() するたびに処理中のノードが進んでいく(カーソル的)

になります。

実行すると,

0 html
1 #text
1 head
2 #text
2 title
3 #text
2 /title
2 #text
1 /head
1 #text
1 body
2 #text
2 div
3 #text
3 span
4 #text
3 /span
3 #text
2 /div
2 #text
2 div
3 #text
3 span
4 #text
3 /span
3 #text
2 /div
2 #text
1 /body
1 #text
0 /html

現在処理中のノードについての情報を,現在の depth も含めて取得できるので便利ですね。

preserveNode() を使ってフィルタリング

XmlTextReader インタフェースのもうひとつのポイントは,処理中のノードを preserve しておくと,最後に preserve したノード群を取得できるというところです。

たとえば,

<html>
    <head>
        <title>XmlTextReader test</title>
    </head>
    <body>
        <div class="skipme">
            <span>Konnichiha, Sekai!</span>
        </div>
        <div class="keepme">
            <span>Hello, world!</span>
        </div>
    </body>
</html>

みたいな HTML があったときに,skipme というクラスが指定されたノードを取り除きたいとします。

use strict;
use warnings;

use XML::LibXML::Reader qw( :types );

my $reader
    = XML::LibXML::Reader->new( location => 'test.xml' );

while ($reader->read()) {
    # 変数名が直感的でないので,エイリアスしてる
    my $node = $reader;

    # 開きエレメント,だったら
    if ($node->nodeType == XML_READER_TYPE_ELEMENT) {
        my $class = $node->getAttribute('class');
        if (defined $class && $class eq 'skipme') {
            # 下位ノードをスキップ
            $node->next();
            next;
        }
    }

    # 開きエレメントで preserveNode() すると
    # 下位ノードもまるごと preserve されるのでよろしくない
    # だから skip
    if ($node->nodeType == XML_READER_TYPE_ELEMENT) {
        next;
    }

    $node->preserveNode();
}
$reader->finish();

# preserve されたドキュメントを document で取り出せる
print $reader->document->toString(1);

このように,手順としては

  1. 保存しておきたいノードについて preserveNode() する
  2. パース終了時に finish() する
  3. document() を呼ぶと,これまで preserve されたノードだけ取得できる

です。

ちょっと注意が必要なのは,開きエレメントで preserveNode() してしまうと,仮に下位ノードを preserve しなくても,ノードツリー全体が preserve されてしまうところです。今回の例だと,<body><html>preserveNode() してしまうと,下位の <div> のところで preserve しなかったとしても,それが最終的なドキュメントに残ってしまいます。なので上記コードではエレメントの開きの部分では preserveNode() していません(閉じエレメントのときに preserve すればそれで OK)。

実行結果は,

<?xml version="1.0"?>
<html>
    <head>
        <title>XmlTextReader test</title>
    </head>
    <body>
        <div class="keepme">
            <span>Hello, world!</span>
        </div>
    </body>
</html>

このように,class="skipme" のところだけ filter out されました。


しかしながら preserveNode() を使っても,条件に合致するノードの削除(反対にいうと条件に合致するノードのみの抽出)しかできません。いまいち使いどころがないような。

DOM と XmlTextReader インタフェースを絡めて aggregate

copyCurrentNode() という API を使うと,現在処理中のノードを DOM ノードとして取得することができます。なので,これと DOM API を絡めると,条件にマッチしたノードを集めて好きなように加工・保存することができます。

たとえば,

<?xml version="1.0"?>
<html>
    <head>
        <title>Opinions</title>
    </head>
    <body>
        <p class="tatemae">建前 1</p>
        <p class="honne">本音 1</p>
        <hr />
        <p class="tatemae">建前 2</p>
        <p class="honne">本音 2</p>
        <hr />
        <p class="tatemae">建前 3</p>
    </body>
</html>

のような HTML から class="tatemae" なところだけ抜き出して XML を生成したいとします。

use strict;
use warnings;

use XML::LibXML;
use XML::LibXML::Reader qw( :types );

# DOM 操作をして XML ドキュメントを生成(ありがちな操作なので解説しません)
my $doc = XML::LibXML::Document->createDocument(1, 'utf-8');
$doc->setStandalone(1);

my $root = $doc->createElement('opinions');
$doc->setDocumentElement($root);

my $reader
    = XML::LibXML::Reader->new( location => 'sample.xml' );

while ($reader->read()) {
    my $node = $reader;

    if ($node->nodeType == XML_READER_TYPE_ELEMENT) {
        my $class = $node->getAttribute('class');
        if (defined $class && $class eq 'tatemae') {
            # 現在のノードを XML::LibXML::Node として取り出す
            # 下位ノードも含め deep copy
            my $hn = $node->copyCurrentNode(1);

            # XML ドキュメントに appendChild() する
            my $item = $doc->createElement('item');
            $item->appendChild($hn->firstChild);

            $root->appendChild($item);
        }
    }

}
$reader->finish();

print $doc->toString(1);

なんだか JavaScript で DOM を操作しているみたいですね。

実行すると,

<?xml version="1" encoding="utf-8" standalone="yes"?>
<opinions>
  <item>建前 1</item>
  <item>建前 2</item>
  <item>建前 3</item>
</opinions>

のようになります。

今回は DOM ドキュメントを生成して appendChild() していきましたが,必ずしもそうする必要はありません。copyCurrentNode() でノードを取得して attribute 等を操作し自力でシリアライズするなどの利用方法も考えられます。

おわりに(自分用メモ)

SAX で触るより使いやすい高位 API なのでこれ使おうと思ったんですが,SAX より高位だと named entity のハンドリングのやり方がまだわからないしなぁ*1

*1:Perl からだと SAX レイヤでもうまくできてないです……