Don't Repeat Yourself

Don't Repeat Yourself (DRY) is a principle of software development aimed at reducing repetition of all kinds. -- wikipedia

Rustで型を強めにつけ、バリデーション情報を型に落とす方法

Rust を読んでいると、さまざまなものに型付けをするコードをよく見かけます。強めに(厳密に)型付けをする文化があるプログラミング言語、と言えるかもれません。

バリデーションチェックに対してもこうした強めの型付けが適用できます。具体的なバリデーションの情報を型情報として落としておくことで、コードを読むだけでバリデーション情報を把握できたり、あるいは誤った値の代入をコンパイルタイムで弾くことができるようになるというメリットを享受できるようになります。

一方で、型情報があまりに複雑化すると、あまりそうした型付け手法に慣れていないプログラマがキャッチアップするのに少し時間がかかったり、あるいはとんでもなく複雑になってそもそもその型を作り切るのが大変というデメリットも生じることになります。

今日紹介する手法は、さまざまなトレードオフの上に成り立つものであり、もし導入の結果、そのプロジェクトにとって得られるものが最適であると判断できるならば利用するとよいと思います。

それでは、実際にどのように型付けできるのかを見ていきましょう。Web アプリケーションではバリデーションチェックはまずまず大きな比重を占める実装であり、今回は Web アプリケーションで使用することを前提としています。

今回の要件

今回は、所定の長さ以下の文字列かどうかを判定するバリデーションを実装したいものとします。8文字以下の判定と、4文字以下の判定を実装することにします。また、文字列は空であってはならないものとします。

バリデーションを通過できなかった場合は、下記のエラーを表現する enum を返すものとします。

#[derive(Debug)]
pub enum ValidationError {
    NotAllowedEmpty,
    InvalidFormat(String),
}

Rust で愚直に実装するとしたら、「特定の条件を満たすかどうかを if 文で判定し、満たさなければエラーにして返す」のような実装が考えられるかもしれません。下記は文字列が空でないか、ならびに8文字以下かをチェックする関数です。

fn maybe_fail(source: String) -> Result<String, ValidationError> {
    if source.is_empty() {
        return Err(ValidationError::NotAllowedEmpty);
    }

    if source.len() > 8 {
        return Err(ValidationError::InvalidFormat("文字列は8文字以下にしてください".to_string()));
    }

    Ok(source)
}

このような実装を、型駆動のプログラミングではどのように実装していくかについて、今回の記事では議論を進めることにします。

デザイン

どういう結果が得られるのか

最終的な使い心地は、たとえば次のようになるようにしたいものとします。

pub struct MyUserId(NonEmptyString<LessThanEqualEight>);

impl MyUserId {
    pub fn new(raw: NonEmptyString<LessThanEqualEight>) -> Result<Self, ValidationError> {
        Ok(MyUserId(raw))
    }
}

fn create_user_id_with_validate() -> Result<MyUserId, ValidationError> {
    let raw_words = "myuseridisover8words".parse()?; // これはエラーになるが
    MyUserId::new(raw_words)
}
  • &str に対する parse を呼び出すと、裏で自動でバリデーションチェックをかけてくれる。内容は、可能ならば後続の処理から型推論される。
  • バリデーションチェックを通った値のみが MyUserId::new に入ってくることになる。どういう値が入っているかは、new 関数の引数の型を見るとわかる。

このコードを実行すると、結果エラーになります。parse の時点で、「文字列が空でないか&8文字以下か」というバリデーションチェックが走るように実装しているためです。

では、どのようにそうした仕組みを実現しているのかについて、これから見ていきます。

実装する

基本的な登場人物

今回主役となる型は NonEmptyString とその型引数に使用されている LessThanEqualEight です。これらは実際にどのようになっているのでしょうか。

NonEmptyString

NonEmptyString は非常に単純な実装で、型引数として V を受け取り、内部には String 型なフィールドと PhantomData を持っているだけの構造体です。PhantomData が必要なのは、 V を実際には使用していないものの、Rust では使用しない型引数を構造体に残したままにするとコンパイルエラーになるためです。それを回避するために、_marker というフィールドが必要になります。

pub struct NonEmptyString<V>
where
    V: ValidateStrategy,
{
    pub value: String,
    _marker: PhantomData<fn() -> V>,
}

V にはさらに ValidationStrategy が必要というトレイト境界が付与されています。この ValidationStrategy が、「どのようなバリデーション規則を適用するか」を型で表現するために重要になります。次はこの ValidationStrategy と、その活用方法について見ていきましょう。

ValidationStrategy

ValidationStrategy はトレイトです。関数に validate をもっています。

pub trait ValidationStrategy {
    fn validate(target: &str) -> Result<String, ValidationError>;
}

先ほど LessThanEqualEight という型引数が少し登場してきました。この型は ValidationStrategy を実装しています。この validate の実装内容は、後々別の箇所で呼び出されることになります。

8文字以下であることのバリデーションチェックの実装内容は、具体的には下記のようになっています。

    pub struct LessThanEqualEight;
    impl ValidationStrategy for LessThanEqualEight {
        fn validate(target: &str) -> Result<String, ValidationError> {
            if target.len() <= 8 {
                Ok(target.to_string())
            } else {
                Err(ValidationError::InvalidFormat(format!(
                    "{} must be less than equal 8",
                    &target
                )))
            }
        }
    }

もし、他のバリデーション規則を追加したいとなった場合には、ValidationStrategy を実装した新しい構造体を用意することで追加できます。今回のサンプルコードでは、rules というモジュールを新たに切り、その中に別のバリデーション規則を用意しています。下記は、たとえば4文字以下となるような文字列かどうかをバリデーションするという規則を追加する例です。

pub mod rules {
    use super::ValidationStrategy;
    use crate::types::error::ValidationError;

    pub struct LessThanEqualFour;
    impl ValidationStrategy for LessThanEqualFour {
        fn validate(target: &str) -> Result<String, ValidationError> {
            if target.len() <= 4 {
                Ok(target.to_string())
            } else {
                Err(ValidationError::InvalidFormat(format!(
                    "{} must be less than equal 4",
                    &target
                )))
            }
        }
    }

    pub struct LessThanEqualEight;
    impl ValidationStrategy for LessThanEqualEight {
        fn validate(target: &str) -> Result<String, ValidationError> {
            if target.len() <= 8 {
                Ok(target.to_string())
            } else {
                Err(ValidationError::InvalidFormat(format!(
                    "{} must be less than equal 8",
                    &target
                )))
            }
        }
    }
}

ここまでで準備は整いました。

// 8文字以下のバリデーションチェックを含む文字列型であると示す型
NonEmptyString<LessThanEqualEight>

// 4文字以下のバリデーションチェックを含む文字列型であると示す型
NonEmptyString<LessThanEqualFour>

使いやすくする

ただ、都度 validate のような関数を呼び出していると、そもそも呼び出し忘れが起きたり、あるいは面倒に感じたりするなど厄介です。この型を生成するタイミングでバリデーションチェックが走ってくれると、非常に使い勝手のよいものになるかもしれません。

今回はたとえば、&str 型から変換するタイミングでバリデーションチェックを起こせるとよいかもしれないというユースケースにあたっているものとします。Rust では、&str 型から任意の型に変換する際の作業を統一的に扱えるようにしてくれる便利な機能があります。FromStr トレイトです。今回は、これを NonEmptyString で実装するようにします。

NonEmptyString には V という「どのようなバリデーション規則を用いるか」を示す型引数がありました。これは ValidationStrategy というトレイトをトレイト境界としてもっています。なので、validate 関数を呼び出すと、あとは解決された具象実装側の validate が実行されることになります。LessThanEqualEight 型なら、8文字以下かどうかのチェックが走りますし、LessThanEqualFour 型なら、4文字以下のチェックが走ります。

impl<T> FromStr for NonEmptyString<T>
where
    T: ValidationStrategy,
{
    type Err = ValidationError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        if s.is_empty() {
            return Err(ValidationError::NotAllowedEmpty);
        }
        let validated = T::validate(s)?;
        Ok(NonEmptyString {
            value: validated,
            _marker: PhantomData,
        })
    }
}

あとは、文字列をパースする関数を呼び出します。型推論が難しい箇所では次のように Turbofish による型の明示が必要になりますが

let _ = "is this less than eight?".parse::<NonEmptyString<LessThanEqualEight>>()?;

後続処理に関数がありそこから型推論が可能な場合には、単に parse 関数を呼び出すだけでよくなります。

let source = "is this less than eight?".parse()?;
// new 関数で NonEmptyString<LessThanEqualEight> を受け取ることがわかっているので、parse の型がここから逆算されて判明する
let user_id = MyUserId::new(source); 

あるいは、NonEmptyString::new のような関数を生やして、そこに FromStr に記述したのと同等の内容を実装してもよいかもしれません。これはどうこの型を使いたいかによると思います。

工夫の余地

  • バリデーション規則の impl の実装が少々面倒くさい: これは専用のマクロを実装することで解消できます。今回は書いていませんが、具象型、バリデーション規則、エラーとして何を返すかを引数として取るマクロを用意し、そのマクロの内部で impl ValidationStrategy for {具象型} のようなコードの生成を行えます。
  • 文字列型以外にも適用したい: FromStr は使えなくなりますが、文字列型以外でも当然実装可能です。ただ、ValidationStrategy はもう少し一般化が可能で、そうした方が便利だと思います。

最終的な実装

gist.github.com

まとめ

  • バリデーション情報を型に落とし込む方法を紹介しました。
  • 型引数を用いることでそれが実現できることを学びました。
  • トレイトを上手に使うと、実装を自動でいくつか導出でき、結果使いやすい形におさめることができることも学びました。