Хабрахабр

[Из песочницы] ReactiveValidation: валидация данных в WPF

Здравствуй, Хабр!

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

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

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

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

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

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

private override void Execute()
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, и окончательно строится не ранее, чем в конструкторе.

AutoRefreshErrorTemplate и ReactiveValidation. Чтобы иметь возможность отображать ошибки в интерфейсе пользователя, необходимо подключить словарь ресурсов из библиотеки (содержащий ControlTemplate по умолчанию) и, желательно, создать стиль (придётся это делать для каждого типа контрола) и внутри него переопределить прикреплённые свойства (Attached property) 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}" />

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

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

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

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

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, можно обеспечить поддержку двух языков.

LanguageManager. Для удобства с помощью статического класса можно задать менеджер ресурсов по умолчанию — для этого достаточно присвоить его значение в ValidationOptions. Тем не менее, существует возможность использования другого менеджера ресурсов. 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 за терпение и исправление ошибок

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть