🦀

Rustのquick-xmlを使ってXMLをTSVに変換してみた

こんにちは、エンジニアの澤田です。

最近、社内でRustで書かれたプログラムを見かけることが増え、社内ライブラリにRustが導入されたりなど、社内でRustを使う気運が高まっているのを感じ、自分もRustを勉強してプログラムを書いてみよう!と思いました。
自分の業務に近いところでRustを使うのによさそうなテーマを考えたとき、前回書いた記事「 Haskellで階層化されたリストを1次元リストのリストに展開する 」ではXMLからデータを取りだすところができていなかったので、それをRustでやってみようと思います!

※rustc と cargo はバージョン 1.74.0 を使用しています。

どのような手法でXMLをパースするか

XMLをパース(解析)する方法として、主に以下の3つの手法が知られています。

  • DOM(Document Object Model): DOMツリー全体をメモリ上に保持し、要素にアクセスするのにそのツリーを辿る。
  • SAX(Simple API for XML): XMLを逐次的に読み込んで、要素の開始や終了、テキストなどのイベントを発生させる。そのイベントに対応したコールバック関数を設定しておいて、値を取りだすなどの処理はコールバック関数側で行う。コールバック関数側で読み込む側の制御ができないので、扱いづらい側面がある。
  • StAX(Streaming API for XML): SAXに似ているが、XMLの読み込みを進める・中断する、イベントを検知して処理するなどの一連の機能が読み込む側に備わっているので扱いやすい。

普段の業務で巨大なXMLから値を取り出してDBに格納することが多く、DOMだとメモリ不足になるおそれがあるので、SAXかStAX方式がよさそうです。
Rustのライブラリを探したところ quick-xml というStAX方式を採用しているライブラリがあったので、こちらを使ってみようと思います!

quick-xml を使ってみる

まず、 cargo new xml2tsv を実行して、プロジェクト用のディレクトリを作成します。
xml2tsvディレクトリ配下に Cargo.toml というファイルができているので、以下のように記述します。

Cargo.toml
[package]
name = "xml2tsv"
version = "0.1.0"
edition = "2021"

[dependencies]
quick-xml = "0.31.0"

これで quick-xml を使う準備が整いました!(quick-xml はバージョン 0.31.0 を使用しています)

xml2tsv/srcディレクトリ配下にある main.rs に、以下のようにサンプルのXMLを記述し、開始要素の要素名と中身のテキストを出力するコードを書いてみます。

main.rs
use quick_xml::events::Event;
use quick_xml::reader::Reader;

fn main() {
    let xml = r#"
    <group>
        <title>12345</title>
        <item>
            <title>あああ</title>
            <description>hogehoge</description>
        </item>
        <item>
            <title>いいい</title>
            <description>piyopiyo</description>
        </item>
        <item>
            <title>ううう</title>
            <description>foobar</description>
        </item>
    </group>
    "#;
    let mut reader = Reader::from_str(xml);
    reader.trim_text(true);  // 要素の前後の改行・空白(インデントなど)は削除する

    let mut buf = Vec::new();

    loop {
        match reader.read_event_into(&mut buf) {
            Ok(Event::Eof) => break,  // ファイルの終端まできたら処理を終了する

            // 開始イベント
            Ok(Event::Start(e)) => {
                println!("Start: {:?}", String::from_utf8(e.name().as_ref().to_vec()).unwrap())
            }

            // テキストイベント
            Ok(Event::Text(e)) => {
                println!("Text: {:?}", e.unescape().unwrap());
            }

            // その他のイベントは何もしない
            _ => (),
        }
        buf.clear();  // メモリ節約のためbufをクリアする
    }
}

xml2tsvディレクトリ配下で cargo run を実行してみましょう。

$ cargo run
   Compiling xml2tsv v0.1.0 (file:///projects/xml2tsv)
    Finished dev [unoptimized + debuginfo] target(s) in 0.15s
     Running `target/debug/xml2tsv`
Start: "group"
Start: "title"
Text: "12345"
Start: "item"
Start: "title"
Text: "あああ"
Start: "description"
Text: "hogehoge"
Start: "item"
Start: "title"
Text: "いいい"
Start: "description"
Text: "piyopiyo"
Start: "item"
Start: "title"
Text: "ううう"
Start: "description"
Text: "foobar"

無事、要素名とテキストが出力されました!

なお、matchさせるパターンに以下のように Err を追加すると、終了タグが無い場合などに異常終了させることができます。

            Err(e) => panic!("Error has occured: {:?}", e),  // エラーが発生したら異常終了させる

逆にあえて書かなければ、タグに不整合があっても処理を続けることができますね!

指定した要素の内容を出力する

今度は title要素 のときだけその内容を出力してみましょう。
output_flg という、出力するかどうかの状態を保持するbool型の変数を用意して、以下の流れで処理してみます。

  1. 開始イベントで指定した要素(title要素)に一致したら output_flgtrue にする
  2. テキストイベントで output_flgtrue なら、そのテキストを出力し output_flgfalse にする

実際にコードにしてみます。

main.rs
fn main() {
    // -- 途中略 --
    let target_elm_name = "title".to_string();  // 出力対象の要素名
    let mut output_flg = false;

    loop {
        match reader.read_event_into(&mut buf) {
            Ok(Event::Eof) => break,  // ファイルの終端まできたら処理を終了する

            // 開始イベント
            Ok(Event::Start(e)) => {
                let elm_name = String::from_utf8(e.name().as_ref().to_vec()).unwrap();
                if elm_name == target_elm_name {
                    output_flg = true
                }
            }

            // テキストイベント
            Ok(Event::Text(e)) => {
                if output_flg {
                    println!("Text: {:?}", e.unescape().unwrap());
                    output_flg = false
                }
            }

            // その他のイベントは何もしない
            _ => (),
        }
        buf.clear();  // メモリ節約のためbufをクリアする
    }
}

それでは実行してみましょう。

$ cargo run
   Compiling xml2tsv v0.1.0 (file:///projects/xml2tsv)
    Finished dev [unoptimized + debuginfo] target(s) in 0.51s
     Running `target/debug/xml2tsv`
Text: "12345"
Text: "あああ"
Text: "いいい"
Text: "ううう"

title要素の内容だけ出力できました!

コンテキストを考慮する

単純に要素名だけで判定してしまうと、同じ要素名が異なる階層にあったときでも同じように出力されてしまいます。(先の例だと、group要素直下のtitle要素とitem要素内のtitle要素がどちらも出力されてしまっています)
要素名のVectorとして取得する階層を定義して、その階層に一致した場合だけ出力するようにしてみます。

取得する階層(item要素内のtitle要素)を表す target_elm_context というVectorと、XMLを逐次読み込む際の現在の階層を保持する context というVectorを以下のように定義します。

main.rs
    let target_elm_context: Vec<String> = vec!["item".to_string(), "title".to_string()];
    let mut context: Vec<String> = Vec::new();

また、要素の開始イベントで context に要素名を追加し、終了イベントで削除するようにします。
ここで、終了イベントでも要素名を取得するので、 BytesStartBytesEnd をまとめた Enum を定義した上で、要素名を取得する関数を定義しておきます。

main.rs
use quick_xml::events::{Event, BytesStart, BytesEnd};
// -- 途中略 --
    enum BytesTag<'a> {
        Start(&'a BytesStart<'a>),
        End(&'a BytesEnd<'a>),
    }

    fn get_elm_name(tag: &BytesTag) -> String {
        match tag {
            BytesTag::Start(tag) => String::from_utf8(tag.name().as_ref().to_vec()).unwrap(),
            BytesTag::End(tag) => String::from_utf8(tag.name().as_ref().to_vec()).unwrap(),
        }
    }
// -- 途中略 --

そして開始イベントと終了イベントは以下のようになります。
階層が一致するかどうかの判定では context の後半部分が target_elm_context と一致していればよい、としています。(大半のケースはこれで問題無さそうですが、階層が複雑な場合はもっと厳密に判定した方がよいかもしれません…!)

main.rs
// -- 途中略 --
            // 開始イベント
            Ok(Event::Start(start)) => {
                context.push(get_elm_name(&BytesTag::Start(&start)).clone());
                if context.ends_with(&target_elm_context) {
                    output_flg = true
                }
            }

            // 終了イベント
            Ok(Event::End(end)) => {
                if context.last().unwrap() == &get_elm_name(&BytesTag::End(&end)) {
                    context.pop();
                } else {
                    panic!("tags are mismatched!")
                }
            }
// -- 途中略 --

それでは実行してみましょう。

$ cargo run
   Compiling xml2tsv v0.1.0 (file:///projects/xml2tsv)
    Finished dev [unoptimized + debuginfo] target(s) in 0.20s
     Running `target/debug/xml2tsv`
Text: "あああ"
Text: "いいい"
Text: "ううう"

無事、item要素内のtitle要素のテキストだけが出力されました!

要素の順不同を考慮しつつ、複数の項目を取得する

複数の項目を取得するには、先の target_elm_context のVectorを定義してループさせればよさそうですが、単純にループさせるだけだと、出力結果のどの組み合わせでひとまとまりなのか分からなくなってしまいます。
また、要素が出現した順番で出力してしまうと、要素の順番が入れ替わった場合や、要素自体が存在しない場合に、出力結果の項目の順番や入り方がおかしくなってしまいます。
最終的にTSVとして出力してDBに取り込むことを考えると、出力結果の項目の順番は変わらないようにし、空であっても項目自体は必ず存在するようにしたいです。

そこで、個々の項目を管理する TargetItem というStructsと、複数の項目全体を管理する TargetItemSet というStructsを定義してみます。

まずは TargetItem を以下のように定義します。

main.rs
#[derive(Debug)]
struct TargetItem {
    target_context: Vec<String>,
    context_match_flg: bool,
    content: String
}

impl TargetItem {
    // 初期値を設定したインスタンスを返す
    pub fn new(target_elm_context: Vec<&str>) -> TargetItem {
        TargetItem {
            target_context: string_vec(target_elm_context),
            context_match_flg: false,
            content: "".to_string(),
        }
    }

    pub fn set_context_match_flg(&mut self, context: &Vec<String>) {
        if context.ends_with(&self.target_context) {
            self.context_match_flg = true
        }
    }

    pub fn set_content(&mut self, content: String) {
        if self.context_match_flg {
            self.content = content;
            self.context_match_flg = false
        }
    }

    pub fn reset(&mut self) {
        self.context_match_flg = false;
        self.content = "".to_string()
    }
}

fn string_vec(str_vec: Vec<&str>) -> Vec<String> {
    str_vec.iter().map(|x| x.to_string()).collect()
}

ポイントは、コンストラクタ( new 関数)でTargetItemの content フィールドに初期値として空文字を設定しているところで、
要素自体が無くテキストが取得できなかった場合は空文字列が設定されたままになるため、最終的に出力する際に必ず項目が存在するようにしています。
また、コンテキストに一致したかどうかの判定結果を保持する context_match_flg フィールドを用意し、 set_context_match_flg メソッドを通じて、インスタンスごとに値を変更するようにしています。

また、 Vec<&str>Vec<String> に変換する処理が少し複雑に見えるので string_vec 関数も定義して使用しています。

続いて TargetItemSet を以下のように定義します。

main.rs
struct TargetItemSet {
    key_context: Vec<String>,
    target_item_vec: Vec<TargetItem>
}

impl TargetItemSet {
    // インスタンスをそのまま返す
    fn new(key_context: Vec<String>, target_item_vec: Vec<TargetItem>) -> TargetItemSet {
        TargetItemSet {
            key_context,
            target_item_vec
        }
    }

    pub fn reset_context_match_flg_all(&mut self) {
        for target_item in &mut self.target_item_vec {
            target_item.context_match_flg = false
        }
    }

    pub fn reset_all(&mut self) {
        for target_item in &mut self.target_item_vec {
            target_item.reset()
        }
    }

    pub fn get_content_vec(&mut self) -> Vec<String> {
        let mut content_vec = Vec::new();
        for target_item in &mut self.target_item_vec {
            content_vec.push(target_item.content.clone())
        }
        content_vec
    }
}

ひとまとまりとして扱うコンテキストを key_context フィールドに設定し、 target_item_vec フィールドには取得する項目を複数設定できるようにTargetItemのVectorを設定します。
ここでTargetItemのVectorは順番を持っているので、 get_content_vec メソッドでその順番通りにTargetItemの content フィールドの値を出力することで、XML内の要素の順番にかかわらず、指定した順番で出力されるようになります。
reset_context_match_flg_all メソッドと reset_all メソッドの違いは、 reset_context_match_flg_all メソッドでは各TargetItemの content フィールドに値を格納したら context_match_flg を一度クリアし、 reset_all メソッドは、ひとまとまりの項目を出力したら、 context_match_flgcontent の両方をクリアする形になっています。

これまでのところを、実際にコードにしてみしょう!

main.rs
use quick_xml::events::{Event, BytesStart, BytesEnd};
use quick_xml::reader::Reader;

enum BytesTag<'a> {
    Start(&'a BytesStart<'a>),
    End(&'a BytesEnd<'a>)
}

#[derive(Debug)]
struct TargetItem {
    target_context: Vec<String>,
    context_match_flg: bool,
    content: String
}

impl TargetItem {
    pub fn new(target_elm_context: Vec<&str>) -> TargetItem {
        TargetItem {
            target_context: string_vec(target_elm_context),
            context_match_flg: false,
            content: "".to_string(),
        }
    }

    pub fn set_context_match_flg(&mut self, context: &Vec<String>) {
        if context.ends_with(&self.target_context) {
            self.context_match_flg = true
        }
    }

    pub fn set_content(&mut self, content: String) {
        if self.context_match_flg {
            self.content = content;
            self.context_match_flg = false
        }
    }

    pub fn reset(&mut self) {
        self.context_match_flg = false;
        self.content = "".to_string()
    }
}

struct TargetItemSet {
    key_context: Vec<String>,
    target_item_vec: Vec<TargetItem>
}

impl TargetItemSet {
    fn new(key_context: Vec<String>, target_item_vec: Vec<TargetItem>) -> TargetItemSet {
        TargetItemSet {
            key_context,
            target_item_vec
        }
    }

    pub fn reset_context_match_flg_all(&mut self) {
        for target_item in &mut self.target_item_vec {
            target_item.context_match_flg = false
        }
    }

    pub fn reset_all(&mut self) {
        for target_item in &mut self.target_item_vec {
            target_item.reset()
        }
    }

    pub fn get_content_vec(&mut self) -> Vec<String> {
        let mut content_vec = Vec::new();
        for target_item in &mut self.target_item_vec {
            content_vec.push(target_item.content.clone())
        }
        content_vec
    }
}

fn get_elm_name(tag: &BytesTag) -> String {
    match tag {
        BytesTag::Start(tag) => String::from_utf8(tag.name().as_ref().to_vec()).unwrap(),
        BytesTag::End(tag) => String::from_utf8(tag.name().as_ref().to_vec()).unwrap(),
    }
}

fn string_vec(str_vec: Vec<&str>) -> Vec<String> {
    str_vec.iter().map(|x| x.to_string()).collect()
}


fn main() {
    let xml = r#"
    <group>
        <title>12345</title>
        <item>
            <title>あああ</title>
            <summary>hoge</summary>
            <description>hogehoge</description>
        </item>
        <item>
            <title>いいい</title>
            <summary>piyo</summary>
        </item>
        <item>
            <description>foobar</description>
            <title>ううう</title>
        </item>
    </group>
    "#;

    let mut target_item_set = TargetItemSet::new(
        string_vec(vec!["item"]),
        vec![
            TargetItem::new(vec!["item", "title"]),
            TargetItem::new(vec!["item", "description"]),
        ]
    );

    let mut reader = Reader::from_str(xml);
    reader.trim_text(true);

    let mut buf = Vec::new();
    let mut context: Vec<String> = Vec::new();

    loop {
        match reader.read_event_into(&mut buf) {
            Err(e) => panic!("Error has occured: {:?}", e),  // エラーが発生したら異常終了させる

            Ok(Event::Eof) => break,  // ファイルの終端まできたら処理を終了する

            // 開始イベント
            Ok(Event::Start(start)) => {
                let elm_name = get_elm_name(&BytesTag::Start(&start));
                context.push(elm_name.clone());
                for terget_item in &mut target_item_set.target_item_vec {
                    terget_item.set_context_match_flg(&context);
                }
            }

            // 終了イベント
            Ok(Event::End(end)) => {
                if context.last().unwrap() == &get_elm_name(&BytesTag::End(&end)) {
                    if context.ends_with(&target_item_set.key_context) {
                        println!("{:?}", target_item_set.get_content_vec());
                        target_item_set.reset_all()
                    } else {
                        target_item_set.reset_context_match_flg_all()
                    }
                    context.pop();
                } else {
                    panic!("tags are mismatched!")
                }
            }

            // テキストイベント
            Ok(Event::Text(e)) => {
                for target_item in &mut target_item_set.target_item_vec {
                    target_item.set_content(e.unescape().unwrap().to_string());
                }
            }

            // その他のイベントは何もしない
            _ => (),
        }
        buf.clear();  // メモリ節約のためbufをクリアする
    }
}

それでは実行してみます。

$ cargo run
   Compiling xml2tsv v0.1.0 (file:///projects/xml2tsv)
    Finished dev [unoptimized + debuginfo] target(s) in 0.28s
     Running `target/debug/xml2tsv`
["あああ", "hogehoge"]
["いいい", ""]
["ううう", "foobar"]

item要素単位で項目のまとまりが出力され、サンプルのXMLでは2つ目のitem要素にdescription要素がありませんが、空文字として出力されています。
また、3つ目のitem要素ではtitle要素とdescription要素の順序が逆になっていますが、きちんと指定した順番で出力されていますね!

TSVファイルへ書き出す

最後にTSVファイルへ書き出してみましょう。

まず先頭に以下を記述し、必要なライブラリを使えるようにします。

main.rs
@@ -1,3 +1,5 @@
+use std::io::{BufWriter, Write};
+use std::fs::File;
 use quick_xml::events::{Event, BytesStart, BytesEnd};
 use quick_xml::reader::Reader;
 

次に、実際にファイルへの書き込みを行う writer を定義します。
reader の定義の下あたりに以下のように記述します。

main.rs
@@ -117,6 +119,7 @@
 
     let mut reader = Reader::from_str(xml);
     reader.trim_text(true);
+    let mut writer = BufWriter::new(File::create("work/data.tsv").unwrap());
 
     let mut buf = Vec::new();
     let mut context: Vec<String> = Vec::new();

そして終了イベントに記述していた println! マクロの代わりに以下を記述します。

main.rs
@@ -140,7 +143,7 @@
             Ok(Event::End(end)) => {
                 if context.last().unwrap() == &get_elm_name(&BytesTag::End(&end)) {
                     if context.ends_with(&target_item_set.key_context) {
-                        println!("{:?}", target_item_set.get_content_vec());
+                        writer.write_all((target_item_set.get_content_vec().join("\t") + "\n").as_bytes()).unwrap();
                         target_item_set.reset_all()
                     } else {
                         target_item_set.reset_context_match_flg_all()

TSVファイルの出力先を work/data.tsv にしているので、xml2tsvディレクトリ直下にworkディレクトリを作成して、xml2tsvディレクトリ直下で cargo run を実行します。
実行完了したら、 data.tsv の中身を見てみます。

$ cat ./work/data.tsv
あああ	hogehoge
いいい	
ううう	foobar

無事出力されていますね!

さいごに

今回の記事を書く前に、弊社のユニットメンバーで「 The Rust Programming Language 」の読み合わせをしていたのですが、読んだだけではよく分からなかったResult型やOption型の扱い方、String型とstr型の違いなどが、実際にコードを書いて動かしてみることで理解できました。
Vectorの iter()map() を実行しても collect() しないと実行結果が得られない、という点も勉強になりました。(遅延評価になっていて、本当に必要になったときに実際に実行されるんですね)
また、quick-xml のイベントごとの処理の分岐を match式でシンプルに記述できるのも凄いことだな、と感じました。
コンパイルできれば、きちんと動作することが保証される、という安心感も大きいですね!
引き続きRustを勉強しつつ、機会があれば業務でも使っていこうと思います :)

この記事を書いた人

澤田 哲明
大手旅行会社でWebデザイナーとして勤務しつつプログラミングを学び、2012年にフォルシアに入社。
現在は旅行プラットフォーム事業部に所属して、福利厚生アウトソーシング会社などのシステム開発を担当。
最近、子供がゲームの攻略本を見ながら自分で進められるようになっていて驚きました。漢字にルビが振ってあると子供も読めるのでよいですね!

FORCIA Tech Blog

Discussion