LoginSignup
44
61

More than 1 year has passed since last update.

.NETで自前で入力チェックを行う際に便利なFluentValidationの使い方

Last updated at Posted at 2021-09-01

はじめに

ASP.NETにて入力チェックをするにはDataAnnotationによる標準のValidationフレームワークを利用することができますが、標準機能だけでは業務要件的に全てのケースに対応できない場合には結局自前で検証コードを書くことになる為、検証コードが複数個所に分散することを嫌って標準のValidationを使っていない方もいるかと思います。

また、いろいろな理由でDataAnnotationが使えず、コントローラや業務ロジック側で入力検証をする必要があるケースもあるでしょう。

その際に使用できるシンプルな入力検証フレームワークの一つがFluentValidationです。
https://docs.fluentvalidation.net/

これを使うと、フレームワークでできる部分はフレームワークに任せ、複雑な検証は自前のコードで追加で記述することが容易に可能です。

また、業務システムにおいて重要なカスタムエラーメッセージの指定も柔軟な方法で可能です。
併せて、項目名をモデルクラスのDisplay属性から取得してエラーメッセージに利用する方法もまとめておきます。

個人的なFluentValidationのお気に入りポイント

  • 検証実行条件を指定できる(Whenメソッド)
  • 検証対象のプロパティ名(文字列)ではなくプロパティそのものをラムダ式で渡せる(強い型付け)
  • エラーメッセージの項目名をプロパティのDisplay属性から取ってこれる(自前でコーディングが必要 ※この記事で解説しています)
  • エラー結果から発生元プロパティ名を取得できる
  • 検証用の個別クラスを書かなくても「コード形式チェック」等の独自の検証処理をライブラリ化できる(Must/Customメソッド、拡張メソッドを使用)
  • エラーメッセージの差し替えが可能(自前でコーディングが必要 ※この記事で解説しています)
  • メッセージテンプレート機能が使いやすい

大まかな使い方の説明

基本

事前にモデルクラスに対する各プロパティの入力検証ルールを定義し、Validate()でそれらを実行します。すると結果がValidationResultで返ってきます。

Customer customer = new Customer();
CustomerValidator validator = new CustomerValidator();

ValidationResult results = validator.Validate(customer);

if(! results.IsValid) {
  foreach(var failure in results.Errors) {
    Console.WriteLine("Property " + failure.PropertyName + " failed validation. Error was: " + failure.ErrorMessage);
  }
}

検証結果の取得(ValidationResult)

ValidationResult results = validator.Validate(customer);

上記の結果取得できるValidationResultの主なプロパティは以下の通りです。

プロパティ 説明
Errors List が返ります。
IsValid Errors.Count == 0 の結果が真偽値で返ります。結果がエラーを含むかどうかをシンプルに記述できます。

Errorsの中はValidationFailure型のリストです。主なプロパティは以下の通りです。

プロパティ 説明
PropertyName モデルのプロパティ名です。
ErrorMessage エラーメッセージテンプレートに各種埋め込みが行われた結果のメッセージです。
AttemptedValue エラーの元となったプロパティの値です。object型です。
CustomState WithState(x)で指定した値が入ります。これにより、ユーザーデータを管理できます。
Severity WithSeverity(x)で指定したエラーレベルが入ります。規定値はSeverity.Errorです。FluentValidation自身はこの値を意識しません(Errorを返そうがInfoを返そうがIsValidはFalseを返します)。
ErrorCode WithErrorCode(x)で指定した値か、指定していなければビルトイン検証クラス名が入ります(MustやCustomメソッドを使った場合はnullが入ります)。
FormattedMessagePlaceholderValues Dictionaryです。エラーメッセージに埋め込んだ元の値が入っています。

検証ルールの定義

入力検証ルールの定義は次のように、AbstractValidator<T>を継承してモデル毎に専用のクラスを定義します(クラスを定義しないやり方も後述)。

ただ、やることはシンプルで、コンストラクタでRuleFor(検証ルール定義)を並べて初期化するだけです。

using FluentValidation;

public class CustomerValidator : AbstractValidator<Customer> {
  public CustomerValidator() {
    RuleFor(customer => customer.Surname).NotNull().MaximumLength(30);

    //Greater than a particular value
    RuleFor(customer => customer.CreditLimit).GreaterThan(0);

    //Greater than another property
    RuleFor(customer => customer.CreditLimit).GreaterThan(customer => customer.MinimumCreditLimit);
  }
}

RuleForメソッドにプロパティメンバを返すラムダ式を渡す、というのが非常にスマートです。
最初、これは何をやっているんだろうかと不思議でしたが、これによって、RuleFor内部ではラムダ式の右辺からReflection.MemberInfoを取得し、そこからプロパティ名やプロパティ属性値なども取得可能になっています。

ビルトインの検証メソッド

ビルトインの検証メソッドは次のものが用意されています。
https://docs.fluentvalidation.net/en/latest/built-in-validators.html

  • NotNull Validator
  • NotEmpty Validator
  • NotEqual Validator
  • Equal Validator
  • Length Validator
  • MaxLength Validator
  • MinLength Validator
  • Less Than Validator
  • Less Than Or Equal Validator
  • Greater Than Validator
  • Greater Than Or Equal Validator
  • Predicate Validator
  • Regular Expression Validator
  • Email Validator
  • Credit Card Validator
  • Enum Validator
  • Enum Name Validator
  • Empty Validator
  • Null Validator
  • ExclusiveBetween Validator
  • InclusiveBetween Validator
  • ScalePrecision Validator

カスタム入力検証(Mustメソッド)

ビルトインの検証メソッドでは対応できない部分は、次のようにMustメソッドを使って記述することもできます。
プロパティ値を受け取って真偽値を返す関数を渡すと、その関数の結果がTrueになる時以外は検証エラーとなります。

public class PersonValidator : AbstractValidator<Person> {
  public PersonValidator() {
    RuleFor(x => x.Pets)
      .Must(list => list.Count < 10)
      .WithMessage("The list must contain fewer than 10 items");
  }
}

カスタム入力検証のライブラリ化

この検証ルールを再利用可能な形で定義したい場合、次のように IRuleBuilder<T,TProperty>型の拡張メソッドを定義すれば、NotNullMaximumLengthのようなビルトイン検証メソッドと同様にRuleForの後のメソッドチェーンで呼び出すことができます。
ちょっとこの辺は、拡張メソッドの作り方やジェネリックの書き方に親しんでいないとギョッとしてしまうかもしれませんね。

public static class MyCustomValidators {
  public static IRuleBuilderOptions<T, IList<TElement>> ListMustContainFewerThan<T, TElement>(this IRuleBuilder<T, IList<TElement>> ruleBuilder, int num) {
    return ruleBuilder.Must(list => list.Count < num).WithMessage("The list contains too many items");
  }
}
RuleFor(x => x.Pets).ListMustContainFewerThan(10);

カスタム入力検証のエラーメッセージ

このように自前で作った検証ルールには自前のエラーメッセージが必要になりますが、FluentValidationはメッセージの整形機能についても公開されており、次のように{placeholdername}の形でエラーメッセージをテンプレート化することができます。

public static IRuleBuilderOptions<T, IList<TElement>> ListMustContainFewerThan<T, TElement>(this IRuleBuilder<T, IList<TElement>> ruleBuilder, int num) {

  return ruleBuilder.Must((rootObject, list, context) => {
    context.MessageFormatter.AppendArgument("MaxElements", num);
    return list.Count < num;
  })
  .WithMessage("{PropertyName} must contain fewer than {MaxElements} items.");
}

尚、{PropertyName}はFluentValidationの標準のプレースホルダーで、プロパティ名が入ります。これを日本語の項目名に自動的に置き換えるやり方は後程説明します。

カスタム入力検証のエラーコード

Mustを使った場合、返されるValidationFailureErrorCodeにはnullが入っています。
ErrorCodeを使ってエラー種別を判定したい場合には、WithErrorCodeを使って独自のエラーコードを指定することができます。

public static IRuleBuilderOptions<T, IList<TElement>> ListMustContainFewerThan<T, TElement>(this IRuleBuilder<T, IList<TElement>> ruleBuilder, int num) {

  return ruleBuilder.Must((rootObject, list, context) => {
    context.MessageFormatter.AppendArgument("MaxElements", num);
    return list.Count < num;
  })
  .WithMessage("{PropertyName} must contain fewer than {MaxElements} items.")
  .WithErrorCode("PetsLimitOver");
}

ErrorCodeはメッセージテンプレートを探すキーにもなっている為、以下のように事前に"PetsLimitOver"に対するメッセージテンプレートを定義しておけば、WithMessageを省略できます。エラーメッセージの管理方法については後程詳しく説明します。

Startup.cs
var languageManager = ValidatorOptions.Global.LanguageManager as Resources.LanguageManager;
languageManager.AddTranslation( "ja-JP", "PetsLimitOver", "{PropertyName} は少なくとも{MaxElements}件以上でなければなりません" );
public static IRuleBuilderOptions<T, IList<TElement>> ListMustContainFewerThan<T, TElement>(this IRuleBuilder<T, IList<TElement>> ruleBuilder, int num) {

  return ruleBuilder.Must((rootObject, list, context) => {
    context.MessageFormatter.AppendArgument("MaxElements", num);
    return list.Count < num;
  })
  .WithErrorCode("PetsLimitOver");
}

複雑なカスタム検証(Customメソッド)

Mustだけでは対応できないような複雑な検証には、Customメソッドで対応可能です。このやり方だと、AddFailureを複数回呼んで、複数のエラーを同時に返すことも可能です。

但しこの方法は直接ResultFailureオブジェクトを生成する(フレームワークに結果の生成を任せない)やり方なので、メッセージテンプレート機能が使えません。

public class PersonValidator : AbstractValidator<Person> {
  public PersonValidator() {
   RuleFor(x => x.Pets).Custom((list, context) => {
     if(list.Count > 10) {
       context.AddFailure("The list must contain 10 items or fewer");
     }
   });
  }
}

もしCustomメソッドでもメッセージテンプレート機能が必要な場合は、組み込みのメッセージ整形機構であるMessageFormatterを直接利用することができます。

public class PersonValidator : AbstractValidator<Person> {
  public PersonValidator() {
   RuleFor(x => x.Pets).Custom((list, context) => {
     if(list.Count > 10) {
       context.MessageFormatter.AppendPropertyName(context.DisplayName);
       var failure = new Results.ValidationFailure(
           context.PropertyName, 
           context.MessageFormatter.BuildMessage("{PropertyName} must contain 10 items or fewer")
       );
       failure.ErrorCode = "PetsLimitOver";

       context.AddFailure(failure);
     }
   });
  }
}

また、もし事前定義したメッセージテンプレートをErrorCodeで取得したい場合には、LanguageManagerを直接参照して次のように書くこともできます。
ただ、どうしても煩雑ですので、基本的にはMustメソッドとWithErrorCodeを使って検証コードを書いた方が無難でしょう。

public class PersonValidator : AbstractValidator<Person> {
  public PersonValidator() {
   RuleFor(x => x.Pets).Custom((list, context) => {
     if(list.Count > 10) {
       string errorCode = "PetsLimitOver";
       string messageTemplate = ValidatorOptions.Global.LanguageManager.GetString(errorCode);
       context.MessageFormatter.AppendPropertyName(context.DisplayName);
       var failure = new Results.ValidationFailure(
           context.PropertyName, 
           context.MessageFormatter.BuildMessage(messageTemplate)
       );
       failure.ErrorCode = errorCode;

       context.AddFailure(failure);
     }
   });
  }
}

さらに再利用可能な検証コードを書くために、PropertyValidator<T, TProperty>クラスを継承したプロパティバリデータを書く事もできますが、ここまでくると少し高度になってくるので、通常は不要であると書かれています(この記事でも紹介しません)。

クラス定義不要なInlineValidator

作者的にはあまり推奨していないようですが、検証用のクラスをAbstractValidator<T>を継承して別途作らずとも、InlineValidatorクラスを用いて以下のような使い捨ての検証コードを書いて実行することもできます。個人的にはとても便利だと思います。

Customer customer = new Customer();
var v = new InlineValidator<Customer>();

v.RuleFor(x => x.Name).NotNull();
v.RuleFor(x => x.Address).MaximumLength(100);

ValidationResult results = v.Validate(customer);

ちなみにこれをこう書くこともできます(作者推奨の書き方のようです)。

Customer customer = new Customer();
var validator = new InlineValidator<Customer> {
    v => v.RuleFor(x => x.Name).NotNull()
};

ValidationResult results = validator.Validate(customer);

最初「えっ? C#にこんな初期化構文あったっけ?」と驚いたのですが、よく見るとこれはC#のコレクション初期化子構文です。

InlineValidatorIEnumerableを実装しており、Add(Func<InlineValidator<T>, IRuleBuilderOptions<T, TProperty>> ruleCreator)を持っています。IEnumerableかつAddを持っているとコレクション初期化子で初期化できるというC#の仕様を利用しているわけです。

Add内部でruleCreator(this)を実行しているので、上記のようにインスタンス初期化用のラムダ式や匿名関数をコレクション初期化子を通して渡せるようになっています。

中括弧の中に初期化処理が書かれているように見えるのは、実は単なる匿名関数のリストだったわけです。

ですので、次のようにカンマで複数並べることもできますし、

var validator = new InlineValidator<Customer> {
    v => v.RuleFor(x => x.Name).NotNull(),
    v => v.RuleFor(x => x.Address).MaximumLength(100)
};

次のように1つの匿名関数の中でいろいろやっても大丈夫です。

var validator = new InlineValidator<Customer> {
    v => {
        v.RuleFor(x => x.Name).NotNull();
        v.RuleFor(x => x.Address).MaximumLength(100);
        return null;
    }
};

ただ、なんというかこれは、だいぶマニアックな気がしますね(汗)。C#によほど慣れている人でないと、裏で何が起こるか理解できないのではないでしょうか。

無難に最初のやり方で書くか、変数スコープが気になるのであれば、普通に検証クラスを作った方が良い気がします。

独自の検証コード作成サンプル

例えばあなたのプロジェクトに既に入力チェック用のシンプルな社内ライブラリが存在し、以下のような数値チェックのプログラムがあったとします。

class SyanaiValidation
{
    public static bool IsNumber(string value)
    {
        ...
    }
}

これをFluentValidationに組み込むには、次のようにします。

class SyanaiFluentValidationExtentions
{
    public static IRuleBuilderOptions<T, string> IsNumber<T>(this IRuleBuilder<T, string> ruleBuilder) {
        return ruleBuilder.Must((T rootObject, string propertyValue, ValidationContext<T> context) => {
            return SyanaiValidation.IsNumber(propertyValue);
        }).WithErrorCode("Syanai_IsNumberError");
    }
}

propertyValueTProperty型のままだとSayanaiValidation.IsNumber(string)を呼び出せないので、TPropertyの型がstringに置き換えられている点に注目して下さい。これにより、string型のプロパティ以外ではこの拡張メソッドを使用できなくなります。

使う際には、以下のようにメソッドチェーンで使用できます。

RuleFor(x => x.Age).NotNull().IsNumber();

"Syanai_IsNumberError"というエラーコードに対するエラーメッセージの定義方法は、後程説明していきます。

階層構造のあるモデルの検証

例えば次のような階層構造を持つモデルクラスがあったとします。
CustomerがAddressを持っている構造です。

public class Customer {
  public string Name { get; set; }
  public Address Address { get; set; }
}

public class Address {
  public string Line1 { get; set; }
  public string Line2 { get; set; }
  public string Town { get; set; }
  public string County { get; set; }
  public string Postcode { get; set; }
}

このような場合には、先にAddress用の検証クラスAddressValidatorを作っておいて、Customer側の検証ルール作成のところではSetValidatorを使って次のように書きます。

public class CustomerValidator : AbstractValidator<Customer> {
  public CustomerValidator() {
    RuleFor(customer => customer.Name).NotNull();
    RuleFor(customer => customer.Address).SetValidator(new AddressValidator());
  }
}

めちゃくちゃわかりやすくてシンプルですね。

リスト構造のあるモデルの検証

文字列型や整数型などのプリミティブ型のリストについては、RuleForEachを使って次のように書きます。

public class Person {
  public List<string> AddressLines { get; set; } = new List<string>();
}

public class PersonValidator : AbstractValidator<Person> {
  public PersonValidator() {
    RuleForEach(x => x.AddressLines).NotNull();
  }
}

とてもわかりやすいですね!

また、クラス型のオブジェクトのリストについては、SetValidatorRuleForEachを組み合わせて次のように書きます。

public class Customer {
  public List<Order> Orders { get; set; } = new List<Order>();
}

public class Order {
  public double Total { get; set; }
}
public class OrderValidator : AbstractValidator<Order> {
  public OrderValidator() {
    RuleFor(x => x.Total).GreaterThan(0);
  }
}

public class CustomerValidator : AbstractValidator<Customer> {
  public CustomerValidator() {
    RuleForEach(x => x.Orders).SetValidator(new OrderValidator());
  }
}

非常に直感的です。

FluentValidationのエラーメッセージの管理方法

業務で入力検証フレームワークを使うにあたって最初に考えるのは、エラーメッセージをどうカスタマイズできるかだと思います。

FluentValidationでは、プレースホルダーが指定されたメッセージテンプレートを元にエラーメッセージを生成します。
プレースホルダーをユーザーが作ることも可能で、規定では次のプレースホルダーが有効です。

プレースホルダー 説明
{PropertyName} エラーの原因となったプロパティ名。DisplayNameが指定されていた場合、そちらが優先される。
{PropertyValue} エラーの原因となったプロパティ値。

ビルトインの検証クラスが生成するエラーメッセージは、以下の流れで生成されます。

  1. エラーメッセージテンプレート(例"{PropertyName}は必須です")を取得する。
  2. PropertyName等の埋め込みデータを用意する。
  3. エラーメッセージテンプレートに埋め込みデータを適用する。

エラーメッセージテンプレートは、以下の流れで取得されます(LengthValidatorの場合)。

  1. RulesFor(xx).NotNull().WithMesage(string)のような形で外部からWithMessageWithCodeを使って指定された場合、そちらが優先される。
  2. LengthValidator.Name = "LengthValidator" これがLengthValidatorのエラーメッセージIDになる。
  3. エラーが起こると、ValidatorOptions.Global.LanguageManager.GetString(Name, culture) が呼ばれる。errorCodeが設定されていた場合、.GetString(errorCode, culture) が呼ばれる。
  4. LanguageManagerFluentValidation.Resources.JapaneseLanguage.GetTranslation(key)を呼び出す。
  5. JapaneseLanguageクラス内にswitchを使ったリテラルでメッセージが記述されており、"LengthValidator"というキーに対する文字列"'{PropertyName}' は {MinLength} から {MaxLength} 文字の間で入力する必要があります。 {TotalLength} 文字入力されています。"が返される。但し、もし内部ディクショナリ _languagesにキーに対応するメッセージが存在すればそれを優先して返す。

こうして決定されたエラーメッセージテンプレートは、MessageFormatterクラスのBuildMessage(string messageTemplate)によって、メッセージテンプレートの{PropertyName}{MinLength}等のプレースホルダーキーを実際の値に変換されます。この値はAppendArgumentを用いて各検証クラスが指定したものです。
そして、このMessageFormatterクラスは、ValidatorOptions.Global.MessageFormatterFactoryによって生成されます。

正直だいぶ複雑で、上記を分析するのにひたすらソースコードとドキュメントを追いましたが、要約すると次の通りとなります。

  1. ビルトイン検証クラスのデフォルトメッセージテンプレートは国際化対応されているものの、ソースコード直書きである。
  2. その場限りでエラーメッセージを変更するにはWithMesageWithErrorCodeを使う方法がある。
  3. 特定のビルトイン検証クラスのメッセージをグローバルに差し替えたい場合には、LanguageManager.AddTransration(culture, key, message)を使って上書き指定する。
  4. 全てをグローバルに差し替えたい場合には、LanguageManagerをカスタムで作って差し替える。

それぞれ詳細は次の通りとなります。

WithMessage を使って差し替える

RuleFor(customer => customer.Surname).NotNull().WithMessage("{PropertyName}は必須です");

WithMessageメソッドにはmessageProvider関数を渡す事もできます。messageProvider関数の引数xには、検証対象のモデルオブジェクトが渡されます。
これを用いて以下のようなローカライズ対応をすることも可能ですが、NotNull検証を使うたびに個別のWithMessageを指定するのはかなりの手間だと思われますし、間違いも起こりやすいでしょう。
尚、messageProvider関数は必要になったタイミングで遅延実行されるようですので、DBなどから読み込む場合には注意が必要です。

RuleFor(x => x.Surname).NotNull().WithMessage(x => MyLocalizedMessages.SurnameRequired);

WithErrorCodeを使って差し替える

メッセージ本文ではなく、エラーコードを指定して差し替えることができます。エラーコードとは、メッセージテンプレートを取得するためのキーです。ビルトインの検証クラスでは、エラーコードは検証クラス名が設定されています。

WithErrorCodeを使って独自のエラーコードが指定されていた場合、それがメッセージキーとして優先され、LanguageManager.GetStringに渡されます。
よって、指定したエラーコードに対するエラーメッセージはあらかじめLanguageManagerに登録しておく必要があります。

Startup.cs
var languageManager = ValidatorOptions.Global.LanguageManager as Resources.LanguageManager;
languageManager.AddTranslation( "ja-JP", "E101", "{PropertyName}は必須です" );
検証ルール
RuleFor(x => x.Surname).NotNull().WithErrorCode("E101");

カスタムメッセージを一元管理したいだけならこのやり方が最もシンプルでしょう。
但し、この方法で登録する場合、システムで利用する全てのメッセージを事前にLanguageManagerに読み込んでおく必要があります。
例えばASP.NET Coreの場合Startup.csに(ASP.NET無印の場合、Global.asaxに)記述しておけば問題ありません。
もしメッセージ量が多すぎるとか、リアルタイムにメッセージDBから読み込みたいというような場合には、この方法は使えません。

特定のビルトイン検証クラスのメッセージをグローバルに差し替える

LanguageManager.GetStringが受け取るメッセージキーは基本的にWithErrorCodeで渡されるものと同等で、WithErrorCodeが指定されなかった場合には、単にその検証クラスの名称(例えばLengthValidatorの場合、"LengthValidator")がキーとして渡されるようです。

よって、NotNullValidatorのエラーメッセージをグローバルに差し替えるには、次のようにします。

Startup.cs
var languageManager = ValidatorOptions.Global.LanguageManager as Resources.LanguageManager;
languageManager.AddTranslation( "ja-JP", "NotNullValidator", "{PropertyName}は必須です" );

全てのビルトイン検証クラスのメッセージをまとめて差し替える

LanguageManagerのサブクラスを作って差し替えることが可能です。

CustomLanguageManager.cs
class CustomLanguageManager : FluentValidation.Resources.LanguageManager
{
    public CustomLanguageManager() 
    {
        this.AddTranslation("ja-JP", "NotNullValidator", "{PropertyName}は必須です");
        this.AddTranslation("ja-JP", "MinimumLengthValidator", "{PropertyName}が{MaxLength}を超えています");
    }
}
Startup.cs
ValidatorOptions.Global.LanguageManager = new CustomLanguageManager()

公式サイトでは上記のようにコンストラクタでメッセージを一括で設定するやり方が紹介されていますが、この内容ですと、Startup.csでAddTranslationするのと大して変わりません。

そこで以下のようにGetStringをオーバーライドする方法を使うと、メッセージテンプレートの取得処理を完全に一元管理&コントロールできるようになります。例えば「エラーが発生したタイミングでDBからエラーメッセージテンプレートを取得したい」というような事も可能になります。

インタフェースを介することで、DIにも対応可能となります。

CustomLanguageManager.cs
interface IMesasgeResolver
{
    public string GetString(string key);
}

class DBMessageResolver : IMessageResolver
{
    private MyDbContext _db;

    public DBMessageResolver(MyDbContext db)
    {
        _db = db;
    }

    public string GetString(string key)
    {
        return (
            from m 
            in this._db.Messages 
            where m.MessageKey == key 
            select m.Message
        ).FirstOrDefault();
    }
}

class CustomLanguageManager : FluentValidation.Resources.LanguageManager
{
    private IMessageResolver _messageResolver;  // IMessageResolverは自前で定義するインタフェース

    public CustomLanguageManager(IMessageResolver messageResolver)
    {
        _messageResolver = messageResolver;
    }

    public override string GetString(string key, CultureInfo culture = null) 
    {
        string message = this._messageResolver.GetString(key);
        return message ?? base.GetString(key, calture);
    }
}

(追記)
 注意:このやり方で生成したCustomeLanguageManagerは、GlobalなSingletonとなります。よって、ASP.NETのような並列処理前提のアーキテクチャで動作させる際にはマルチスレッド対応が必要になります(この例のMyDbContextはリクエスト毎に生成される事を前提にしていますが、現状、それをValidatorOptions.Global.LanguageManagerにDIする方法を見つけられていません)。
 埋め込みリソースやハードコーディングなリテラル文字列を読み込むLanguageManagerならばマルチスレッドを考慮する必要は通常ほとんどありませんが、この例のようにリアルタイムにDBから読み込むようなケースでは重大な問題になるでしょう。解決策が見つかるまでは、DBからのメッセージリソース読み込みはリアルタイムに行うのではなく、Startup時に一度行うだけにすべきです。

(追記2)
上記の件について、作者に質問してみました。
https://github.com/FluentValidation/FluentValidation/issues/1841
回答としては、「LanguageManagerはビルトイン検証クラス用のメッセージを差し替える為のものであり、グローバルなシングルトンとして使われることを前提としている為、このようなDBからメッセージを取得するような使い方はできない」というものでした。
そのような使い方をしたければValidatorにコンストラクタインジェクションでリソースを渡して、WithMessageを使って差し替えて下さいという回答だったのですが、ビルトイン検証クラスのメッセージにまで毎回WithMessageを使って個別メッセージを指定していたら大変面倒なので、それらのメッセージについてのみ、事前にDBから読み込む形にしようと思います。

(追記3)
思いつきですが、DBMessageResolverに Func<MyDbContext>を渡して、その関数でDIコンテナからリクエスト単位のDbContextインスタンスを取得して返すようにすれば、いけそうですね…(悪い顔)。ただ、作者は「そういうのはやめてくれ」と言いそうです。個人的には、確かにリアルタイムにメッセージリソースをDBから取得するのはパフォーマンスが良くないのですが、業務要件上そうせざるをえないパターンもあるのですよね…。

プロパティ名の日本語化

規定では、{PropertyName}はモデルのプロパティ名がそのまま使われます。customer.SurnameプロパティのNotNullエラーならば、

Surnameは必須です

のようになります。

これを差し替えるには2つの方法があります。

WithNameを使ってプロパティ名を差し替える

WithNameメソッドを使うと、その場限りでプロパティ名を差し替えることができます。

RuleFor(customer => customer.Surname).NotNull().WithMessage("{PropertyName}は必須です").WithName("苗字");
 苗字は必須です

尚、WithMessageの引数には遅延実行の為の関数を指定することも可能です。

DisplayNameResolverを使ってグローバルに差し替える

以下の方法で、プロパティ名→任意の文字列 への変換関数をグローバルに指定することができます。
resolver関数の型はFunc<Type, Reflection.MemberInfo, Expressions.LambdaExpression, string>です。

Startup.cs
ValidatorOptions.Global.DisplayNameResolver = (modelType, memberInfo, expression) => resolver;

この方法を使って、Display属性を読み取るやり方をご紹介します。

Display属性を読み取ってエラーメッセージのプロパティ名表示に使う。

DataAnnotationsのDisplay属性をクラス定義から読み取った結果をプロパティ名と差し替えるには、先ほどのDisplayNameResolverを使って次のようにします。
(以前のFluentValidationのバージョンはこれを規定でやってくれたようですが、ASP.NET Coreになって不整合が起き、規定では何もしなくなったようです)

public class Customer
{
    [Display(Name="苗字")]
    public string Surname { get; set; }
    :
}
Startup.cs
ValidatorOptions.Global.DisplayNameResolver = (modelType, memberInfo, expression) => 
{
    var displayName = memberInfo.GetCustomAttribute<System.ComponentModel.DataAnnotations.DisplayAttribute>()?.GetName();
    return displayName ?? memberInfo.Name;
};

エラーメッセージの{PropertyName}プレースホルダーには基本的にはPropertyNameが入りますが、DisplayNameResolverが指定されていた場合はそちらが使われます。

但し、ValidationContextPropertyNameには、常に実際のプロパティ名が入っています。ValidationFailurePropertyNameも同様です。DisplayNameはエラーメッセージの生成時にのみ、PropertyNameの代わりに利用されます。

これによって、エラーメッセージの生成を完全にFluentValidationに任せることができるようになります。

その他

入力エラー1件目で残りはスキップする(Cascade modeの設定)

規定では、FluentValidationは一つのエラーが起きた後も残りの検証も全て実施します。

例えば次のような検証ルールにおいて、入力値が空でNotNullでエラーを検出した場合でも、続けてNotEqual("foo")の検証も行います。
結果、2つのエラーがValidationResult.Errorsに返ります。

RuleFor(x => x.Surname).NotNull().NotEqual("foo");

これが不要な場合、以下のように指定すると、1件目のエラーでやめてValidationResultを返します。

RuleFor(x => x.Surname).Cascade(CascadeMode.Stop).NotNull().NotEqual("foo");

もし、システムの全ての検証において同じ設定を行いたい場合には、スタートアップにて以下のグローバル設定を行います。

ValidatorOptions.Global.CascadeMode = CascadeMode.Stop;

Whenメソッドで「検証を実行する前提条件」を指定

例えばあるコード項目があり、Code Subcode のような2つの項目に入力欄が分かれていたとします。
そしてこれらは2つとも入力されている場合にのみ入力検証を行い、全て空ならば入力エラーとしない、とします。

単純にNotNull条件を付けてしまうと、両方空にすることができなくなってしまいます。
そこで、Whenメソッドを使って、この入力検証に対する条件として「もう一つの入力欄が空でなければ」を追加します。

RuleFor(x => x.Code).NotNull().When(x => ! String.IsNullOrEmpty(x.Subcode));
RuleFor(x => x.Subode).NotNull().When(x => ! String.IsNullOrEmpty(x.Code));

尚、Whenの指定は、それ以前に指定した検証に対してのみ影響します。具体的にWhenの範囲を指定する方法もあります。

個別のプロパティにそれぞれWhenをつけて整合性を考えるのが複雑すぎる場合、複数のルールに対するWhenをまとめて指定することもできます。

When(x => ! (String.IsNullOrEmpty(x.Code) && String.IsNullOrEmpty(x.Subcode)), () => {
   RuleFor(x => x.Code).NotNull();
   RuleFor(x => x.Subcode).NotNull();
});

詳しくはConditionsの項目を参照してください。
https://docs.fluentvalidation.net/en/latest/conditions.html#

FluentValidationにはDependentRulesというメソッドもあり、依存関係にある検証を関連づけることができますが、作者としては「読みにくくなるのでWhenCascadeModeを組み合わせて使った方が良い。明らかにDependentRulesを使った方が見通しがよくなる場合のみ使うと良い」とのことです。
https://docs.fluentvalidation.net/en/latest/conditions.html#dependent-rules

FluentValidationのあまり好きではないポイント

個人的に、FluentValidationのあまり好きではない点を挙げておきます。

頻繁なアップデートに伴う仕様変更

アップデートが今でも頻繁に行われており、この1年間でも約2回のメジャーアップデートが行われています。
それ自体は良いのですが、その際に、後方互換性のない破壊的な変更が毎回行われており、最新版にアップデートしようとするとコードの修正が必要になっています。

エラーメッセージに依存したValidationResult

設計思想として、「エラー結果はエラーメッセージで返す」というのがベースになっているようで、エラーコードはエラーメッセージを生成する為のキーでしかないような扱いです。

その為、エラー結果を「このエラーの場合は〇〇をして、このエラーの場合は△△をして…」のように処理したい場合には、この記事で解説しているように、エラーコードを都度指定する少し面倒な管理が必要になります。

例えばMustメソッドを使って定義した検証ルールは、基本的にErrorCodeを何も返しません。それを返すには、別途WithErrorCodeを指定しなければなりません。

Customメソッドの場合はもう少し面倒で、生成するValidationFailureErrorCodeを別途指定して返す必要があります。

ビルトインクラスの場合は、規定値としてErrorCodeに検証クラス名(NotNullの場合、"NotNullValidator")が設定されますので、それを元に処理の振分を行うことは可能です。

Exceptionクラスのように、検証結果を表すValidationFailure自体に検証ごとの結果型を持たせ、戻り値の型でエラー種別を判断しつつ、細かいエラー結果を追加で取得できる形になっていたら便利だったのにと思いますが、それはそれで面倒だと感じたかもしれません。

使うのに少々高度な知識を要する

少なくとも、ジェネリクスやラムダ式、匿名関数、拡張メソッドについて理解していないと完全には使いこなせません。
InlineValidatorのコレクション初期化子を使ったルール定義とか、ちょっとマニアックすぎます。

実はこれが一番大きな欠点であるようにも思います。もし、そこまでC#に習熟していない人員しか集められないような場合だと、このフレームワークを採用しづらいようにも思います。

ただ、事前に決められたNotNullとかMaximumLengthとかのメソッドだけを使い、Mustなんかは基本使わせない、Whenは基本的なケースのみ使い方を教える、とかにすれば、パターン化して運用できそうな気もします。それ以外はFluentValidationを使わずに自前で書くようにすれば、必須チェックやフォーマットチェック、範囲チェックなどの定型チェックはフレームワークに任せて、あとは各自でってできるかもしれませんね。

44
61
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
44
61