Ruby で XML の処理を行うのに Nokogiri を使うことも多いと思う。
要素に(内容として)文字列を追加するときに,よく注意しないとトンデモない結果になる,という話。
問題なさそうなコード
以下のコードは期待どおりの結果が得られており,とくに問題は無さそうに見える。
require "nokogiri"
# ドキュメントを用意
doc = Nokogiri::XML::Document.new
# 要素を用意
elem = Nokogiri::XML::Element.new("foo", doc)
# 文字列を追加
elem << "Ruby"
# elem の XML テキストを表示
puts elem.to_s
# => <foo>Ruby</foo>
少し補足しておく。
Element.new
の第二引数は何なのか。Nokogiri では,Element は最初から何らかの Document に所属させる必要がある。その Document を指定している。
ただ,elem
が doc
に所属するといっても,XML のツリーに配置されることを意味しない。上記のコードを実行しても doc
はルート要素すらない Document のままである。
elem
への <<
を add_child
に変えても同じである。両者の違いは返り値だけ。
ともかく,Element オブジェクトに対して String オブジェクトを <<
でぶっ込めば,それが要素の「内容」になるようだ。
複数回ぶっ込めば内容が後ろに追記されていく感じになる。
実は問題があった
さきほどのコードでは,何も考えずにカジュアルに文字列をぶっ込んだが,このとき与える文字列は,XML 形式で正しくマークアップされた ものでなくてはならない。
つまり,だ。要素の内容を M&A
にしたければ,文字列としては M&A
などの方法で与えなくてはならないのだ。
つまり
elem << "M&A"
のように書かなければならない。
同様に,要素の内容を <TITLE>
にしたければ
elem << "<TITLE>"
のように書かなければならない。
以下のコードで正しいやり方とその結果を確認しておこう。
require "nokogiri"
doc = Nokogiri::XML::Document.new
elem = Nokogiri::XML::Element.new("foo", doc)
elem << "<A&B>"
# elem の XML テキストを表示
puts elem.to_s
# => <foo><A&B></foo>
# elem の内容を表示
puts elem.content
# => <A&B>
ルールを守らないとどうなるか
前節のルールを知らずに,単に内容そのものを与えるつもりで文字列を渡すとどうなるか。
以下のように実に恐ろしい結果が待ち受けている。
require "nokogiri"
doc = Nokogiri::XML::Document.new
elem = Nokogiri::XML::Element.new("foo", doc)
elem << "Ruby&Python"
# elem の XML テキストを表示
puts elem.to_s
# => <foo>Ruby</foo>
なんと &
以降が消えてしまった。
例外も発生しなかった!
これはどういうことなのか。
おそらくこうだ。
Nokogiri::XML::Element の <<
メソッドに String オブジェクトが与えられた場合,XML 形式でマークアップされた文字列とみなされることは既に述べた。
今の場合,&
は当然,文字参照の開始とみなされる。そのあと文字列(Python
)が続くので,これは名前文字参照のようなのだが,結局 ;
が現れることなく文字列が終わってしまったので,うまく解釈できなかった &Python
をすっ飛ばしてしまったようなのだ。
こんなふうにして消えてしまうのは,解釈不能の部分だけのようだ。
例えば
elem << "Ruby&Python, Rust"
の場合,,
は文字参照の一部とは考えられないので,消えるのはやはり &Python
だけであり,
puts elem.to_s
# => <foo>Ruby, Rust</foo>
のようになる。
Nokogiri のバージョンにも依る
もう少し調べたところ,このような動作になったのは nokogiri のバージョン 1.13.0 以降のようだ。
調べた範囲では,その一つ前のバージョン 1.12.5 までは,こうではなかった。
つまり,"Ruby&Python, Rust"
の &
は文字参照の開始記号としては解釈できないので &
という文字そのものと解釈されていた。
だから,1.12.5 までで,<<
の誤った使い方をしているプロジェクトがあった場合,問題に気づかず,1.13.0 以降に上げたところでバグが発現する,ということが起こりうる。
ご自身のプロジェクトが「ヤバいかも?」と思われた方はどうぞ点検なさってください。
なお,既に述べたように <<
も add_child
もこの点は同じなので,両方の使用箇所を調べましょう。
正しいやり方は?
文字列を内容そのものとして要素に与えるにはどうするのが正しいのか。
もちろん,&
や <
などの特別な文字を文字参照に変換してやればいいのだけれど,String オブジェクトではなくテキストノードを作って渡す方法もある。
このほうが何をやっているか分かりやすいのでないだろうか。
つまりこんなふうに:
require "nokogiri"
doc = Nokogiri::XML::Document.new
elem = Nokogiri::XML::Element.new("foo", doc)
elem << Nokogiri::XML::Text.new("Ruby&Python, Rust", doc)
# elem の XML テキストを表示
puts elem.to_s
# => <foo>Ruby&Python, Rust</foo>
Text.new
の第一引数はマークアップテキストではなく,テキストノードの内容そのものである。
追記:別のやり方
@gemmaro さんのコメントにあるとおり,
elem << Nokogiri::XML::Text.new("Ruby&Python, Rust", doc)
は
elem.content += "Ruby&Python, Rust"
でもいい。こっちのほうが簡潔!
elem
が空の状態でいきなり上記のように書いても問題ないし,また,追記でないなら
elem.content = "Ruby&Python, Rust"
でいい。