outcoldman
outcoldman Denis Gladkikh

Silverlight. Основы. Валидация. Часть 2. IDataErrorInfo & INotifyDataErrorInfo

Silverlight, XAML, and Validation

Буквально вчера написал первую часть статьи про валидацию введённых данных в Silverlight, сегодня хочу продолжить эту тему, чтобы не откладывать в дальний ящик. В этой части я попробую дополнить первую часть, скажу еще, что не сказал про DataAnnotations, а так же опишу интерфейсы IDataErrorInfo и INotifyDataErrorInfo. Рекомендую прочесть первую часть статьи перед прочтением этой, потому как я буду использовать все тот же пример.

Еще пару слов про ValidatesOnExceptions

Забыл сказать, что если вам хочется построить валидацию на исключениях, то совсем не обязательно использовать DataAnnotations, можно очень просто выбрасывать исключения прям из set методов. Например, для проверки того, что повторно введенный пароль из прошлого примера совпадает с перво-введённым паролем, можно сделать так:

[Display(Name = "New password confirmation")]
public string NewPasswordConfirmation
{    get { return _newPasswordConfirmation; }
    set
    {        _newPasswordConfirmation = value;
        OnPropertyChanged("NewPasswordConfirmation");
        ChangePasswordCommand.RaiseCanExecuteChanged();        if (string.CompareOrdinal(_newPassword, value) != 0)
            throw new Exception("Password confirmation not equal to password.");
    }
}

Так, конечно, выглядит намного проще, чем описывать все на аттрибутах (в случае CustomValidationAttribute).

IDataErrorInfo

IDataErrorInfo интерфейс пришел вместе с Silverlight 4. Он нам поможет избавиться от передачи сообщений об ошибках инфраструктуре Silverlight основанной на бросании исключений. Все, что нужно сделать – это реализовать два описанных в этом интерфейсе метода/свойства. Чаще всего разработчики начинают с того, что добавляют некий класс-обработчик, который хранит коллекцию сообщений об ошибках:

public class ValidationHandler
{    private Dictionary<string, string> BrokenRules { get; set; }
     public ValidationHandler()
    {        BrokenRules = new Dictionary<string, string>();
    }
     public string this[string property]
    {        get { return BrokenRules[property]; }
    }
     public bool BrokenRuleExists(string property)
    {        return BrokenRules.ContainsKey(property);
    }
     public bool ValidateRule(string property, string message, Func<bool> ruleCheck)
    {        bool check = ruleCheck();
        if (!check)
        {            if (BrokenRuleExists(property))
                RemoveBrokenRule(property);
 
            BrokenRules.Add(property, message);
        }        else
        {
            RemoveBrokenRule(property);
        }        return check;
    }
     public void RemoveBrokenRule(string property)
    {        if (BrokenRules.ContainsKey(property))
        {
            BrokenRules.Remove(property);
        }
    }
     public void Clear()
    {
        BrokenRules.Clear();
    }
}

Дальше, давайте перепишем немного наш класс BindingModel, наследуем его от вышеупомянутого интерфейса и реализуем его при помощи класса ValidationHandler:

public class BindingModel : INotifyPropertyChanged, IDataErrorInfo
{    private string _newPassword;
    private string _newPasswordConfirmation;
    private readonly ValidationHandler _validationHandler = new ValidationHandler();
     #region INotifyPropertyChanged
     public event PropertyChangedEventHandler PropertyChanged = delegate { };
     private void OnPropertyChanged(string propertyName)
    {        PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
    }
     #endregion
     #region IDataErrorInfo
     public string this[string columnName]
    {
        get
        {            if (_validationHandler.BrokenRuleExists(columnName))
            {                return _validationHandler[columnName];
            }            return null;
        }
    }
     public string Error
    {        get { return throw new NotImplementedException(); }
    }
     #endregion
}

Основные свойства нашей BindingModel будут описаны следующим образом:

[Display(Name = "New password")]
public string NewPassword
{    get { return _newPassword; }
    set
    {        _newPassword = value;
        OnPropertyChanged("NewPassword");
         if (_validationHandler.ValidateRule("NewPassword", "New password required", 
                                    () => !string.IsNullOrEmpty(value)))
        {            _validationHandler.ValidateRule("NewPassword", "Max length of password is 80 symbols.", 
                                    () => value.Length < 80);
        }
 
        ChangePasswordCommand.RaiseCanExecuteChanged();
    }
}
 [Display(Name = "New password confirmation")]
public string NewPasswordConfirmation
{    get { return _newPasswordConfirmation; }
    set
    {        _newPasswordConfirmation = value;
        OnPropertyChanged("NewPasswordConfirmation");
         _validationHandler.ValidateRule("NewPasswordConfirmation", "Password confirmation not equal to password.",
                                        () => string.CompareOrdinal(_newPassword, value) == 0);
 
        ChangePasswordCommand.RaiseCanExecuteChanged();
    }
}

То есть, каждый вызов метода ValidateRule проверяет некоторое условие, и если оно не выполнилось, то записывает информацию о нем в коллекцию ошибок. После байдинга произойдет обращение к индексируемому свойству this[string columnName] и оно вернет сообщение об ошибке. Для того, чтобы это работало в байдинге мы установили свойство ValidatesOnDataErrors в True. Свойство Error кидает NotImplementedException() не просто так, в нем нет необходимости, если вы сами его не используете. Цитата с MSDN: “Note that the binding engine never uses the Error property, although you can use it in custom error reporting to display object-level errors.”. То есть, инфраструктура Silverlight его не использует при байдинге.

Давайте заканчивать с этим примером. Все, что нам осталось – это реализовать и проинициализировать команду, которая будет производить изменение пароля. Сделаем это так:

public BindingModel()
{    ChangePasswordCommand = new DelegateCommand(ChangePassword, CanChangePassword);
}
 public DelegateCommand ChangePasswordCommand { get; private set; }
 private bool CanChangePassword(object arg)
{    return !string.IsNullOrEmpty(_newPassword) 
        && string.CompareOrdinal(_newPassword, _newPasswordConfirmation) == 0;
}
 private void ChangePassword(object obj)
{    if (ChangePasswordCommand.CanExecute(obj))
    {        MessageBox.Show("Bingo!");
    }
}

Нам опять нужно использовать CanChangePassword метод, чтобы делать кнопку неактивной при невалидности объекта. У нас нет возможности как-то проверить валидность всего объекта, пока не произошел байдинг по каждому полю. Так же, в нашей реализации нам приходится описывать правила валидации дважды, один раз при байдинге, а второй раз на проверку того, можно ли вызвать команду. Это проблемы только нашей реализации (точнее той, что я взял по ссылке выше). Решить эту проблему можно, например, записывая сами правила в тот же ValidationHandler, и умея проходить по всем этим правилам, и в целом получать общую картину о валидности состояния объекта. Но, все же, одно проблема останется, мы не можем прямо из кода, из метода ChangePassword (нажатия на кнопку) сказать инфраструктуре, что ошибки появились или, наоборот, пропали. Можно так же использовать атрибуты из DataAnnotations для описания условий. Но об этом мы поговорим в рамках следующего интерфейса, который, мне кажется, является лучшим выбором для реализации валидации.

Результат этой реализации привожу ниже (Silverlight приложение):

Get Microsoft Silverlight

Думаю, его поведение не сильно должно отличаться от предыдущего примера (в предыдущей части статьи). Но вот хочется отметить, что здесь есть скрытый баг. Если пользователь сначала введет Password Confirmation а затем New Password, то останется ошибка о неверном введенном подтверждении пароля, так как эта проверка происходит только на байдинг New Password Confirmation свойства.

INotifyDataErrorInfo

INotifyDataErrorInfo интерфейс к нам так же пришел совместно с Silverlight 4. Основное его преимущество в том, что он может осуществлять как синхронную (как было в предыдущих примерах) так и асинхронную валидацию. Например, подождать, когда проверка придет от сервера, и только потом показать сообщение об ошибке. Вот этот метод я хочу описать более подробно, насколько это возможно. Помогут реализовать валидацию при помощи INotifyDataErrorInfo статьи, опубликованные Davy Brion в рамках “MVP In Silverlight/WPF Series”.

Для начала возьмем класс PropertyValidation, при помощи которого мы будем хранить информацию о каждом правиле валидации, и о том, какое сообщение отображать при этом:

public class PropertyValidation<TBindingModel>
    where TBindingModel : BindingModelBase<TBindingModel>
{    private Func<TBindingModel, bool> _validationCriteria;
    private string _errorMessage;
    private readonly string _propertyName;
     public PropertyValidation(string propertyName)
    {
        _propertyName = propertyName;
    }
     public PropertyValidation<TBindingModel> When(Func<TBindingModel, bool> validationCriteria)
    {        if (_validationCriteria != null)
            throw new InvalidOperationException("You can only set the validation criteria once.");
 
        _validationCriteria = validationCriteria;        return this;
    }
     public PropertyValidation<TBindingModel> Show(string errorMessage)
    {        if (_errorMessage != null)
            throw new InvalidOperationException("You can only set the message once.");
 
        _errorMessage = errorMessage;        return this;
    }
     public bool IsInvalid(TBindingModel presentationModel)
    {        if (_validationCriteria == null)
            throw new InvalidOperationException(
                "No criteria have been provided for this validation. (Use the 'When(..)' method.)");
         return _validationCriteria(presentationModel);
    }
     public string GetErrorMessage()
    {        if (_errorMessage == null)
            throw new InvalidOperationException(
                "No error message has been set for this validation. (Use the 'Show(..)' method.)");
         return _errorMessage;
    }
     public string PropertyName
    {        get { return _propertyName; }
    }
}

Как он работает будет понятно ниже, когда мы будем описывать правила валидации. В качестве Generic Type параметра у нас используется базовый класс BindingModelBase<T>, от которого мы потом наследуем наш основной класс BindingModel примера.

Давайте приступим к реализации класса BindingModelBase, его мы наследуем от INotifyPropertyChanged и от INotifyDataErrorInfo, добавим так же два поля. Одно для хранения ошибок для свойств, второе для хранения правил валидации:

public abstract class BindingModelBase<TBindingModel> : INotifyPropertyChanged, INotifyDataErrorInfo
    where TBindingModel : BindingModelBase<TBindingModel>
{    private readonly List<PropertyValidation<TBindingModel>> _validations = new List<PropertyValidation<TBindingModel>>();
    private Dictionary<string, List<string>> _errorMessages = new Dictionary<string, List<string>>();
     #region INotifyDataErrorInfo
     public IEnumerable GetErrors(string propertyName)
    {        if (_errorMessages.ContainsKey(propertyName)) 
            return _errorMessages[propertyName];
         return new string[0];
    }
     public bool HasErrors
    {        get { return _errorMessages.Count > 0; }
    }
     public event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged = delegate { };
     private void OnErrorsChanged(string propertyName)
    {        ErrorsChanged(this, new DataErrorsChangedEventArgs(propertyName));
    }
     #endregion
     #region INotifyPropertyChanged
     public event PropertyChangedEventHandler PropertyChanged = delegate { };
     protected void OnPropertyChanged(string propertyName)
    {        PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
    }
     #endregion
}

Реализация пока очень простая. Так же я хотел бы добавить два метода дополнительно к OnPropertyChanged к этому классу. Первый метод OnCurrentPropertyChanged() (на самом деле - это вредный совет, и метод очень опасный, более подробно можно узнать об этом в этом комментарии, если будете пользоваться, то пользуйтесь с умом), который позволит нам делать так:

public string NewPassword
{    get { return _newPassword; }
    set { _newPassword = value; OnCurrentPropertyChanged(); }
}

Второй метод OnPropertyChanged, который принимает Expression и позволяет делать так:

public string NewPassword
{    get { return _newPassword; }
    set { _newPassword = value; OnPropertyChanged(() => NewPassword); }
}

Оба метода очень полезны и удобны. Позволяют в разы писать более надежные приложения, и более быстро. Тут многие скажут о производительности, но в чем проблема? Будут проблемы с производительностью – мы знаем где искать эту проблему. Да и не будет их. Эти операции выполняются только на действия пользователя, отклик к которым будет через 10 миллисекунд или через 100, особо не будет никого напрягать. Реализация этих методов следующая:

protected void OnPropertyChanged(Expression<Func<object>> expression)
{
    OnPropertyChanged(GetPropertyName(expression));
}
 protected void OnCurrentPropertyChanged()
{    string methodName = string.Empty;
     StackTrace stackTrace = new StackTrace(); // get call stack
    StackFrame[] stackFrames = stackTrace.GetFrames(); // get method calls (frames)
     if (stackFrames != null && stackFrames.Length > 1)
    {
        methodName = stackFrames[1].GetMethod().Name;
    }    if (!methodName.StartsWith("set_", StringComparison.OrdinalIgnoreCase))
        throw new NotSupportedException("OnCurrentPropertyChanged should be invoked only in property setter");
     string propertyName = methodName.Substring(4);
    OnPropertyChanged(propertyName);
}
 private static string GetPropertyName(Expression<Func<object>> expression)
{    if (expression == null)
        throw new ArgumentNullException("expression");
 
    MemberExpression memberExpression;
     if (expression.Body is UnaryExpression)
        memberExpression = ((UnaryExpression)expression.Body).Operand as MemberExpression;
    else
        memberExpression = expression.Body as MemberExpression;
    if (memberExpression == null)
        throw new ArgumentException("The expression is not a member access expression", "expression");
     var property = memberExpression.Member as PropertyInfo;
    if (property == null)
        throw new ArgumentException("The member access expression does not access a property", "expression");
     var getMethod = property.GetGetMethod(true);
    if (getMethod.IsStatic)
        throw new ArgumentException("The referenced property is a static property", "expression");
     return memberExpression.Member.Name;
}

Метод с Expression берет имя свойства из этого Expression. Метод OnCurrentPropertyChanged берет имя свойства из StackTrace.

Теперь давайте добавим набор методов, которые будут осуществлять валидацию:

public void ValidateProperty(Expression<Func<object>> expression)
{
    ValidateProperty(GetPropertyName(expression));
}
 private void ValidateProperty(string propertyName)
{
    _errorMessages.Remove(propertyName);
 
    _validations.Where(v => v.PropertyName == propertyName).ToList().ForEach(PerformValidation);
    OnErrorsChanged(propertyName);
    OnPropertyChanged(() => HasErrors);
}
 private void PerformValidation(PropertyValidation<TBindingModel> validation)
{    if (validation.IsInvalid((TBindingModel) this))
    {
        AddErrorMessageForProperty(validation.PropertyName, validation.GetErrorMessage());
    }
}
 private void AddErrorMessageForProperty(string propertyName, string errorMessage)
{    if (_errorMessages.ContainsKey(propertyName))
    {
        _errorMessages[propertyName].Add(errorMessage);
    }    else
    {        _errorMessages.Add(propertyName, new List<string> {errorMessage});
    }
}

Метод ValidateProperty удаляет информацию о всех предыдущих ошибках для этого свойства, потом проверяет каждое правило валидации, которое приписано к данному свойству, и если какое-то правило не прошло проверку, то записывает ошибку в список ошибок для конкретного свойства. Более того, мы можем вызывать валидацию автоматически для каждого обновленного поля, если было вызвано событие PropertyChanged, для этого мы проинициализируем следующим образом наш базовый класс:

protected BindingModelBase()
{    PropertyChanged += (s, e) => { if (e.PropertyName != "HasErrors") ValidateProperty(e.PropertyName); };
}

Для того, чтобы добавлять правила в список правил валидации добавим специальный метод:

protected PropertyValidation<TBindingModel> AddValidationFor(Expression<Func<object>> expression)
{    var validation = new PropertyValidation<TBindingModel>(GetPropertyName(expression));
    _validations.Add(validation);    return validation;
}

Теперь мы можем приступить непосредственно к написанию класса BindingModel, который используется в нашем примере. Чтобы работала валидация, основанная на INotifyDataErrorInfo, у нас в байдинге свойств Xaml описания стоит ValidatesOnNotifyDataErrors равное True.

Вот как будет выглядеть класс BindingModel в данной реализации:

public class BindingModel : BindingModelBase<BindingModel>
{    private string _newPassword;
    private string _newPasswordConfirmation;
     public DelegateCommand ChangePasswordCommand { get; private set; }
     public BindingModel()
    {        ChangePasswordCommand = new DelegateCommand(ChangePassword);
 
        AddValidationFor(() => NewPassword)            .When(x => string.IsNullOrEmpty(x._newPassword))
            .Show("New password required field.");
 
        AddValidationFor(() => NewPassword)            .When(x => !string.IsNullOrEmpty(x._newPassword) && x._newPassword.Length > 80)
            .Show("New password must be a string with maximum length of 80.");
 
        AddValidationFor(() => NewPasswordConfirmation)            .When(x => !string.IsNullOrEmpty(x._newPassword) && string.CompareOrdinal(x._newPassword, x._newPasswordConfirmation) != 0)
            .Show("Password confirmation not equal to password.");
    }
     [Display(Name = "New password")]
    public string NewPassword
    {        get { return _newPassword; }
        set
        {            _newPassword = value;
            OnCurrentPropertyChanged();
        }
    }    [Display(Name = "New password confirmation")]
    public string NewPasswordConfirmation
    {        get { return _newPasswordConfirmation; }
        set
        {            _newPasswordConfirmation = value;
            OnCurrentPropertyChanged();
        }
    }
     private void ChangePassword(object obj)
    {        throw new NotImplementedException();
    }
}

В конструкторе класса мы описываем при помощи PropertyValidation все три наших правила. Выглядит очень читабельно, вроде. Я больше не хочу делать неактивной кнопку для изменения пароля, потому избавился от метода CanChangePassword. Но пока и не реализовал сам метод ChangePassword. Для того чтобы реализовать метод мне нужен метод, который бы валидировал все свойства (полностью состояние объекта), отображал бы ошибки на форме, а так же давал мне знать о том, валидна форма или нет. Для этого в классе BindingModelBase я реализую метод ValidateAll:

public void ValidateAll()
{
    var propertyNamesWithValidationErrors = _errorMessages.Keys;
     _errorMessages = new Dictionary<string, List<string>>();
 
    _validations.ForEach(PerformValidation);
 
    var propertyNamesThatMightHaveChangedValidation =
        _errorMessages.Keys.Union(propertyNamesWithValidationErrors).ToList();
 
    propertyNamesThatMightHaveChangedValidation.ForEach(OnErrorsChanged);
 
    OnPropertyChanged(() => HasErrors);
}

Данный метод чистит все ошибки. Потом делает проверку для нашего правила (записывается ошибка, если нужно), а дальше для всех свойств, для которых могло бы поменяться состояние валидно оно или нет, вызываем метод OnErrorsChanged.

Реализация метода ChangePassword (проверяем все свойства, и если ошибок нет – можем произвести действие):

private void ChangePassword(object obj)
{
    ValidateAll();
     if (!HasErrors)
    {        MessageBox.Show("Bingo!");
    }
}

Результат (Silverlight приложение):

Get Microsoft Silverlight

Данный вариант для валидации мне нравится больше всего. Он намного более гибкий, и более того, он может использовать преимущества всех предыдущих вариантов. А как же DataAnnotations? Нравится использование аттрибутов для описания правил валидации? Давайте напишем метод, который будет собирать все такие правила и преобразовывать в PropertyValidation (изменим так же немного метод AddValidatorFor, точнее сделаем два возможных варианта для его вызова):

protected PropertyValidation<TBindingModel> AddValidationFor(Expression<Func<object>> expression)
{    return AddValidationFor(GetPropertyName(expression));
}
 protected PropertyValidation<TBindingModel> AddValidationFor(string propertyName)
{    var validation = new PropertyValidation<TBindingModel>(propertyName);
    _validations.Add(validation);
     return validation;
}
 protected void AddAllAttributeValidators()
{
    PropertyInfo[] propertyInfos = GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance);
     foreach (PropertyInfo propertyInfo in propertyInfos)
    {        Attribute[] custom = Attribute.GetCustomAttributes(propertyInfo, typeof(ValidationAttribute), true);
        foreach (var attribute in custom)
        {
            var property = propertyInfo;            var validationAttribute = attribute as ValidationAttribute;
             if (validationAttribute == null)
                throw new NotSupportedException("validationAttribute variable should be inherited from ValidationAttribute type");
             string name = property.Name;
             var displayAttribute = Attribute.GetCustomAttributes(propertyInfo, typeof(DisplayAttribute)).FirstOrDefault() as DisplayAttribute;
            if (displayAttribute != null)
            {
                name = displayAttribute.GetName();
            }
 
            var message = validationAttribute.FormatErrorMessage(name);
 
            AddValidationFor(propertyInfo.Name)
                .When(x =>
                {                    var value = property.GetGetMethod().Invoke(this, new object[] { });
                    var result = validationAttribute.GetValidationResult(value,
                                                            new ValidationContext(this, null, null) { MemberName = property.Name });
                    return result != ValidationResult.Success;
                })
                .Show(message);
 
        }
    }
}
Метод AddAllAttributeValidators не протестирован особо, так что используете на свой страх и риск. Скорее всего будет поддерживаться локализация (если указываете ресурсы для аттрибутов, а не сообщения об ошибках). Последнее переписывание нашей модели BindingModel:
public class BindingModel : BindingModelBase<BindingModel>
{    private string _newPassword;
    private string _newPasswordConfirmation;
     public DelegateCommand ChangePasswordCommand { get; private set; }
     public BindingModel()
    {        ChangePasswordCommand = new DelegateCommand(ChangePassword);
 
        AddAllAttributeValidators();
 
        AddValidationFor(() => NewPasswordConfirmation)            .When(x => !string.IsNullOrEmpty(x._newPassword) && string.CompareOrdinal(x._newPassword, x._newPasswordConfirmation) != 0)
            .Show("Password confirmation not equal to password.");
    }
     [Display(Name = "New password")]
    [Required]    [StringLength(80, ErrorMessage = "New password must be a string with maximum length of 80.")]
    public string NewPassword
    {        get { return _newPassword; }
        set
        {            _newPassword = value;
            OnCurrentPropertyChanged();
        }
    }
     [Display(Name = "New password confirmation")]
    public string NewPasswordConfirmation
    {        get { return _newPasswordConfirmation; }
        set
        {            _newPasswordConfirmation = value;
            OnCurrentPropertyChanged();
        }
    }
     private void ChangePassword(object obj)
    {
        ValidateAll();
         if (!HasErrors)
        {            MessageBox.Show("Bingo!");
        }
    }
}

Специально оставил солянку из двух подходов. Все работает, поведение такое же, как в последнем примере.

Исходный код этих примеров можно скачать с моего репозитария на assembla.com.

Have feedback or questions? Looking for consultation?

My expertise: MongoDB, ElasticSearch, Splunk, and other databases. Docker, Kubernetes. Logging, Metrics. Performance, memory leaks.

Send me an email to public@denis.gladkikh.email.

The content on this site represents my own personal opinions and thoughts at the time of posting.

Content licensed under the Creative Commons CC BY 4.0.