はじめに
ファイルサイズが数十GB程度あり、ファイルから読み込んだデータを全てメモリ上に展開するには大分無理のあるXMLファイルを扱う機会がありました。そんな時に便利なライブラリnode-xml-stream
を見つけたので使い方をご紹介していきます。
今回、Node.jsのバージョンは18.12.0
、node-xml-stream
のバージョンは1.0.2
を使用しています。
node-xml-streamとは
XML/HTMLファイルを軽量かつ高速に処理するパーサーライブラリです。Node.jsの標準APIであるStream
を使用して作られています。
通常のファイル読み込みではデータを一気にメモリ上に展開してから後続の処理に進むのに対し、Stream
を使用したファイル読み込みではchunk
と呼ばれる単位でファイルを読み込みながら順次処理することができるため、物理メモリサイズを超えたファイルの処理も可能になります。その反面、階層構造が複雑なファイルの読み込みにはあまり適していません。理由は以降で説明します。
振る舞い
ファイルを開いたStream
とパーサーインスタンスをpipe()
で接続すると、ファイルを順次読み込みながら開始タグや終了タグ等が見つかったタイミングでイベントが発火されます。これをハンドリングしてお目当てのデータを取得していくのが基本的な使い方です。
試しに、次のようなイベントを拾うだけのプログラムを作り、サンプルXMLファイルを読み込ませてイベントの発火順序を見てみましょう。
import { Parser } from 'node-xml-stream'
import fs from 'fs'
const parser = new Parser()
// 開始タグが見つかった
parser.on('opentag', (name, attrs) => console.log(`${name} has opened`))
// 終了タグが見つかった
parser.on('closetag', name => console.log(`${name} has closed`))
// テキストが見つかった
parser.on('text', text => console.log(text))
// 読み込みが完了した
parser.on('finish', () => console.log('finish!'))
// Streamとパーサーを接続してファイルを読み込んでいく
const stream = fs.createReadStream('./sample.xml')
stream.pipe(parser)
<?xml version="1.0"?>
<root>
<parent>
<child>foo</child>
<child>bar</child>
</parent>
<parent>
<child>hoge</child>
</parent>
</root>
open: root
open: parent
open: child
text: foo
close: child
open: child
text: bar
close: child
close: parent
open: parent
open: child
text: hoge
close: child
close: parent
close: root
finish
実行結果を見ると分かる通り、各イベントはそれぞれ「何のタグの始まり/終わりが見つかった」「何のタグのテキストが見つかった」等、タグ単品についての情報は教えてくれますが、「親、子、兄弟にはどのようなタグを持つ」といったタグの文脈を教えてはくれません。データの取得時にはだいたい階層構造の決まった位置にあるテキストを取得したいケースがほとんどであると思うので、自前実装で見つかった開始タグと終了タグの名前や出現回数を見ながらデータを取得していく必要があります。
サンプル
タグの開始/終了をフラグ管理し、お目当ての箇所のテキストが見つかったらテキストを取得する例です。これに先程のサンプルXMLファイルを読み込ませると、期待どおりの位置のテキストが取得できていることが確認できます。
サンプルのような小さなXMLファイルではほとんどライブラリによる恩恵は受けられませんが、数GBから数十GBクラスのファイルを読み込ませても、このプログラムが使用する物理メモリ量は高々数百MB程度におさえることができます。
const isOpened = {
root: false,
parent: false,
child: false,
}
parser.on('opentag', (name, attrs) => {
if (!typeof(isOpened[name]) !== 'undefined') {
isOpened[name] = true
}
})
parser.on('closetag', name => {
if (!typeof(isOpened[name]) !== 'undefined') {
isOpened[name] = false
}
})
parser.on('text', text => {
// root > parent > childのテキストを取得する
if (isOpened.root && isOpened.parent && isOpened.child) {
console.log(`child: ${text}`)
}
})
const stream = fs.createReadStream('./sample.xml')
stream.pipe(parser)
child: foo
child: bar
child: hoge
まとめ
巨大なXMLファイルを扱えるnode-xml-stream
をご紹介しました。あまりこのようなユースケースは多くはないとは思いますが、日本語の記事を探しても見当たらなかったので思い切って自分で書いてみました。記載した内容に誤り等があればご指摘ください。また、もっと便利な使い方があるよ~なんてご意見等あればぜひお寄せいただけると嬉しいです。ではでは。