WPFにてViewModelやModelでデータ検証を行う際に System.ComponentModel.DataAnnotationsの検証属性を使用することが多いのではないかと思います。その場合、メッセージリソースと共に使用したいんじゃないかと思いますが、
/// <summary>
/// メールアドレス
/// </summary>
[Display(Name = "メールアドレス")]
[Required(ErrorMessageResourceType=typeof(ErrorMessage), ErrorMessageResourceName="Required")]
[EmailAddress(ErrorMessageResourceType=typeof(ErrorMessage), ErrorMessageResourceName="EmailAddress")]
public string MailAddress {
get => _MailAddress;
set
{
if (_MailAddress == value) return;
_MailAddress = value;
OnPropertyChanged();
}
}
private string _MailAddress;
のように、エラーメッセージリソースのTypeとリソースキーを指定することになります。
この記述、例えば必須チェックのメッセージなんて全て一緒になることがほとんどなのに、全ての項目にやたら長い属性指定を付けなければいけません。
ASP.NET MVCには DefaultModelBinder.ResourceClassKey
や、~AttributeAdapter
を使って既定のメッセージリソースを指定する方法があります。
今回は「WPFでもどうにかならないのか?」と思い1週間ほど格闘してみました。
場合によってはWPF以外でも使える...かもしれません。
格闘結果のソースはGitHubのこちらに置いてあります。
煮るなり焼くなりお好きにどうぞ。
part2の記事を書きました。
これに伴い、GitHubのソースも変更しています。
2019/09/28、さらにさらに、
.NET Core 3.0でWPFしてみました。Nekoni.DataValidation
という記事で.NET Core 3.0のサンプルプロジェクトを追加しています。併せてご覧ください。
本記事中のConfiguration
クラスは上記の記事中で、ValidationConfig
という名前に変更しています。
最新ソースと一緒に本記事を読まれている方は、適宜読み換えていただけると幸いです。
真っ当なアプローチ
まずは至極まっとうなアプローチ、ValidationAttribute
を継承して独自の検証属性を作ってしまうってやつですね。
/// <summary>
/// 文字列長チェック属性
/// </summary>
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public class CheckStringLengthAttribute : StringLengthAttribute
{
/// <summary>
/// コンストラクタ
/// </summary>
/// <param name="maximumLength">最大長</param>
public CheckStringLengthAttribute(int maximumLength) : base(maximumLength)
{
if (!string.IsNullOrEmpty(this.ErrorMessage)) return;
this.ErrorMessageResourceType = this.ErrorMessageResourceType ?? typeof(ErrorMessage);
this.ErrorMessageResourceName = this.ErrorMessageResourceName ?? nameof(this);
}
}
上記は StringLengthAttribute
を継承してみた例です。
DataValidation
というプロジェクトのAttibuteフォルダにCheck
というプレフィクスを付けた検証属性をいくつか定義してあります。
Check~という非常に怪しい英語になっている感じでごめんなさい。
昔はデータチェックとか呼んでいた気がします。「データチェック仕様はこれこれこうで...」みたいな感じに。
でも、英語のcheckというのは、検証というよりも「確認する」「検査する」というニュアンスしか無いっぽいですねぇ。
ちなみにvalidateには「有効かどうか検証する」というニュアンスがあるようです。
さてさて、今回の目的を実現するには、上記ソースのようにコンストラクタでリソースタイプとリソースキーを指定すればいいわけです。ValidationAttribute
(とその派生クラス)にはErrorMessage
というプロパティがあり、検証属性を項目に指定する際にメッセージそのものを指定できるようになっているので、その指定が無い場合のみ設定するようにしています。
ただ、メッセージリソースのTypeを直接埋め込んでしまうと別のリソースを使いたくなったときに問題があるので、Configuration
クラスというものを用意しました。
/// <summary>
/// 基本設定クラス
/// </summary>
public static class Configuration
{
/// <summary>
/// 既定のエラーメッセージリソースのSystem.Type
/// </summary>
public static Type DefaultErrorMessageResourceType { get; set; }
/// <summary>
/// 既定のエラーメッセージリソース名を決定する関数
/// </summary>
public static Func<ValidationAttribute, string> DefaultErrorMessageResourceNameProvider { get; set; } =
(va) => va.GetType().Name.Replace("Attribute", string.Empty);
:
:
:
DefaultErrorMessageResourceType
はそのものズバリなのでいいとして、DefaultErrorMessageResourceNameProvider
の方ですが、ValidationAttributeを引数にしてstringを返すFuncデリゲートにしてみました。
既定では上記のように属性クラス名から"Attribute"
を除いた文字列をリソースキーとしています。
別のルールでリソースキーを決定したい場合はこのプロパティを変更してもらえればいいかなと。
また、前述のCheckStringLengthAttributeのコンストラクタのような処理を行う拡張メソッドを用意してあるので、新たに検証属性を追加する際はより簡単に記述できるようになっています。詳しくは実際のソースコードをご覧ください。
part2にて、DefaultErrorMessageResourceTypeはDefaultErrorMessageResourceTypeProviderに変更しました。
/// <summary>
/// 既定のエラーメッセージリソースを決定する関数
/// </summary>
public static Func<ValidationAttribute, Type> DefaultErrorMessageResourceTypeProvider { get; set; }
DefaultErrorMessageResourceNameProviderと同様、ValidationAttributeを引数にとってTypeを返すFuncデリゲートを指定するように変更しました。
// エラーメッセージリソースの設定
Configuration.DefaultErrorMessageResourceTypeProvider = (attr) => typeof(ErrorMessage);
このように、プロジェクトで固定なら単純にリソースクラスのTypeを返すようにすれば同じ動作になります。
ValidationAttributeの種類に応じて別のリソースを返すようにも出来るかと思います。
真っ当でないアプローチ
カスタム検証属性を作るのはMicrosoftも推奨する順当なアプローチでしょうけど、たかだか既定のエラーメッセージリソースを指定するのにわざわざカスタム検証属性を作るのはめんどい...と思いますよねぇ。
ASP.NET MVCには仕組みがあるのに、WPFだとダメなのかと...。
WPFの検証でよく紹介されている INotifyDataErrorInfoを実装したモデルクラスで、検証結果を得るために使われているのが System.ComponentModel.DataAnnotaions.Validatorというクラスです。
おそらく ASP.NET MVCでもこのクラスを使用していて、そこに介在できるようになっているはずです。
で、しばらく Validatorクラスのソースとにらめっこしてみました。
.NET Frameworkのソースが公開されているというのはとてもうれしいことですよね。
しかーし...
どうも介在できる余地が無さそうに見えます。うーん。ASP.NET MVCのソース(ModelBinderあたり?)も見てみるか、と、こちらもしばらくにらめっこしていたのですが、そもそもValidatorクラスを使用しているのかわからないという...
いろいろ検索してみたのですが、Validatorクラスを使用している箇所を見つけられません。
もしかして...使っていない?
そうです。使っていない可能性が濃厚です。
そもそもValidationAttributeはIsValid
というメソッドの中に検証ロジックを記述するので、これを適切に呼び出せば検証結果を得られるわけです。Validatorクラスじゃなくてもいいわけだ。
というわけで、ASP.NET MVCがValidatorクラスを使っていないという確証は得られていませんが、独自の検証処理を作ってしまえ~。
で、出来たやつがValidator.csというソースです。
ValidationAttributeの検証メソッドにはパラメータとしてValidationContext
というクラスのインスタンスを渡します。
ValidatorクラスのTryValidate~
を呼び出す際にも指定します。
なので、当初はValidationContextの拡張メソッドという形で実装を始めました。
リフレクションばりばりです。
ValidatorクラスはTypeDescriptor
をがしがし使ってますね。
大先生に言わせるとクソが付くほど遅いらしいですが...
たしかに遅いようです。
上記リンクにあるFastMember
を使おうかと一瞬思いましたが、そもそもValidationContextが内部でTypeDescriptorをバリバリ使っているんですよねぇ。
今回は余計な依存関係を作らないためにも、素のリフレクションで処理するようにしました。Type情報をキャッシュする仕組みも実装するために専用のコンテキストクラス(ForValidation
)も用意しました。
最終的にはこのForValidationというクラスの拡張メソッドとして実装してます。
普段はあまりユニットテストは作成しないのですが (ぉぃ、今回はCheck~Attribute
含め、ユニットテストプロジェクトも作ってます。
Check~Attribute
をテストするUnitTest1の方が圧倒的に速いです... しくしく
part2の変更後、なぜかそこまで遅くない感じに...。
テストプロジェクトを.NET Coreにしたのですが、そのせいでしょうか...?
今計ってみたら、トータル61ミリ秒ほど。
UnitTest1は39ミリ秒、UnitTest2は21ミリ秒になってます。
.NET Coreのリフレクションは、.NET Frameworkから変わっているらしいので、コードの最適化でもされたのでしょうかねぇ。
あ、ユニットテストで大先生作のChaining Assertion使ってます。書き味がかなり変わるのでオススメです。
で、肝心の検証メソッドはこんな風に使います。UnitTest2.csから一部抜粋。
まずはメッセージリソースの指定。このテストクラスのコンストラクタで指定しています。
public UnitTest2()
{
// エラーメッセージリソースの設定
Configuration.DefaultErrorMessageResourceType = typeof(ErrorMessage);
Configuration.DefaultErrorMessageResourceNameProvider = (attr) => {
var attrName = attr.GetType().Name.Replace("Attribute", string.Empty);
return attrName.StartsWith("Check") ? attrName : $"Check{attrName}";
};
}
実際はスタートアップのどこかで呼び出すのを想定しています。
全てのプロパティを検証する際はこんな感じ。
[TestMethod]
public void Test2_Requiredのテスト()
{
var model = new Staff2();
var results = model.ForValidation().GetAllErrors();
results.Count.Is(4); // 必須項目は4つ
}
対象オブジェクト.ForValidation()
.検証メソッドみたいな感じですね。
プロパティを指定する際はプロパティ名を文字列でこんな感じに。
[TestMethod]
public void Test2_StringLengthのテスト()
{
var model = new Staff2();
Func<List<ValidationResult>> check = () => model.ForValidation().GetPropErrors("SyainNo");
model.SyainNo = "1234567890";
var results = check.Invoke();
results.Count().Is(0);
model.SyainNo = "12345678901";
results = check.Invoke();
results.Count().Is(1);
if (results.Count != 1) return;
var res = results.First();
res.ErrorMessage.Is(string.Format(ErrorMessage.CheckStringLength, "社員番号", 10));
}
遅いけど、 まぁ、それなりに動いているかなぁ。
実は実装上、一部爆弾を抱えている箇所があります。
検証属性クラスにリソースを与える処理で、以下のようにプライベートフィールドにアクセスしてます。
/// <summary>
/// 検証属性にメッセージリソースを設定する拡張メソッド
/// </summary>
/// <param name="va">検証属性</param>
/// <param name="errMsgResourceType">エラーメッセージが設定されているリソースのType</param>
/// <param name="errMsgResourceName">エラーメッセージリソースのキー名</param>
public static void SetupErrorMessageResource(this ValidationAttribute va, Type errMsgResourceType,
string errMsgResourceName)
{
//if (!string.IsNullOrEmpty(va.ErrorMessage)) return;
// hack: EmailAddressAttributeやUrlAttributeなど、コンストラクタでinternalのDefaultErrorMessageプロパティがセットされている。
// ValidationAttribute.ErrorMessageプロパティは自身のバッキングストア(_errorMessage)がnullの場合、DefaultErrorMessageを返す。
// 属性指定でErrorMessageを指定しているかどうかの判定がErrorMessageプロパティではわからないので、リフレクションを使用してバッキングストア
// に直接アクセスする。
var vaType = typeof(ValidationAttribute);
var field = vaType.GetField("_errorMessage", BindingFlags.Instance | BindingFlags.NonPublic);
if (field == null) return;
var errorMessage = field.GetValue(va) as string;
if (!string.IsNullOrEmpty(errorMessage)) return;
va.ErrorMessageResourceType = va.ErrorMessageResourceType ?? errMsgResourceType;
va.ErrorMessageResourceName = va.ErrorMessageResourceName ?? errMsgResourceName;
}
ValidationAttributeクラスのErrorMessage
プロパティの有無を見て、リソースを設定するかどうか判断しているのですが、
上記のコメントにもあるように、一部例外な挙動を示す検証属性があるんですよねぇ。
ErrorMessage
プロパティだと判断できないという...
ValidationAttributeクラスのソースを見ると、ErrorMessage
プロパティに修正が加えられている形跡があるんですよ。これが...
_errorMessage
というフィールド名が変わらないことを祈ります...
ReactivePropertyにも対応したい
WPFでMVVMする場合、ReactivePropertyは非常に便利なライブラリだと思います。
ReactivePropertyで検証属性を使う場合は、標準のSetValidateAttribute
を使うことになると思いますが、内部ではやっぱりValidatorクラスを使用しています。
SetValidateAttribute
のソースを基に、今回作成したValidator.csの IList<ValidationResult># AddErrors を使用して検証処理を行うSetNekoniDataValidationAttribute
という拡張メソッドを作ってみました。
SampleWpfApp1プロジェクトのReactivePropertyExtensions.csというソースです。
Validator
クラスをAddErrors
で差し替えただけですが、機能しているようです。
左が一般的なViewModel、右がReactivePropertyでの挙動です。
一部エラーになっているのはCompareAttribute
で、これは他のプロパティと同値かどうかを検証する属性なのですが(上の例だと、「メールアドレス(確認)」という項目で使用)、ReactivePropertyでは使えない感じですねぇ。おそらく標準のSetValidateAttribute
でもダメでしょう。原理的に。
ReactivePropertyは検証メソッドとしてSetValidateNotifyError
というメソッドがあるのでこちらで自前の検証ロジックを作りましょう。
Rxに慣れていないので、エラーメッセージのリストを構築するのに苦労しました。
もっといい書き方があるような気がする...
part2でReactiveProperty向けのクラスライブラリを追加しています。複数のプロパティをまとめて処理するヘルパメソッドを追加しました。
SampleWpfApp1も画面変更してあります。CompareAttributeは相変わらずなので、別のプロパティを追加して比較してます。
まとめ
紆余曲折、いろいろありましたが、タイトルにある目的はなんとか達成できたかも。
プロダクトにするつもりは無いので、試してみたい方はGitHubからソースを拾ってくださいな。
もちろん、own risk で!