Здравствуй!
Мне хотелось бы рассказать об Open Source библиотеке для WPF — ReactiveValidation, в процессе написания которой я пытался ориентироваться на FluentValidation и Reactive UI. Её задача — это валидация формы каждый раз, когда пользователь изменил данные внутри неё.
Пример работы с библиотекой. Хорошая новость — шаблон можно использовать свой
Основные фичи библиотеки:
- Правила создаются через fluent-интерфейс
- Полный внутренний контроль над изменением свойств
- Поддержка локализации (в том числе «на лету»)
- Отображение сообщений в GUI
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}" />
Если всё собрать, то получим следующее простое приложение:
По мере заполнение полей, будет исчезать красный треугольник, свидетельствующий об ошибке:
Текст сообщений и локализация
В приложениях, где нет необходимости использовать локализацию, можно воспользоваться обычными статическими строками. Ниже приведен пример для изменения текста сообщения целиком для валидатора свойства:
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
Проекты, упоминаемые в статье:
- FluentValidation, который я нередко вижу в приложениях типа Web API
- Reactive UI, кроме того рекомендую этот цикл статей (первая, вторая и третья части)
Хотел бы выразить благодарность своим коллегам adeptuss и @baisel
за помощь в разработке первой версии проекта.
А также @truetanchik за терпение и исправление ошибок
Автор: Всеволод