ReactiveValidation: валидация данных в WPF

в 7:33, , рубрики: .net, C#, fluent interface, open source, reactiveui, validation, wpf

Здравствуй!

Мне хотелось бы рассказать об Open Source библиотеке для WPF — ReactiveValidation, в процессе написания которой я пытался ориентироваться на FluentValidation и Reactive UI. Её задача — это валидация формы каждый раз, когда пользователь изменил данные внутри неё.

ReactiveValidation: валидация данных в WPF - 1

Пример работы с библиотекой. Хорошая новость — шаблон можно использовать свой

Основные фичи библиотеки:

  • Правила создаются через fluent-интерфейс
  • Полный внутренний контроль над изменением свойств
  • Поддержка локализации (в том числе «на лету»)
  • Отображение сообщений в GUI

Причины создания

Имеется приложение на WPF, которое принимает данные от пользователя и передаёт их серверу. Сервер же, в свою очередь, вызывает хранимые процедуры БД. Полная проверка валидности входных данных реализована в коде хранимой процедуры, поэтому пользователь, передав некорректные параметры, гарантировано получит исключение с сообщением (оно вернётся в приложение и отобразится). Очевидно, что часть исключений мы можем предугадать на клиенте, и там же должны их обработать. В начале мы использовали следующие конструкции:

private override void Execute()
{
    if(string.IsNullOrEmpty(Property1) == true)
    {
        MessageBox.Show("Необходимо указать Property1");
        return;
    }

    if(Property2 < Property3)
    {
        MessageBox.Show("Property2 должно быть не меньше Property3");
        return;
    }

    ...

    //отправка данных серверу
    do();
}

К недостаткам данного варианта можно отнести:

  • Избыток кода
  • Взаимодействие с пользователем только через всплывающие окна

Следующим этапом стала реализация валидации через атрибуты аннотации(DataAnnotations) и использование IDataErrorInfo. В результате получился следующий код:

public class ViewModel : BaseViewModel
{
    [IsRequired]
    public string Property1 { get {...} set {...} }

    [CustomValidation(typeof(ViewModel), nameof(ValidateProperty2))]
    public int? Property2 { get {...} set {...} }

    public int? Property3 { get {...} set {...} }


    [UsedImplicitly]
    public static ValidationResult ValidateProperty2(int? property2, ValidationContext validationContext)
    {
        var viewModel = (ViewModel)validationContext.ObjectInstance;
        if (viewModel.Property2 < viewModel.Property3)
        {
            return new ValidationResult("Property2 должно быть не меньше Property3");
        }

        return ValidationResult.Success;
    }
}

BaseViewModel реализует в себе механизм, который через рефлексию (отражение) получает список свойств и их валидационных атрибутов. При изменении свойства, вызывается проверка всех атрибутов, а результаты записываются в словарь. При вызове индексатора string this[string columnName] из интерфейса IDataErrorInfo отдаются эти значения (конкатенация сообщений).

Данный подход сильно упростил наиболее частые случаи использования валидации – проверку обязательных значений, сравнения с константами и прочее. Реализация интерфейса IDataErrorInfo позволяет отображать невалидные поля в GUI. Также есть возможность блокировки кнопки выполнения до тех пор, пока пользователь не заполнит корректно все поля. В таком виде работа библиотеки нас полностью устраивала, но потом попались зависимые друг от друга свойства…
Как раз тот пример, который я привёл выше, иллюстрирует эту проблему. Если для проверки используются два значения, которые могут меняться, то при изменении одного, необходимо перевалидировать и другое. Описанный мной выше механизм это не поддерживал, от чего в некоторых местах код стал трещать от костылей, поддерживающих всё это в работоспособном состоянии (их я не стал приводить в примере, но они основаны на вызовах PropertyChanged другого свойства с контролем зацикливания). Прислушиваясь к советам моих коллег, я написал новый механизм, который исправил упомянутые недостатки, после чего возникло желание использовать его без зазрения совести и в других проектах, не связанных с работой. Именно поэтому мне захотелось переписать весь код с нуля, учитывая ошибки исходного проектирования и добавив новые.

В процессе разработки я старался ориентироваться на FluentValidation, поэтому синтаксис легко узнаваем. Однако, существуют и различия: что-то было адаптировано под задачу, что-то не было реализовано, но обо всём по порядку.

Вся информация о состоянии объекта хранится в свойстве Validator, которое формируется при помощи правил. Рассмотрим его создание на примере свойств машины:

public class CarViewModel : ValidatableObject
{
    public CarViewModel()
    {
        Validator = GetValidator();
    }

    private IObjectValidator GetValidator()
    {
        var builder = new ValidationBuilder<CarViewModel>();

        builder.RuleFor(vm => vm.Make).NotEmpty();
        builder.RuleFor(vm => vm.Model).NotEmpty().WithMessage("Please specify a car model");
        builder.RuleFor(vm => vm.Mileage).GreaterThan(0).When(model => model.HasMileage);
        builder.RuleFor(vm => vm.Vin).Must(BeAValidVin).WithMessage("Please specify a valid VIN");
        builder.RuleFor(vm => vm.Description).Length(10, 100);

        return builder.Build(this);
    }

    private bool BeAValidVin(string vin)
    {
        //Здесь проверяем VIN номер на корректность
    }

    //Далее следуют свойства с реализацией INotifyPropertyChanged
}

Данный пример сильно похож на тот, что предлагает FluentValidation, поэтому надеюсь, что он не нуждается в комментариях. Акцентирую внимание на том, что валидатор является внутренним объектом по отношению к ViewModel, и окончательно строится не ранее, чем в конструкторе.

Чтобы иметь возможность отображать ошибки в интерфейсе пользователя, необходимо подключить словарь ресурсов из библиотеки (содержащий ControlTemplate по умолчанию) и, желательно, создать стиль (придётся это делать для каждого типа контрола) и внутри него переопределить прикреплённые свойства (Attached property) ReactiveValidation.AutoRefreshErrorTemplate и ReactiveValidation.ErrorTemplate, как показано на примере:

xmlns:b="clr-namespace:ReactiveValidation.WPF.Behaviors;assembly=ReactiveValidation"
...
<ResourceDictionary>
    <ResourceDictionary.MergedDictionaries>
        <ResourceDictionary Source="/ReactiveValidation;component/WPF/Themes/Generic.xaml" />
    </ResourceDictionary.MergedDictionaries>

    <Style x:Key="TextBox" TargetType="TextBox">
        <Setter Property="b:ReactiveValidation.AutoRefreshErrorTemplate" Value="True" />
        <Setter Property="b:ReactiveValidation.ErrorTemplate" Value="{StaticResource ValidationErrorTemplate}" />
         <!-- Margin просто для красоты -->
         <Setter Property="Margin" Value="3" />
    </Style>
</ResourceDictionary>

Этот код удобнее всего размещать в App.xaml, где он будет доступен всему приложению

Я думаю, что причины добавления ControlTemplate очевидны. А вот свойства могут вызывать недоумение. К сожалению, стандартный Validation из WPF содержит множество проблем, которые приводят к некорректному отображению шаблона ошибок (применяется, когда свойство валидно и наоборот). Чтобы этого избежать, была написана горстка костылей, которые работают через прикреплённые свойства.

Остаётся только применить стиль к контролам и всё будет работать:

<TextBlock Grid.Row="0" Grid.Column="0" Margin="3" Text="Make: " />
<TextBox Grid.Row="0" Grid.Column="1" Style="{StaticResource TextBox}"
         Text="{Binding Make, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />

<TextBlock Grid.Row="1" Grid.Column="0" Margin="3" Text="Model: " />
<TextBox Grid.Row="1" Grid.Column="1" Style="{StaticResource TextBox}"
         Text="{Binding Model, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />

<TextBlock Grid.Row="2" Grid.Column="0" Margin="3" Text="Has mileage: " />
<CheckBox Grid.Row="2" Grid.Column="1" Margin="3"
          IsChecked="{Binding HasMileage, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />

<TextBlock Grid.Row="3" Grid.Column="0" Margin="3" Text="Mileage: " />
<TextBox Grid.Row="3" Grid.Column="1" Style="{StaticResource TextBox}" IsEnabled="{Binding HasMileage}"
         Text="{Binding Mileage, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />

<TextBlock Grid.Row="4" Grid.Column="0" Margin="3" Text="Vin: " />
<TextBox Grid.Row="4" Grid.Column="1" Style="{StaticResource TextBox}"
         Text="{Binding Vin, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />

<TextBlock Grid.Row="5" Grid.Column="0" Margin="3" Text="Description: " />
        <TextBox Grid.Row="5" Grid.Column="1" Style="{StaticResource TextBox}"
         Text="{Binding Description, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />

Если всё собрать, то получим следующее простое приложение:

ReactiveValidation: валидация данных в WPF - 2

По мере заполнение полей, будет исчезать красный треугольник, свидетельствующий об ошибке:
ReactiveValidation: валидация данных в WPF - 3

Текст сообщений и локализация

В приложениях, где нет необходимости использовать локализацию, можно воспользоваться обычными статическими строками. Ниже приведен пример для изменения текста сообщения целиком для валидатора свойства:

builder.RuleFor(vm => vm.PhoneNumber)
    .NotEmpty()
        .When(vm => Email, email => string.IsNullOrEmpty(email) == true)
        .WithMessage("You need to specify a phone or email")
    .Matches(@"^d{11}$")
        .WithMessage("Phone number must contain 11 digits");

Чтобы указать отображаемое имя свойства можно воспользоваться атрибутом DisplayName (из пространства имён ReactiveValidation.Attributes)

[DisplayName(DisplayName = "Minimal amount")]
public int MinAmount { get; set; }

Для локализации сообщений используется класс ResourceManager, который создаётся вместе с ресурсами. Создав два файла Default.resx и Default.ru.resx, можно обеспечить поддержку двух языков.

Для удобства с помощью статического класса можно задать менеджер ресурсов по умолчанию — для этого достаточно присвоить его значение в ValidationOptions.LanguageManager.DefaultResourceManager. Тем не менее, существует возможность использования другого менеджера ресурсов. Всё выше сказанное продемонстрировано в этом примере:

builder.RuleFor(vm => vm.Email)
    .NotEmpty()
        .When(vm => PhoneNumber, phoneNumber => string.IsNullOrEmpty(phoneNumber) == true)
        .WithLocalizedMessage(nameof(Resources.Default.PhoneNumberOrEmailRequired))
    .Matches(@"^w+@w+.w+$")
        .WithLocalizedMessage(Resources.Additional.ResourceManager, nameof(Resources.Additional.NotValidEmail));

При незаполненном значении Email или PhoneNumber будет выведено сообщение из ресурса по умолчанию с ключом PhoneNumberOrEmailRequired. Кроме того, почта должна удовлетворять регулярному выражению, а при несоответствии будет выведено сообщение уже из ресурса Additional с ключом NotValidEmail.

Для локализации отображаемых имён нужно воспользоваться атрибутом и передать DisplayNameKey и ResourceType для переопределения ресурса(для атрибутов невозможно использовать сам ResourceManager, поэтому используется его тип):

[DisplayName(DisplayNameKey = nameof(Resources.Default.PhoneNumber))]
public string PhoneNumber { get; set; }

[DisplayName(ResourceType = typeof(Resources.Additional), DisplayNameKey = nameof(Resources.Additional.Email))]
public string Email { get; set; }

Для локализации берётся культура из CultureInfo.CurrentUICulture. Кроме того, имеется возможность её переопределить с помощью ValidationOptions.LanguageManager.CurrentCulture. По умолчанию, текст сообщений не меняется при смене культуры, однако, это поведение можно включить с помощью опции ValidationOptions.LanguageManager.TrackCultureChanged, при этом стоит учитывать ряд особенностей:

  • Изменение локализации «на лету» основано на том, что внутри класса ValidationMessage происходит подписка на событие класса LanguageManager
  • Подписка происходит только в том случае, если свойство TrackCultureChanged равно true. Поэтому его стоит задавать лишь один раз — при старте приложения и более не менять.
  • Если культура меняется с помощью CultureInfo.CurrentUICulture, после его изменения нужно вызвать метод ValidationOptions.LanguageManager.OnCultureChanged()

Кроме того, не имеет смысла включать данное поведение, если не поддерживается смена локализации в интерфейсе.

Дополнительные возможности:

  • Есть 2 основных типа сообщений: ошибки(Error) и предупреждения(Warining). Предупреждения также отображаются на GUI(только оранжевым цветом), но модель считается валидной. Кроме того, существует градация обычные/простые(Simple). Обычные сообщения показываются при фокусе или наведении курсора на контрол, тогда как простые только при наведении
  • Правила основаны на extensions-методах, так что легко можно их расширить собственными валидаторами
  • Базовый интерфейс, необходимый для валидируемого объекта — IValidatableObject. В библиотеке существует реализация ValidatableObject, основанная на INPC. Вы легко можете сами определить свой базовый класс с этим интерфейсом, так как его реализация содержит немного кода (в проекте показаны примеры с переопределением от ReactiveObject из Reactive UI)
  • Если валидируется свойство, унаследованное от INotifyPropertyChanged или INotifyCollectionChanged, то специальные классы-адаптеры следят за их вызовом, инициируя перевалидацию. Можно расширить подписки своими классами, например, добавить IReactiveNotifyCollectionItemChanged<> из Reactive UI

О чём хотелось бы еще сказать:

  • Отсутствует асинхронная валидация. Я думаю, что можно подискутировать о том, нужна ли она или нет, но тем не менее, сейчас она не поддерживается.
  • Мне стыдно, но возможны ошибки. Надеюсь, что они быстро будут найдены и исправлены.
  • Это мой первый опыт разработки для Open Source. Я очень волнуюсь, что будет недостаточно тех возможностей, что я вложил изначально. Надеюсь, что это также будет легко поправимо.

Исходный код доступен на GitHub, лицензия MIT
Кроме того, Вы можете скачать из Nuget

Проекты, упоминаемые в статье:

Хотел бы выразить благодарность своим коллегам adeptuss и @baisel
за помощь в разработке первой версии проекта.

А также @truetanchik за терпение и исправление ошибок

Автор: Всеволод

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js