Denis Gladkikh

outcoldman

My personal blog about software development

  • 19 Oct 2010
  • Silverlight, XAML, Validation

В Silverlight 4 есть несколько способов для валидации введённых данных, точнее несколько подходов для реализации валидации. Первый вариант, реализация валидации на DataAnnotation. Вариант, когда правила валидации описываются при помощи атрибутов. Два других подхода – это реализация одного из интерфейсов IDataErrorInfo или INotifyDataErrorInfo. Я хотел бы остановиться на каждом из подходов, поговорить о преимуществах и недостатках каждого из них. Цель данной статьи выявить лучшие практики для валидации для себя и для вас. Так получилось, что статья оказалась большой, потому реализую ее в два или три подхода. Эта часть только про DataAnnotation.

Подготовка

Итак, давайте перейдем к примеру. Я всю валидацию буду описывать при помощи одного простого окна изменения пароля.

Capture

В нем у меня два контрола PasswordBox, одна кнопка, и ValidationSummary. Для каждого примера у меня будет меняться только объект, который байдится к DataContext этого контрола. В реализации этого объекта и будет скрыта валидация введённых данных. Xaml-описание этого окна:

<UserControl x:Class="SilverlightValidation.MainPage"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
    xmlns:sdk="http://schemas.microsoft.com/winfx/2006/xaml/presentation/sdk" 
    xmlns:DataAnnotations="clr-namespace:SilverlightValidation.DataAnnotations" 
    xmlns:SilverlightValidation="clr-namespace:SilverlightValidation"    
    mc:Ignorable="d" d:DesignHeight="300" d:DesignWidth="400">
     <UserControl.DataContext>
        <DataAnnotations:BindingModel />
    </UserControl.DataContext>
    <Grid x:Name="LayoutRoot" Background="White" Width="500">
         <Grid.ColumnDefinitions>
            <ColumnDefinition Width="Auto" />
            <ColumnDefinition Width="*" />
        </Grid.ColumnDefinitions>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="Auto" />
            <RowDefinition Height="Auto" />
            <RowDefinition Height="Auto" />
        </Grid.RowDefinitions>
         <TextBlock HorizontalAlignment="Right">New Password:</TextBlock>
         <PasswordBox Grid.Column="1" Password="{Binding Path=NewPassword, Mode=TwoWay, 
            ValidatesOnNotifyDataErrors=True, ValidatesOnExceptions=True, ValidatesOnDataErrors=True, NotifyOnValidationError=True}" />
         <TextBlock Grid.Row="1" HorizontalAlignment="Right">New Password Confirmation:</TextBlock>
         <PasswordBox Grid.Row="1"  Grid.Column="1" Password="{Binding Path=NewPasswordConfirmation, Mode=TwoWay, 
            ValidatesOnNotifyDataErrors=True, ValidatesOnExceptions=True, ValidatesOnDataErrors=True, NotifyOnValidationError=True}"  />
         <Button Grid.Row="2" Grid.ColumnSpan="2" HorizontalAlignment="Right"  Content="Change" 
                Command="{Binding Path=ChangePasswordCommand}" />
         <sdk:ValidationSummary Grid.Row="3" Grid.ColumnSpan="2" />
    </Grid>
</UserControl>

В байдинге для PasswordBox у меня сразу же прописаны 4 свойства, связанных с валидацией. Пока скажу только про NotifyOnValidationError, он нужен для того, чтобы сообщить ValidationSummary о том, что нужно отобразить ошибки валидации.

Если кто не знает – PasswordBox имеет только односторонний байдинг от контрола к объекту, сделано это из соображений безопасности. Сам процесс байдинга происходит только при смене фокуса (так правда и у обычного контрола TextBox). В WPF есть возможность изменить это поведение, сделать так, чтобы байдинг происходил на нажатие клавиши, в Silverlight это тоже можно реализовать при помощи Attached Property. Сделать это можно так:

public static class UpdateSourceTriggerHelper
{    public static readonly DependencyProperty UpdateSourceTriggerProperty =
        DependencyProperty.RegisterAttached("UpdateSourceTrigger", typeof(bool), typeof(UpdateSourceTriggerHelper),
                                            new PropertyMetadata(false, OnUpdateSourceTriggerChanged));
     public static bool GetUpdateSourceTrigger(DependencyObject d)
    {        return (bool)d.GetValue(UpdateSourceTriggerProperty);
    }
     public static void SetUpdateSourceTrigger(DependencyObject d, bool value)
    {        d.SetValue(UpdateSourceTriggerProperty, value);
    }
     private static void OnUpdateSourceTriggerChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {        if (e.NewValue is bool && d is PasswordBox)
        {            PasswordBox textBox = d as PasswordBox;
            textBox.PasswordChanged -= PassportBoxPasswordChanged;
             if ((bool)e.NewValue)
                textBox.PasswordChanged += PassportBoxPasswordChanged;
        }
    }
     private static void PassportBoxPasswordChanged(object sender, RoutedEventArgs e)
    {        var frameworkElement = sender as PasswordBox;
        if (frameworkElement != null)
        {
            BindingExpression bindingExpression = frameworkElement.GetBindingExpression(PasswordBox.PasswordProperty);            if (bindingExpression != null)
                bindingExpression.UpdateSource();
        }
    }
}

Чтобы использовать нужно просто устанавливать это свойство для PasswordBox:

<PasswordBox Grid.Column="1" Password="{Binding Path=NewPassword, Mode=TwoWay, 
    ValidatesOnNotifyDataErrors=True, ValidatesOnExceptions=True, ValidatesOnDataErrors=True, NotifyOnValidationError=True}"
    SilverlightValidation:UpdateSourceTriggerHelper.UpdateSourceTrigger="True"/>

Так как я не буду использовать никаких фреймворков в этих примерах, то мне потребуется так же самая обычная реализация DelegateCommand:

public class DelegateCommand : ICommand
{    private readonly Action<object> _execute;
    private readonly Func<object, bool> _canExecute;
     public DelegateCommand(Action<object> execute)
    {        if (execute == null)
            throw new ArgumentNullException("execute");
        _execute = execute;
    }
     public DelegateCommand(Action<object> execute, Func<object, bool> canExecute)
        : this(execute)
    {        if (canExecute == null)
            throw new ArgumentNullException("canExecute");
        _canExecute = canExecute;
    }
     public bool CanExecute(object parameter)
    {        if (_canExecute != null)
            return _canExecute(parameter);
        return true;
    }
     public void Execute(object parameter)
    {
        _execute(parameter);
    }
     public void RaiseCanExecuteChanged()
    {        CanExecuteChanged(this, EventArgs.Empty);
    }
     public event EventHandler CanExecuteChanged = delegate {};
}

Эта реализация не очень хороша, с ней возможно получить утечки памяти (Memory Leak caused by DelegateCommand.CanExecuteChanged Event), лучше будет взять реализацию из Prism версии 2.1 или выше.

В мною описанном примере мне нужно реализовать несколько правил для валидации:

  • New Password должен быть введен;
  • New Password имеет ограниченную длину ввода, пускай 20 символов (печально, что многие разработчики совсем забывают, что за этим тоже нужно следить, а потом получают ошибки, вроде string truncated);
  • New Password Confirmation должен совпадать с New Password.

#1 DataAnnotations & ValidatesOnExceptions

Этот вариант валидации появился еще до Silverlight 4, либо со второй, либо с третьей версии. Я с Silverlight знаком на хорошем уровне только с 3-ей версии, потому точно могу сказать только, что он был именно в этой версии. Единственный, наверное, плюс этого типа валидации то, что он есть во всех технологиях (или практически во всех?). По крайней мере, в ASP.NET он тоже есть. Основная идея в этом варианте – валидация на бросаниях исключений. Хотя не обязательно, можно реализовать и по-другому. По крайней мере, примеры, которые вы найдете, будут именно основываться на них, и вся инфраструктура будет заточена под них. В Silverlight 3 валидация на исключениях была единственным возможным вариантом (исключаем с нуля написания своего варианта). В байдинге за поддержку валидации на исключениях отвечает свойство ValidatesOnExceptions.

Давайте приступим к реализации. Буду описывать BindingModel. Все примеры будут начинаться с одной и той же реализации с поддержкой интерфейса INotifyPropertyChanged и набором полей – два поля на пароль и одно свойство – это команда, которая байдиться на кнопку и которая должна производить смену пароля (инициализировать ее буду в конструкторе потом):

public class BindingModel : INotifyPropertyChanged
{    private string _newPassword;
    private string _newPasswordConfirmation;
     public DelegateCommand ChangePasswordCommand { get; private set; }
     #region INotifyPropertyChanged
     public event PropertyChangedEventHandler PropertyChanged = delegate { };
     private void OnPropertyChanged(string propertyName)
    {        PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
    }
     #endregion
}

Для этого этого примера нам так же потребуются два вспомогательных метода:

private bool IsValidObject()
{    ICollection<ValidationResult> results = new Collection<ValidationResult>();
    return Validator.TryValidateObject(this, new ValidationContext(this, null, null), results, true) && results.Count == 0;
}
 private void ValidateProperty(string propertyName, object value)
{    Validator.ValidateProperty(value, new ValidationContext(this, null, null) { MemberName = propertyName });
}

Первый проверяет валидность всего объекта BindingModel, второй проверяет валидность определенного свойства. Оба метода используют класс Validator, это инфраструктура Silverlight. Этот класс собирает все аттрибуты, в которых описывается валидация, и соответственно производит валидирование. В методе IsValidObject он просто вернет булевское значение. Метод ValidateProperty бросить исключение, если какое-то из условий валидации не пройдет. Условия можно описать при помощи следующих заготовленных аттрибутов, наследованных от ValidationAttribute (можно реализовать свой): StringLengthAttribute, RequiredAttribute, RegularExpressionAttribute, RangeAttribute, DataTypeAttribute, CustomValidationAttribute. По ссылкам можно ознакомиться ближе с каждым из ним, а я теперь опишу два свойства моего типа BindingModel:

[Required]
[StringLength(20)][Display(Name = "New password")]
public string NewPassword
{    get { return _newPassword; }
    set
    {        _newPassword = value;
        OnPropertyChanged("NewPassword");
        ChangePasswordCommand.RaiseCanExecuteChanged();        ValidateProperty("NewPassword", value);
    }
}
 [CustomValidation(typeof(BindingModel), "CheckPasswordConfirmation")]
[Display(Name = "New password confirmation")]
public string NewPasswordConfirmation
{    get { return _newPasswordConfirmation; }
    set
    {        _newPasswordConfirmation = value;
        OnPropertyChanged("NewPasswordConfirmation");
        ChangePasswordCommand.RaiseCanExecuteChanged();        ValidateProperty("NewPasswordConfirmation", value);
    }
}

Думаю, что с NewPassword свойством все ясно, там используется два аттрибута Required и StringLenght, которые реализуют два условия валидации. Так же я использую DisplayAttribute для того, чтобы ValidationSummary правильно отображало, что скрывается за этим полем, а не простое “NewPassword”. Оба свойства имеют в set методах одинаковую процедуру: установить значение, сказать, что значение изменилось, сказать команде, что может быть изменилось значение CanExecute метода (если используем), и произвести валидацию для этого свойства. Свойство NewPasswordConfirmation использует CustomValidatorAttribute, в нем описан метод, который нужно вызвать для того, чтобы произвести валидацию, а так же описан тип, в котором этот метод описан. На этот метод накладываются следующие правила: он должен быть публичным, статичным, возвращать тип ValidationResult, принимать первым параметром переменную, тип у которой должен быть такой же как у свойства, второй параметр может быть ValidationContext. Моя реализация следующая:

public static ValidationResult CheckPasswordConfirmation(string value, ValidationContext context)
{    var bindingModel = context.ObjectInstance as BindingModel;
    if (bindingModel == null)
        throw new NotSupportedException("ObjectInstance must be BindingModel");
     if (string.CompareOrdinal(bindingModel._newPassword, value) != 0)
        return new ValidationResult("Password confirmation not equal to password.");
     return ValidationResult.Success;
}

Дальше нужно решить как быть с кнопкой, как она будет работать. Сделаю следующим образом:

public BindingModel()
{    ChangePasswordCommand = new DelegateCommand(ChangePassword, CanChangePassword);
}
 private bool CanChangePassword(object arg)
{    return IsValidObject();
}
 private void ChangePassword(object obj)
{    if (ChangePasswordCommand.CanExecute(obj))
    {        MessageBox.Show("Bingo!");
    }
}

Для этого примера я так же установлю для обоих контролов PasswordBox UpdateSourceTrigger=”True” в Xaml разметке. Итого получаем следующий результат (внизу SL пример):

Get Microsoft Silverlight

Минусы этого подхода очевидны. Основной минус – это сами бросания исключений. Они мешают везде. Если отлаживаете приложение, то это, скорее всего, лишнее при отладке (особенно если включите байдинг на каждое изменение значение, на каждое нажатие клавиши). Самое неприятное, что, например, нет нормальной возможности обнулить поле извне без какого-нибудь backend свойства или метода. Например, нам нужно было бы установить null в оба поля при загрузке контрола, а не сможем просто так - получим исключения. Очень не приятно. Конечно, решается, при помощи какого-нибудь метода Set[Property]Value, но в целом свойства реально можно будет использовать только для байдинга, что немного архитектурно странно.

Другое, что мне тоже очень не нравится, это сама реализация. Приходится использовать CanExecute свойство для команды, и делать неактивным кнопку изменения пароля, что, мне кажется, не совсем очевидным и ясным для пользователя. Очевиднее, когда кнопка активна всегда. А когда пользователь нажмет ее в случае, если ничего не установлено – он увидит список ошибок валидации. Но так просто это реализовать не получится (или я не знаю как). Нельзя просто провалидировать весь объект и сказать контролам о том, что произошли какие-то ошибки валидации. То есть в методе ChangePassword я могу только проверить (и это делаю на всякий случай), но не вызвать какое-нибудь событие о том, что произошли ошибки валидации. Хотя реализовать подобное поведение, конечно же, можно. Нужно просто дернуть для каждого контрола UpdateBinding, для этого нужно написать специальный класс (типа Validation Scope), который бы это делал.

Мне так же приходится в этом случае постоянно использовать UpdateSourceTrigger, так как если байдинг будет происходить на смену фокуса, то у пользователя будет ступор. Он ввел New Password, ввел Confirmation, оба пароля одинаковые, а кнопка до сих пор не валидна, просто потому, что байдинг еще не произошел. А когда используешь UpdateSourceTrigger при вводе Confirmation пароля на каждое нажатие клавиши уже видишь, что пароли не совпадают. Ужасно кривое решение.

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

Этот пример можно скачать с assembla.com

Have a question? Want to follow up? Send a comment? Or just ask for help or consultation? Send me an email at public[at]denis[dot]gladkikh[one more dot]email.