2018年2月20日火曜日

WPFにおけるINotifyDataErrorInfoを使った値の検証 属性版

2年ちょっと前にINotifyDataErrorInfoを使った値の検証の方法を記事にしました。

これはViewModelを継承したクラスにエラー情報を蓄積する機能を持たせ、任意のタイミングでエラーを登録したり削除したりすることができるというものでした。
実際、それでも使い物にはなるのですが、しっかりとMVVMを実装しようとすると妙に不便に思えてきました。

簡単な値の検証(例えばnullじゃなければ受け付ける、など)ならまだしも、複雑な判定ロジックになるとModel側にその判定機能を持たせたくなります。なぜならば、入力値を受けて何かしらの処理を行うのはModelの仕事だからです。何かしらの処理を行う前には、渡されたパラメーターが正しいものかどうかを判定し、間違っていれば何かしらのエラー通知(例外を吐く等々)をするかと思いますが、値の検証をViewModelでやっていたとすると、ViewModelとModelの両方に複雑な判定ロジックを持たせるメリットがありません。
入力された値の正当性も状態の1つだと考えると、Modelがその状態を持っていても変ではありません。すなわち、ViewModelは入力された値をそのままModelに横流ししておき、Modelがその横流しされた値の正当性を検証して状態として公開し、それをViewModelが監視してUIに何かしらの表示を行うといった流れになれば、正当性の判断をすべてModelがすることができますし、ViewModelもそれを監視して入力欄のエラー表示やボタンの有効/無効などを切り替えればいいわけです。

そうすると、前回の記事で書いたクラスでは多少の不満が出てきます。ViewModelがModelの正当性を表すプロパティを監視し、そのプロパティの変化に合わせてエラーをセットするロジックをViewModelに書くことになります。それ自体はMVVMの考え方には反しないのですが、「あるプロパティの値の正当性を他のとあるプロパティが示している」というシンプルな関係なのに、なぜわざわざプロパティの変更通知を購読し、値が変化したタイミングでメソッドを呼び出して…なんて面倒くさい処理を書かなきゃいけないのかと。実際書いてみると、誰もが「もっとシンプルにプロパティの関係性を書きたい!」と思うはずです。

そこで、そのような機能を実現するViewModelを作ってみました。
public abstract class NotifyDataErrorViewModel : ViewModel, INotifyDataErrorInfo
{
    Dictionary<string, NotifyErrorAttribute[]> properties;

    public NotifyDataErrorViewModel()
    {
        properties = this
            .GetType()
            .GetProperties()
            .Select(p => new KeyValuePair<string, NotifyErrorAttribute[]>(
                p.Name,
                p.GetCustomAttributes(typeof(NotifyErrorAttribute), true)
                    .Cast<NotifyErrorAttribute>()
                    .Where(p1 => p1.IsValidProperty(this))
                    .ToArray()))
            .Where(p => p.Value.Length > 0)
            .ToDictionary(p => p.Key, p => p.Value);

        this.PropertyChanged += This_PropertyChanged;

        foreach(var p in properties.Where(p => p.Value.Any(p1 => !p1.GetValidityValue(this))).Select(p => p.Key))
            RaiseErrorsChanged(p);
    }

    private void This_PropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        foreach(var p in properties.Where(p => p.Value.Any(p1 => p1.ValidityPropertyName == e.PropertyName)))
            RaiseErrorsChanged(p.Key);
    }

    /// <summary>
    /// エラーがあるかどうかを示すプロパティ
    /// </summary>
    public bool HasErrors => properties.Values.SelectMany(p => p).Any(p => !p.GetValidityValue(this));

    /// <summary>
    /// エラー情報を返すメソッド
    /// </summary>
    /// <param name="propertyName">エラーがあるか調べたいプロパティ名</param>
    /// <returns>エラーがあればエラーテキストの配列、無ければ空の配列</returns>
    public IEnumerable GetErrors(string propertyName)
    {
        if(properties.ContainsKey(propertyName))
            return properties[propertyName].Where(p => !p.GetValidityValue(this)).Select(p => p.ErrorText).ToArray();
        else
            return Enumerable.Empty<string>();
    }

    /// <summary>
    /// エラー状態が変わった時に発生するイベント
    /// </summary>
    public event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged;

    /// <summary>
    /// ErrorsChangedイベントを発生させるメソッド
    /// </summary>
    /// <param name="PropertyName">プロパティ名</param>
    protected void RaiseErrorsChanged(string PropertyName)
    {
        ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(PropertyName));
    }
}
ViewModelを継承した抽象クラスで、INotifyDataErrorInfoを実装しています。
ここまでは前回と同じなのですが、エラーを登録/削除するメソッドは用意していません。さて、ではどうやってエラーの登録/削除をするのでしょう。

その答えは属性です。
public class NotifyErrorAttribute : Attribute
{
    public NotifyErrorAttribute(string ValidityPropertyName, string ErrorText)
    {
        this.ValidityPropertyName = string.IsNullOrEmpty(ValidityPropertyName) ? throw new ArgumentNullException(nameof(ValidityPropertyName)) : ValidityPropertyName;
        this.ErrorText = string.IsNullOrEmpty(ErrorText) ? throw new ArgumentNullException(nameof(ErrorText)) : ErrorText;
    }

    public string ValidityPropertyName { get; }

    public string ErrorText { get; }

    /// <summary>
    /// この属性で与えられたプロパティ名が、指定したオブジェクトで値の正当性を示しているプロパティとして正当かを検証します。
    /// </summary>
    /// <param name="Source">オブジェクト</param>
    /// <returns>指定したオブジェクトがbool型の読み取り可能なこの名前のプロパティを持っていた場合true、そうでなければfalse</returns>
    internal bool IsValidProperty(object Source)
    {
        var property = Source.GetType().GetProperty(ValidityPropertyName);
        return property != null && property.CanRead && property.PropertyType == typeof(bool);
    }

    /// <summary>
    /// 値の正当性を取得します。
    /// </summary>
    /// <param name="Source">オブジェクト</param>
    /// <returns>値の正当性</returns>
    internal bool GetValidityValue(object Source)
    {
        return (bool)Source.GetType().GetProperty(ValidityPropertyName).GetValue(Source);
    }
値の正当性情報を付加したいViewModelのプロパティにこの属性を付け、値の正当性を示しているプロパティの名前とエラーが起きたときのメッセージを渡してあげます。そうすることで、NotifyDataErrorViewModelクラスは値の正当性を示しているプロパティを監視して、エラー状態が変化すると自動的にErrorsChangedイベントを発生させます。
NotifyErrorAttributeを1つのプロパティに複数付けた場合、1つでもエラーが起こるとエラーとみなします。GetErrorsメソッドが返すエラーメッセージもそのエラーの内容によって変わるので、エラーになる条件が複数ある場合なんかは属性をいくつか付けるといいでしょう。


さて、せっかくですので以前の記事の時に作ったサンプルプログラムと同じような動きをするサンプルプログラムを今回のクラスを使って作ってみました。
Modelは次の通りです。
public class Model : NotificationObject
{
    #region Singleton

    static Model Instance;

    public static Model GetInstance()
    {
        if(Instance == null)
            Instance = new Model();
        return Instance;
    }

    #endregion

    private Model()
    {
        this.PropertyChanged += This_PropertyChanged;

        CheckValidity();
    }

    private void This_PropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        switch(e.PropertyName) {
            case nameof(Number):
            case nameof(Digits):
                CheckValidity();
                break;
        }
    }

    void CheckValidity()
    {
        NumberIsANumber = int.TryParse(Number, out int number);
        DigitsIsANumber = int.TryParse(Digits, out int digits);

        if(NumberIsANumber && DigitsIsANumber) {
            if(number == 0)
                NumberHasTheDigits = digits == 1;
            else
                NumberHasTheDigits = (int)Math.Log10(Math.Abs(number)) + 1 == digits;
        } else
            NumberHasTheDigits = true;
    }

    #region Number変更通知プロパティ

    public string Number
    {
        get { return _Number; }
        set
        {
            if(_Number == value)
                return;
            _Number = value;
            RaisePropertyChanged(nameof(Number));
        }
    }
    private string _Number;

    #endregion

    #region Digits変更通知プロパティ

    public string Digits
    {
        get { return _Digits; }
        set
        {
            if(_Digits == value)
                return;
            _Digits = value;
            RaisePropertyChanged(nameof(Digits));
        }
    }
    private string _Digits;

    #endregion

    #region NumberHasTheDigits変更通知プロパティ

    public bool NumberHasTheDigits
    {
        get { return _NumberHasTheDigits; }
        set
        {
            if(_NumberHasTheDigits == value)
                return;
            _NumberHasTheDigits = value;
            RaisePropertyChanged(nameof(NumberHasTheDigits));
        }
    }
    private bool _NumberHasTheDigits;

    #endregion

    #region DigitsIsANumber変更通知プロパティ

    public bool DigitsIsANumber
    {
        get { return _DigitsIsANumber; }
        set
        {
            if(_DigitsIsANumber == value)
                return;
            _DigitsIsANumber = value;
            RaisePropertyChanged(nameof(DigitsIsANumber));
        }
    }
    private bool _DigitsIsANumber;

    #endregion

    #region NumberIsANumber変更通知プロパティ

    public bool NumberIsANumber
    {
        get { return _NumberIsANumber; }
        set
        {
            if(_NumberIsANumber == value)
                return;
            _NumberIsANumber = value;
            RaisePropertyChanged(nameof(NumberIsANumber));
        }
    }
    private bool _NumberIsANumber;

    #endregion
}
NumberとDigitsというプロパティを持っており、これらの値が変更されるたびにCheckValidityメソッドを呼び出しています。ここからNumberHasTheDigitsプロパティ、DigitsIsANumberプロパティ、NumberIsANumberプロパティの3つを変更しています。NumberIsANumberはなんか哲学的な名前になってしまっていますが、xxxxIsANumberのプロパティがxxxxのプロパティが値であるかどうか、NumberHasTheDigitsはNumberがDigitsの桁数を持っているかを判定するフラグです。ただ、NumberとDigitsのどちらかのみが値だった場合にfalseにしてしまうと芸がない(というか、xxxxIsANumberプロパティの存在価値がなくなる)ので、片側が入力されている状態ではtrueにしてみました。

肝心のViewModelはこちらです。
public class MainWindowViewModel : NotifyDataErrorViewModel
{
    Model model;

    public void Initialize()
    {
        model = Model.GetInstance();

        this.PropertyChanged += This_PropertyChanged;
        model.PropertyChanged += Model_PropertyChanged;
    }

    private void This_PropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        switch(e.PropertyName) {
            case nameof(Number):
                model.Number = Number;
                break;
            case nameof(Digits):
                model.Digits = Digits;
                break;
            case nameof(NumberHasTheDigits):
                model.NumberHasTheDigits = NumberHasTheDigits;
                break;
            case nameof(DigitsIsANumber):
                model.DigitsIsANumber = DigitsIsANumber;
                break;
            case nameof(NumberIsANumber):
                model.NumberIsANumber = NumberIsANumber;
                break;
        }
    }

    private void Model_PropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        switch(e.PropertyName) {
            case nameof(Model.Number):
                Number = model.Number;
                break;
            case nameof(Model.Digits):
                Digits = model.Digits;
                break;
            case nameof(Model.NumberHasTheDigits):
                NumberHasTheDigits = model.NumberHasTheDigits;
                break;
            case nameof(Model.DigitsIsANumber):
                DigitsIsANumber = model.DigitsIsANumber;
                break;
            case nameof(Model.NumberIsANumber):
                NumberIsANumber = model.NumberIsANumber;
                break;
        }
    }

    #region Number変更通知プロパティ

    [NotifyError(nameof(NumberHasTheDigits), "The Number does not match to the Digits.")]
    [NotifyError(nameof(NumberIsANumber), "The Number is not a number.")]
    public string Number
    {
        get { return _Number; }
        set
        {
            if(_Number == value)
                return;
            _Number = value;
            RaisePropertyChanged(nameof(Number));
        }
    }
    private string _Number;

    #endregion

    #region Digits変更通知プロパティ

    [NotifyError(nameof(NumberHasTheDigits), "The Digits does not match to the Number.")]
    [NotifyError(nameof(DigitsIsANumber), "The Digits is not a number.")]
    public string Digits
    {
        get { return _Digits; }
        set
        {
            if(_Digits == value)
                return;
            _Digits = value;
            RaisePropertyChanged(nameof(Digits));
        }
    }
    private string _Digits;

    #endregion

    #region NumberHasTheDigits変更通知プロパティ

    public bool NumberHasTheDigits
    {
        get { return _NumberHasTheDigits; }
        set
        {
            if(_NumberHasTheDigits == value)
                return;
            _NumberHasTheDigits = value;
            RaisePropertyChanged(nameof(NumberHasTheDigits));
        }
    }
    private bool _NumberHasTheDigits;

    #endregion

    #region DigitsIsANumber変更通知プロパティ

    public bool DigitsIsANumber
    {
        get { return _DigitsIsANumber; }
        set
        {
            if(_DigitsIsANumber == value)
                return;
            _DigitsIsANumber = value;
            RaisePropertyChanged(nameof(DigitsIsANumber));
        }
    }
    private bool _DigitsIsANumber;

    #endregion

    #region NumberIsANumber変更通知プロパティ

    public bool NumberIsANumber
    {
        get { return _NumberIsANumber; }
        set
        {
            if(_NumberIsANumber == value)
                return;
            _NumberIsANumber = value;
            RaisePropertyChanged(nameof(NumberIsANumber));
        }
    }
    private bool _NumberIsANumber;

    #endregion
}
前半にあるのは自身とModel両方の値の変化をウォッチして同期するプログラムです。
NumberとDigitsにはNotifyError属性を付けて、どのプロパティが自身の正当性を示しているかを記述しています。これだけでコントロールに正当性表示が行われるなんて夢の酔うでしょ?

ん?DataAnnotations
…知らね…そんなの…。
(マジレスするとDataAnnotationsはCustomValidationAttributeが任意のメソッドを判定に指定できてしまうため、INotifyPropertyChangedの監視とリフレクションだけじゃ対応しきれずここまでスマートな実装はできないのです…。また、Modelでの値の検証を反映するという考えからも外れてしまいます…。)

0 件のコメント:

コメントを投稿