Yabutan 技術ブログ > Rust CSVパーサー Serdeで複雑なレコード構造に対応させる。

Rust CSVパーサー Serdeで複雑なレコード構造に対応させる。

#Rust#Serde#CSV

2023-03-05

この記事では、Rust言語を使ったCSVパーサーを実装する際に、複雑な構造体にマッピングさせたい場合にはどうしたらよいのか? csvクレートと、serdeクレートを使った、構造体へのデシリアライズ処理の書き方について紹介します。

単純な構造であれば、Deserializeをderiveするだけで済みますが、 サフィックス付きのフィールドを配列化したり、特定フィールドに値がある場合にだけ、読み取りたいフィールドがあるなど、 そういう場合には、一度HashMap構造にレコードをバッファリングしてから、デシリアライズ処理をおこなうのが扱いやすいと思います。

今回はそんなやり方を、サンプルとして紹介していきます。

利用するクレート

重要なクレートはこの3つ

  • csv … CSVの読み書きを補助してくれるクレート
  • serde … Serialize/Deserialize の王道クレート
  • serde_json … serdeのJson関連のプラグイン

serde_jsonは、一度HashMap化する際に、serde_json::Value の形で値保持するために使います。 バリアント型みたいな扱いで、値を保持できるので重宝します。

Cargo.toml

~ 省略 ~

[dependencies]
csv = "1.2"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

anyhow = "1.0"
thiserror = "1.0"
indoc = "2.0"

anyhow, thiserror, indocは、使い勝手よくするためにいつも入れてるやつです。

CSVの形

例で使うCSVを用意しました、グループごとに3人ずつのメンバーがいるようなレコードで、 メンバーは1~3のサフィックスで表現されています。

サンプルで使うCSVデータ

GroupId,Name1,Age1,Gender1,Name2,Age2,Gender2,Name3,Age3,Gender3
100,Alice,28,Female,Bob,45,Male,Charlie,32,Male
200,Diana,39,Female,,,,,,
300,George,47,Male,Hannah,29,Female,,,
400,Jessica,27,Female,,,,Karen,35,Female
500,,,,Maggie,33,Female,Nathan,41,Male

ちょっと見づらいので、表にするとこんな感じ。
所々に抜けている部分があって、Nameが空の部分はスキップして読み取れるようにしたい。
data

マッピングしたいレコード構造体

CSVレコードからマッピングしたい構造体がRecordです。

サフィックスのフィールドごとに、Memberという構造でVec配列にして保持させたいという意図。 Nameが空の部分は、Noneとして空きになっている事を表します。

#[derive(Debug)]
struct Record {
    group_id: u32,
    members: Vec<Option<Member>>,
}

#[derive(Debug)]
struct Member {
    name: String,
    age: u8,
    gender: Gender,
}

#[derive(Debug)]
enum Gender {
    Female,
    Male,
}

Serdeのデシリアライズ実装について

serdeのデシリアライズ実装を書く際に、知っておきたい以下の3つをざっくりとだけ説明しておきます。 役割を知っておくとイメージしやすいと思います。

  • Deserializer
  • Visitorトレイト
  • Deserializeトレイト

Deserializerはデータを構文解析し、解析できたトークン単位で、Visitorにデータを受け渡します。
Visitorは、受け取ったトークンデータを好きな形に加工して返す事ができます。
Visitorが作成した加工済みデーターを、Deserializeで最終的な構造体にマッピングします。

こんなイメージ
desirialize

Visitorを実装する。

CSVを処理するにあたって、 カラム毎にHashMap構造になっていると扱いやすいので、そのようなVisitorを実装してみましょう。

FieldMapVisitor

// Visitorが作成するHashMap構造。
pub type FieldMap = HashMap<String, serde_json::Value>;

// HashMapをVisitorに作ってもらう実装。
pub struct FieldMapVisitor;

// Visitor実装
impl<'de> Visitor<'de> for FieldMapVisitor {
    type Value = FieldMap;

    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
        formatter.write_str("a field map")
    }

    fn visit_map<V>(self, mut map: V) -> Result<Self::Value, V::Error>
        where
            V: MapAccess<'de>,
    {
        // HashMapにバッファリングしていく。
        let mut field_map = FieldMap::new();
        while let Some(k) = map.next_key::<&str>()? {
            // valueはserde_json::Valueで取得するのが扱いやすい。
            let value = map.next_value()?;

            if field_map.insert(k.to_owned(), value).is_some() {
                // 重複したフィールドがあった場合はエラーにしておく。
                return Err(de::Error::custom(&format!("duplicate field `{k}`")));
            }
        }
        Ok(field_map)
    }
}

このFieldMapVisitor の役割は、FieldMap と定義した HashMap構造を作る事です。

visit_mapメソッドには、Deserializerが解析したカラム名と、その値がMapAccessという形で渡ってきます。 そこから取得できる、カラム名と値を、HashMap<String, serde_json::Value>の形でバッファリングして返しているだけです。 Visitorで、直接目的のRecord構造を作っても良いと思いますが、汎用的に使えるようにHashMapに一度バッファリングしています。 MapAccessはSeekしていくので、このカラムがあったら~的な判断をして使う場合にはつらいと思うので。

デシリアライズ実装を書く

デシリアライズ処理では、deserializeメソッドに渡ってくるdeserializerと、FieldMapVisitorを使って、 先ほど実装したHashMap構造(FieldMapと定義した)が取得できるようになっています。

あとは、HashMap構造から、好きなようにRecord構造にマッピングしていけばOKです。 今回は、FieldMap → Recordへの変換はTryFromトレイトを使って実装しているので、 Deserializeトレイトの実装コードはこれだけになります。

Deserializeトレイトの実装

// Record構造をデシリアライズする実装。
impl<'de> Deserialize<'de> for Record {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
        where
            D: Deserializer<'de>,
    {
        // Visitorを使ってHashMapを取得。
        let field_map: FieldMap = deserializer.deserialize_map(FieldMapVisitor)?;

        // HashMapから、Record構造にマッピング。
        field_map.try_into().map_err(de::Error::custom)
    }
}

HashMap構造からRecord構造への変換

先ほどのtry_intoで型変換してる部分の処理がこちら。

TryFromトレイトを実装することで、FieldMap → Recordへの変換がtry_intoメソッドで出来るようにしています。


// FieldMapからRecord構造に変換する実装。
impl TryFrom<FieldMap> for Record {
    type Error = anyhow::Error;

    fn try_from(map: FieldMap) -> Result<Self, Self::Error> {
        let group_id = map.get_value_as_u64("GroupId")? as u32;

        let mut members = Vec::new();
        for n in 1..=3_usize {
            let name = map.get_value_as_str(&format!("Name{n}"))?;
            if name.is_empty() {
                // 名前が空ならスキップ
                members.push(None);
                continue;
            }

            let age = map.get_value_as_u64(&format!("Age{n}"))? as u8;

            let gender_field = format!("Gender{n}");
            let gender = map.get_value_as_str(&gender_field)?;
            let gender: Gender = gender
                .parse()
                .map_err(|e| anyhow!("failed to parse Gender caused by {}", e))?;

            members.push(Some(Member {
                name: name.to_owned(),
                age,
                gender,
            }))
        }

        Ok(Record { group_id, members })
    }
}

HashMap構造から取得できなかった時のエラー処理など、冗長コードは FieldMapAccess トレイトを用意して、 get_value_as_str、get_value_as_u64 メソッドとして実装しています。
(コード全体の方に載せておきます)

実装したCSVパーサーを実行

サンプル例を実際に実行するときのコードになります。

実行コード


let input = indoc! {r#"
    GroupId,Name1,Age1,Gender1,Name2,Age2,Gender2,Name3,Age3,Gender3
    100,Alice,28,Female,Bob,45,Male,Charlie,32,Male
    200,Diana,39,Female,,,,,,
    300,George,47,Male,Hannah,29,Female,,,
    400,Jessica,27,Female,,,,Karen,35,Female
    500,,,,Maggie,33,Female,Nathan,41,Male
    "#};

let mut reader = csv::ReaderBuilder::new()
.has_headers(true)
.from_reader(input.as_bytes());

for record in reader.deserialize() {
let record: Record = record ?;
println !("{record:?}");
}

出力結果

Record { group_id: 100, members: [Some(Member { name: "Alice", age: 28, gender: Female }), Some(Member { name: "Bob", age: 45, gender: Male }), Some(Member { name: "Charlie", age: 32, gender: Male })] }
Record { group_id: 200, members: [Some(Member { name: "Diana", age: 39, gender: Female }), None, None] }
Record { group_id: 300, members: [Some(Member { name: "George", age: 47, gender: Male }), Some(Member { name: "Hannah", age: 29, gender: Female }), None] }
Record { group_id: 400, members: [Some(Member { name: "Jessica", age: 27, gender: Female }), None, Some(Member { name: "Karen", age: 35, gender: Female })] }
Record { group_id: 500, members: [None, Some(Member { name: "Maggie", age: 33, gender: Female }), Some(Member { name: "Nathan", age: 41, gender: Male })] }

membersの部分が、Vecで読み取れているのがわかります。

コード全体

省略してる部分もあったので、全コード掲載しておきます。

main.rs

use indoc::indoc;

mod field_map;
mod gender_parse;
mod record_deserialize;

#[derive(Debug)]
struct Record {
    group_id: u32,
    members: Vec<Option<Member>>,
}

#[derive(Debug)]
struct Member {
    name: String,
    age: u8,
    gender: Gender,
}

#[derive(Debug)]
enum Gender {
    Female,
    Male,
}

fn main() -> anyhow::Result<()> {
    let input = indoc! {r#"
    GroupId,Name1,Age1,Gender1,Name2,Age2,Gender2,Name3,Age3,Gender3
    100,Alice,28,Female,Bob,45,Male,Charlie,32,Male
    200,Diana,39,Female,,,,,,
    300,George,47,Male,Hannah,29,Female,,,
    400,Jessica,27,Female,,,,Karen,35,Female
    500,,,,Maggie,33,Female,Nathan,41,Male
    "#};

    let mut reader = csv::ReaderBuilder::new()
        .has_headers(true)
        .from_reader(input.as_bytes());

    for record in reader.deserialize() {
        let record: Record = record?;
        println!("{record:?}");
    }

    Ok(())
}

field_map.rs

use std::collections::HashMap;
use std::fmt;

use serde::de;
use serde::de::{MapAccess, Visitor};

// Visitorが作成するHashMap構造。
pub type FieldMap = HashMap<String, serde_json::Value>;

// HashMapをVisitorに作ってもらう実装。
pub struct FieldMapVisitor;

// FieldMapから値取り出し易くするために書いたトレイト
pub trait FieldMapAccess {
    fn get_value_as_u64(&self, key: &str) -> Result<u64, FieldMapAccessError>;
    fn get_value_as_str(&self, key: &str) -> Result<&str, FieldMapAccessError>;
}

#[derive(Debug, thiserror::Error)]
pub enum FieldMapAccessError {
    #[error("missing field {0}")]
    MissingField(String),

    #[error("illegal value {0}=`{1}`")]
    IllegalValue(String, String),
}

// Visitor実装
impl<'de> Visitor<'de> for FieldMapVisitor {
    type Value = FieldMap;

    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
        formatter.write_str("a field map")
    }

    fn visit_map<V>(self, mut map: V) -> Result<Self::Value, V::Error>
        where
            V: MapAccess<'de>,
    {
        // HashMapにバッファリングしていく。
        let mut field_map = FieldMap::new();
        while let Some(k) = map.next_key::<&str>()? {
            // valueはserde_json::Valueで取得するのが扱いやすい。
            let value = map.next_value()?;

            if field_map.insert(k.to_owned(), value).is_some() {
                // 重複したフィールドがあった場合はエラーにしておく。
                return Err(de::Error::custom(&format!("duplicate field `{k}`")));
            }
        }
        Ok(field_map)
    }
}

impl FieldMapAccess for FieldMap {
    fn get_value_as_u64(&self, key: &str) -> Result<u64, FieldMapAccessError> {
        let value = self
            .get(key)
            .ok_or_else(|| FieldMapAccessError::MissingField(key.to_owned()))?;

        value
            .as_u64()
            .ok_or_else(|| FieldMapAccessError::IllegalValue(key.to_owned(), value.to_string()))
    }

    fn get_value_as_str(&self, key: &str) -> Result<&str, FieldMapAccessError> {
        let value = self
            .get(key)
            .ok_or_else(|| FieldMapAccessError::MissingField(key.to_owned()))?;

        value
            .as_str()
            .ok_or_else(|| FieldMapAccessError::IllegalValue(key.to_owned(), value.to_string()))
    }
}

gender_parse.rs

use std::str::FromStr;

use thiserror::Error;

use crate::Gender;

impl FromStr for Gender {
    type Err = GenderParseError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "Female" => Ok(Gender::Female),
            "Male" => Ok(Gender::Male),
            _ => Err(GenderParseError::IllegalValue(s.to_owned())),
        }
    }
}

#[derive(Debug, Error)]
pub enum GenderParseError {
    #[error("illegal value: `{0}`")]
    IllegalValue(String),
}

record_deserialize.rs

use anyhow::anyhow;
use serde::{de, Deserialize, Deserializer};

use crate::field_map::{FieldMap, FieldMapAccess, FieldMapVisitor};
use crate::{Gender, Member, Record};

// Record構造をデシリアライズする実装。
impl<'de> Deserialize<'de> for Record {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
        where
            D: Deserializer<'de>,
    {
        // Visitorを使ってHashMapを取得。
        let field_map: FieldMap = deserializer.deserialize_map(FieldMapVisitor)?;

        // HashMapから、Record構造にマッピング。
        field_map.try_into().map_err(de::Error::custom)
    }
}

// FieldMapからRecord構造に変換する実装。
impl TryFrom<FieldMap> for Record {
    type Error = anyhow::Error;

    fn try_from(map: FieldMap) -> Result<Self, Self::Error> {
        let group_id = map.get_value_as_u64("GroupId")? as u32;

        let mut members = Vec::new();
        for n in 1..=3_usize {
            let name = map.get_value_as_str(&format!("Name{n}"))?;
            if name.is_empty() {
                // 名前が空ならスキップ
                members.push(None);
                continue;
            }

            let age = map.get_value_as_u64(&format!("Age{n}"))? as u8;

            let gender_field = format!("Gender{n}");
            let gender = map.get_value_as_str(&gender_field)?;
            let gender: Gender = gender
                .parse()
                .map_err(|e| anyhow!("failed to parse Gender caused by {}", e))?;

            members.push(Some(Member {
                name: name.to_owned(),
                age,
                gender,
            }))
        }

        Ok(Record { group_id, members })
    }
}

まとめ

単純な構造であれば、Recordにderiveアノテーションで、Deserializeを付けるだけで済みますが、 今回のような複雑な形にマッピングしたい場合は、自前でデシリアライズ処理を実装していった方が、融通が利くと思います。

  • serdeのデシリアライズを書く際には、Visitorトレイトと、Deserializeトレイトを実装する必要がある。
  • deserializer.deserialize_mapにVisitorインスタンスを渡して、HashMapを作る。
  • HashMapから、目的の構造にマッピングする。

Serdeは、できるだけ中間バッファを作らずに処理できるように設計がされているはずですが、 今回はHashMapのような中間バッファを作って処理してしまっています。 Visitorをうまく使えば、もっと無駄のない実装があるのかもしれないですが、そこまでこだわらない案件であれば参考にしてもらえれば幸いです。