Ruby による Win32OLE プログラミング - Msxml による XML プログラミング
Ruby による Win32OLE プログラミング -
Msxml による XML プログラミング
はじめに
XML はデータ交換のための汎用ファイルフォーマットとしての側面にばかりフォーカスが当たっているため,人間サイドの話(XML ファイルを作成するエディタは何がいいかとか,どういう DTD にしたら人が読みやすいかなど)はあまり表に出てきません.でも適切なエディタがあれば,XML は開発文書などの定型文書には最適です.XMLパーサのおかげで,人間にもコンピュータにも読みやすいですからね.
XML ファイルを編集するには,いまのところ Meadow など Emacs 系のエディタで psgml モード を使うのが一番いいと思います.興味のある方は 山本陽平さんによる詳しい解説 をご覧になってください.このモードを使えば,DTD に従う XML ファイルを書くよう強制されるので,開発文書作成などにはもってこいです.開発者に DTD を渡しておけば,みんなそれに従う XML 文書を書くようになりますから.
とはいえ,最初のページに書いたように,最終的な提出文書は XML ではなく Excel ファイルということも多いのです.こういう場合に Ruby の Win32OLE モジュールで Excel にコンバートすればいいわけです.
ところで,なぜ Ruby の XMLParser を使わず Msxml を使うのかと疑問に思う方もいるかもしれません.それは,Windows 環境ではシフト JIS の文字をそのまま扱えた方が楽だから,というのが理由です.でもこれは僕が不勉強なだけでひょっとすると XMLParser の方がいいのかもしれません.
VBE の参照設定
FileSystemObject のときと同様に VBE の参照設定をしてオブジェクトブラウザをオープンします.Msxml のライブラリ名は,`Microsoft XML, version 2.0' です.オブジェクトブラウザのイメージは,下図のようになります.
Msxml モジュール
Msxml のOLE ルートモジュールを定義しましょう.プログラム識別子は `MSXML.DOMDocument' なので次のようになります.
require 'win32ole-ext'
module Msxml
def Msxml.new
return WIN32OLE.new_with_const('MSXML.DOMDocument', Msxml)
end
end
Msxml#new が返す OLE オブジェクトは,XML 文書全体を表す XMLDOMDocument です.このオブジェクトは,nodeType プロパティが NODE_DOCUMENT であるため assert メソッドは次のようになります.
def assert_node_document(document)
assert_not_nil(document)
assert_equals(Msxml::NODE_DOCUMENT, document.nodeType)
end
したがって, この assert_node_document を使ったテストコードは次のようになります.
def test_new
doc = Msxml.new
assert_node_document(doc)
end
XML 文書のロード
次に XML 文書をロードしましょう.XMLDOMDocument オブジェクトに URL を指定して load メソッドを呼べば,そのURL の XML 文書をロードできます.
doc = Msxml.new
doc.async = false
doc.load('sample.xml')
XMLDOMDocument オブジェクトの async プロパティは,ロードを非同期に行うかどうかの設定をします.凝ったことをしない限り,このプロパティは false にしておいた方がいいでしょう.こうするとロードが終わってから次の処理に移ります.この設定を忘れて,完全にロードされてないのにDOM ツリーにアクセスすると Ruby そのものが異常終了してしまうことがあるようです.注意してください.
また, load メソッドに渡す URL はカレントディレクトリも認識してロードできるようです.Excel や Word と違いフルパスでファイル名を指定する必要はありません.
エラー処理
XML 文書をロードするとき,ファイルが存在しない場合や文法エラーなどが起こる場合があります.これらのエラーに対応するため,例外クラス ParseError を定義しましょう.
module Msxml class ParseError < StandardError end end
実際にエラーが起こったかどうかは,ロードした後で XMLDOMDocument オブジェクトの parseError プロパティで判断できます.この処理を Msxml#handle_error で行うことにします.
def Msxml.handle_error(doc, url)
error = doc.parseError
if error.errorCode != 0 then
docpos = ""
if error.line != 0 then
docpos = sprintf(":%d:%d", error.line, error.linepos)
end
msg = sprintf("\n%s%s: %s",
url, docpos, error.reason.gsub(/\r/, ""))
raise ParseError, msg
end
end
上の処理では,エラーメッセージを作成する処理が多少込み入っていますが,基本的には parseError プロパティが返す XMLDOMParse オブジェクトからエラー情報を取得しているだけです.ファイルが存在しない場合のエラーは
not_exist.xml: システム エラー: -2146697210
というようなエラーメッセージが表示されますが,文法エラーの場合は
hello.xml:2:44: 終了タグ 'greeting' が開始タグ 'greting' と一致していません。
などのようなメッセージが表示されます.この形式のエラーメッセージは Meadow 環境で該当ファイルの行とカラムに一気にジャンプできるためとても便利です.
open メソッド
以上をふまえて,エラー処理を含めた XML 文書をオープンするメソッドを次のように定義します.
def Msxml.open(url, preserveWhiteSpace = false, async = false)
doc = new
doc.preserveWhiteSpace = preserveWhiteSpace
doc.async = async
doc.load(url)
begin
handle_error(doc, url)
rescue ParseError
raise $!.type, $!.message, caller
end
return doc
end
第二引数の preserveWhiteSpace は,XML 文書をロードして DOM ツリーを構築する際,文書にある空白文字をそのまま反映させるかどうかを指定します.場合によっては空白文字の情報がそのまま残っている方が便利な場合があるので,ここではオプションとして指定できるようにしました.
open メソッドのテスト
さて,この open メソッドをテストしてみます.今度は,例外クラスの発生を RubyUnit フレームワークが持つ assert_no_exception と assert_exception を使ってテストしてみましょう.これら二つの assert メソッドは,ブロック文を引数に取り,そのブロック内で特定の例外が発生するかしないかをテストできる便利なメソッドです.
まずは,hello.xml という単純な XML ファイルが正しく読みこまれるかどうかのテストです.この場合,ParseError が起こってはいけません.
assert_no_exception(Msxml::ParseError) do
doc = Msxml.open('hello.xml')
assert_node_document(doc)
end
次に,存在しない XML 文書 (not_exist.xml) をロードした場合のテストです.
ex = assert_exception(Msxml::ParseError) do
doc = Msxml.open('not_exist.xml')
end
assert_equals("\nnot_exist.xml: システム エラー: -2146697210\n", ex.message)
assert_exception の引数に,起こらなければならない例外クラスを指定します.実際に起こった例外を返り値として受け取り,エラーメッセージのチェックを行っています.このエラーにある番号の意味はよくわかりません.このテストは,厳しすぎるテスト の一つかもしれませんね.
「厳しすぎるテスト」という言葉は僕が勝手に使っているだけなのですが,テストコードを書いたことがある人なら容易に想像がつくと思います.それは,本来要求されている仕様よりも強い制限がかかったテストコードのことです.そういう意味では,そのテストコードは間違っているといえるかもしれません.
例えば,Java の Servlet など HTML を吐くプログラムのテストを行う場合,単なる文字列比較でチェックすることはできません.厳密にチェックしようと思えば,HTML を解析し,DOM ツリーを構築して各ノードを比較する必要があります.他にも, RDB を扱うシステムでは,SQL文の assert メソッドがほしくなります.この場合も SQL 文をパースし,構文ツリーを作ってから比較・検証する必要があります.でも実際の開発でそんなことやってられませんよね.単なる文字列比較を行ってテストコードを書くぐらいが精一杯でしょう.
このように,テストコードを書く負担を軽減するために「厳しすぎるテスト」を書くのですが,たとえ「厳しすぎるテスト」になったとしてもまったく書かないよりははるかにマシです.とりあえず書きましょう.この場合,あとでテストコードのメンテに苦労することにはなるのですが,しょうがありません.
さて最後に,文法エラーの XML 文書 (error.xml) をロードして文法エラーが起こるかどうかのチェックを行いましょう.
ex = assert_exception(Msxml::ParseError) do
doc = Msxml.open('error.xml')
end
msg = "\nerror.xml:2:25: 終了タグ 'greeting' が開始タグ 'greting' と一致していません。\n"
assert_equals(msg, ex.message)
Msxml#Loader クラス
XML 文書は通常ファイルとして提供されますが,上のようなテストを行う場合,テストごとに新しくファイルを作成するというのは面倒です.そこで,ここでは XML 文書を表す文字列から直接 ロードするためのクラスを定義しましょう.
XMLDOMDocument オブジェクトには,loadXML プロパティ があります.このプロパティを使えば,文字列から直接ロードすることが可能です.
def test_loadXML
doc = Msxml.new
doc.loadXML('<?xml version="1.0"?><greeting>Hello, world!</greeting>')
assert_node_document(doc)
end
このメソッドを利用して,エラー処理を含めた Msxml#Loader クラスを定義しましょう (loader.rb).このクラスの使用例は,以下のようになります.
def test_loader
loader = Msxml::Loader.new
loader.xml = <<XXX
<?xml version="1.0"?>
<greeting>Hello, world!</greeting>
XXX
assert_node_document(loader.doc)
end
Msxml#Loader の xml プロパティに文字列を指定してから,doc プロパティで XMLDOMDocument オブジェクトを取得します.以下のテストコードでは,この Loader クラスを使って書くことにします.
ただ,この loadXML プロパティには問題があって,encoding 宣言を行うとエラーになってしまうようです.この状況も RubyUnit によるテストケースで表しましょう.
def test_loader_encoding
loader = Msxml::Loader.new
ex = assert_exception(Msxml::ParseError) do
loader.xml ='<?xml version="1.0" encoding="UTF-8"?><greeting>Hello, world!</greeting>'
end
assert_match(/現在のエンコードから指定したエンコードへの切り替えはサポートしていません。/,
ex.message)
end
assert_match メソッドは,正規表現が等しいかどうかをテストする RubyUnit の assert メソッドです.上のように, encoding="UTF-8" と宣言すると loadXML はエラーになってしまい,ロードできません.単に宣言しないだけで正しく処理されるようです.
Document Object Model
XML の Document Object Model (DOM) は,XMLDOMNode オブジェクトによって構成されるツリー構造になっています.DOM を一言でいうと,XMLDOMNode による Composite パターンといえるでしょう.下図のように,childNodes プロパティによって子ノードにアクセスできます.
XMLDOMNode オブジェクトは,XML に出てくる構成要素すべての親クラスになっています.先に出てきた,XML 文書全体をあらわす XMLDOMDocument オブジェクトも XMLDOMNode オブジェクトの子クラスですし, XML エレメントを表す XMLDOMElement オブジェクト も そのオブジェクトの子クラスです.XMLエレメントとは,いわゆる開始タグと終了タグで表される部分のことです.XML 文書をパースするには,とりあえずこの三つのオブジェクトについて知っていれば十分でしょう.
Msxml#new によって生成される XMLDOMDocument は,documentElement プロパティ を持ちます.このプロパティは,最上位の XML エレメントを表します.つまり,HTML 文書における <html> ・・・ </html> に相当する エレメントです.最上位の XML エレメントが取得できれば,あとは,childeNodes プロパティで子ノードに順次アクセスできるため,パースするプログラムのサンプルは次のようになります.
def test_childNodes
loader = Msxml::Loader.new
loader.xml = "<root><a></a><a></a></root>"
count = 0
for node in loader.doc.documentElement.childNodes
assert_equals('a', node.nodeName)
count += 1
end
assert_equals(2, count)
end
ところで,XMLDOMDocument オブジェクトの nodeType プロパティは NODE_DOCUMENT でしたが, XMLDOMElement オブジェクトの場合,NODE_ELEMENT になります.したがって assert メソッドは次のようになります.
def assert_node_element(element)
assert_not_nil(element)
assert_equals(Msxml::NODE_ELEMENT, element.nodeType)
end
XMLDOMElement
XML 文書を解析する場合,各 XMLDOMElement の中身を見て処理を行います.プロパティおよびメソッドとして次のものを知っていれば十分だと思います.
- nodeName, tagName プロパティ
タグ名を返す.
- text プロパティ
テキスト部分(タグを除いた部分)の文字列を返す.子エレメントのテキスト部分も含む.
- xml プロパティ
エレメント全体の XML 表現を返す.
- getAttribute メソッド
エレメントの属性の値を返す.
試しに <lang name="ruby" type="script"><strong>Ruby</strong> is a scripting language.</lang> という エレメントについてテストコードを書いてみましょう.
def test_node_element
loader = Msxml::Loader.new
testXML = '<lang name="ruby" type="script"><strong>Ruby</strong> is a scripting language.</lang>'
loader.xml = testXML
lang = loader.doc.documentElement
assert_node_element(lang)
assert_equals("lang", lang.nodeName)
assert_equals("lang", lang.tagName)
assert_equals("Ruby is a scripting language.", lang.text)
assert_equals(testXML, lang.xml)
assert_equals("ruby", lang.getAttribute("name"))
assert_equals("script", lang.getAttribute("type"))
end
getElementsByTagName メソッド
XMLDOMElement オブジェクトには,タグ名を指定してそのタグ名の子ノードのリストを返す getElementsByTagName メソッド があります.実際には,childNodes プロパティを使うよりもこのメソッドを使うことが多いです.というのも,そのほうが特定の XMLDOMElement をとってくるのに便利だし.空白情報を残したオプションでパースしたい場合( preserveWhiteSpace を true にした場合),childNodes プロパティでは,XMLDOMText という空白を表すオブジェクトまで拾ってしまうからです.このことを考えると getElementsByTagName メソッドを使うほうが無難でしょう.
このメソッドを使ったサンプルとして,次の XML 文書( books.xml )をパースしてみましょう.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE 文献リスト [
<!ELEMENT 文献リスト (本*)>
<!ELEMENT 本 (著者+,ISBN,出版社)>
<!ATTLIST 本 名前 CDATA #REQUIRED>
<!ELEMENT 著者 (#PCDATA)>
<!ELEMENT ISBN (#PCDATA)>
<!ELEMENT 出版社 (#PCDATA)>
]>
<文献リスト>
<本 名前="新版 C言語プログラミングのエッセンス">
<著者>結城 浩</著者>
<ISBN>4-89052-868-7</ISBN>
<出版社>ソフトバンク</出版社>
</本>
<本 名前="Designing Object-Oriented Software">
<著者>Rebecca Wirfs-Brock</著者>
<著者>Brian Wilkerson</著者>
<著者>Lauren Wiener</著者>
<ISBN>0-13-629825-7</ISBN>
<出版社>Prentice Hall</出版社>
</本>
<本 名前="Using CRC Cards">
<著者>Nancy M. Wilkinson</著者>
<ISBN>1-884842-07-0</ISBN>
<出版社>SIGS BOOKS</出版社>
</本>
</文献リスト>
次のプログラムは,この XML 文書から本の名前とISBN を拾ってくるものです.
def test_getElementsByTagName
doc = Msxml::open('books.xml')
expected_names = [
"新版 C言語プログラミングのエッセンス",
"Designing Object-Oriented Software",
"Using CRC Cards",
]
expected_isbns = [
"4-89052-868-7",
"0-13-629825-7",
"1-884842-07-0",
]
count = 0
for book in doc.documentElement.getElementsByTagName("本")
name = book.getAttribute("名前")
assert_equals(expected_names[count], name)
isbn = book.getElementsByTagName("ISBN").item(0)
assert_equals(expected_isbns[count], isbn.text)
count += 1
end
assert_equals(expected_names.length, count)
end
なお,for 文内で isbn エレメントをとってくるときに使っている item メソッド は,インデックスを指定して子ノードを返すメソッドです. item(0) とした場合は先頭の子ノードを返します.
Msxml#Iterator クラス
以上のことがわかれば,XML 文書をパースするのに十分なのですが,ここでは Ruby の Enumerable モジュールを使ってもう少し機能を拡張しましょう.Enumerable を使えば,より Ruby らしいプログラムになりますし,拾ってくる XMLDOMElement をソートしたりすることもできます.
childNodes プロパティ と getElementsByTagName メソッドが返すのは,XMLDOMNodeList オブジェクト です.このオブジェクトをラップした Enumerable 機能を持つ外部イテレータ,Msxml#Iterator クラス を定義しましょう.
module Msxml
class Iterator
include Enumerable
def initialize(node, tag_name = nil)
if tag_name
@children = node.getElementsByTagName(tag_name)
else
@children = node.childNodes
end
end
def each(&block)
for node in @children
block.call(node)
end
end
def first
@children.item(0)
end
alias begin first
end
end
each メソッドを定義し,Enumerable をインクルードするだけで Enumerable の全機能が使えるようになります.こういうところは Ruby のすごいところですね.C++ の STL を連想させます.そのほかに利便性を考えて begin (first) という先頭の子ノードを返すメソッドも用意しました.
この Iterator クラスを用いて前節のプログラム,本の名前と ISBN コードを列挙するプログラムを書いてみましょう.そのままでは面白くないので本の名前でソートしてパースするようにしてみます.
def test_iterator
doc = Msxml::open('../books.xml')
expected_names = [
"Designing Object-Oriented Software",
"Using CRC Cards",
"新版 C言語プログラミングのエッセンス",
]
expected_isbns = [
"0-13-629825-7",
"1-884842-07-0",
"4-89052-868-7",
]
count = 0
for book in Msxml::Iterator.new(doc, "本").sort {
|a, b| a.getAttribute("名前") <=> b.getAttribute("名前")
}
name = book.getAttribute("名前")
assert_equals(expected_names[count], name)
isbns = Msxml::Iterator.new(book, "ISBN")
isbn = isbns.first
assert_equals(expected_isbns[count], isbn.text)
count += 1
end
assert_equals(expected_names.length, count)
end
スクリプトファイル
このセクションで用いたプログラムは以下の通りです.自由に使ってください.
-
msxml.rb,
msxmltest.rb,
hello.xml,
error.xml,
books.xml,
Msxml モジュールとテストケース,サンプル XML 文書
-
msxml/loader.rb,
msxml/loadertest.rb
Msxml#Loader クラスとテストケース
-
msxml/iterator.rb,
msxml/iteratortest.rb
Msxml#Iterator クラスとテストケース
