Всем привет!

5 лет назад я рассказывал, как написал библиотеку для валидации данных в WPF. Всё это время активно развивался и продолжает развиваться другой десктоп фреймворк - Avalonia UI. Мне очень хотелось поддержать также и его, что и было сделано во второй версии библиотеки. Помимо этого, туда вошли другие интересные фичи.

Чтобы не было необходимости перечитывать предыдущую статью (тем более, многое поменялось с тех пор), здесь описано практически всё, что умеет библиотека на данный момент.

FluentTheme от Avalonia с разного рода ошибками
FluentTheme от Avalonia с разного рода ошибками

Небольшое замечание: с момента выхода первой версии появилось популярное решение от ReactiveUI - ReactiveUI.Validation. Тем не менее, надеюсь, что возможности ReactiveValidation также будут интересны читателю.

Задание правил

При разработке в качестве вдохновения выступала известная библиотека FluentValidation, так как она имеет отличный и легко читаемый fluent-интерфейс для настойки правил. Эта библиотека прекрасно справляется с разовой валидации объекта (например, проверка параметров входящего web-запроса), но не подходит для валидации объекта, свойства которого постоянно меняются (как это часто происходит в десктопе).

По этой причине вся логика для ReactiveValidation написана с нуля. Она сразу завязана на объекты, реализующие INotifyPropertyChanged и INotifyDataErrorInfo, которые используются как в WPF, так и в Avalonia, чтобы взаимодействовать с UI. Но для удобства программиста синтаксис во многом копирует FluentValidation:

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

builder.RuleFor(vm => vm.Email)
    .NotEmpty()
        .When(vm => PhoneNumber, phoneNumber => string.IsNullOrEmpty(phoneNumber))
        .WithMessage("You need to specify a phone or email")
    .Must(IsValidEmail)
        .WithMessage("Not valid email");


builder.RuleFor(vm => vm.Password)
    .NotEmpty()
    .MinLength(8, ValidationMessageType.Warning)
        .WithMessage("For a secure password, enter more than {MinLength} characters. You entered {TotalLength} characters");

builder.RuleFor(vm => vm.ConfirmPassword)
    .Equal(vm => vm.Password);


builder.RuleFor(vm => vm.Country)
    .NotEmpty()
    .AllWhen(vm => vm.AdditionalInformation);

builder.RuleFor(vm => vm.City)
    .NotEmpty()
    .AllWhen(vm => vm.AdditionalInformation);

Также, как и для FluentValidation, для каждого свойства можно задавать правила (например, NotEmpty, Matches, MinLength, Must). Если правило должно проверяться только при особых условиях, то есть When. Чтобы задать условие для всего набора правил, можно использовать AllWhen. Для переопределения текста сообщения можно использовать WithMessage / WithLocalizedMessage. Чтобы задать важность правила используется ValidationMessageType (чтобы Warning отображался оранжевым требуются переопределить шаблоны, подробнее в самом конце статьи).

Обратите внимание на важный момент. Если в правиле участвует значение другого свойства, то его обязательно нужно передать в параметры как это сделано с When(vm => Email, email => string.IsNullOrEmpty(email)). Таким образом, валидатор будет знать, что правилу присвоено два зависимых свойства (PhoneNumber и Email ), и будет перепроверять его при изменении любого из них.

Создание валидатора для объекта

Чтобы правила применялись для объекта необходимо:

  1. Унаследовать валидируемый объект от ValidatableObject.

  2. Инициализировать свойство Validator, используя ValidationBuilder<T>.

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

    private IObjectValidator GetValidator()
    {
        var builder = new ValidationBuilder<BaseSampleViewModel>();
        // Здесь настраиваются правила.                    
        return builder.Build(this);
    }

    // Свойства BaseSampleViewModel с реализацией INotifyPropertyChanged.
}

Если вы используете WPF, то во View дополнительно нужно вставить элемент AdornerDecorator (показано тут). В Avalonia всё уже встроено в базовые стили, и ничего дополнительно делать на надо (за что большой поклон разработчикам).

Если у вас уже есть собственный базовый класс, то необходимо реализовать интерфейс IValidatableObject. Сделать это достаточно просто, смотреть здесь.

Больше ничего не требуется, чтобы валидация начала работать. Внутри ValidatableObject уже есть вся логика, чтобы View получило информацию об ошибках. Полный пример ViewModel можно посмотреть здесь.

В новой версии ReactiveValidation добавился привычный для FluentValidation способ задать правила в отдельном файле:

public class YourViewModelValidation : ValidationRuleBuilder<YourViewModel>
{
    public YourViewModelValidation()
    {
        RuleFor(vm => vm.Name)
            .NotEmpty()
            .MaxLength(16)
            .NotEqual("foo");

        RuleFor(vm => vm.Surname)
            .Equal("foo");

        RuleFor(vm => vm.PhoneNumber)
            .NotEmpty(ValidationMessageType.Warning)
            .Matches(@"^\d{9,12}$");
    }
}

Тогда сам валидатор можно создать следующими способами:

// Это конструктор класса YourViewModel.
public YourViewModel(IValidatorFactory validatorFactory)
{
    // Просто создать объект класса.
    Validator = new YourViewModelValidation().Build(this);

    // Использовать фабрику-синглтон.
    Validator = ValidationOptions.ValidatorFactory.GetValidator(this);

    // Зарегистрировать фабрику в DI и прокидывать её в конструктор.
    Validator = validatorFactory.GetValidator(this);
}

Про регистрацию фабрики подробнее можно почитать тут.

Правила для сложных свойств

Если у вашего объекта есть свойства, которые:

  • сами реализуют INotifyDataErrorInfo или IValidatableObject

  • являются коллекциями, а их элементы реализуютINotifyDataErrorInfo или IValidatableObject,

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

builder.RuleFor(vm => vm.InnerObjectValue)
    .SetValueValidator(GetInnerObjectValidator)
    .NotNull()
    .ModelIsValid();

builder.RuleForCollection(vm => vm.InnerObjectsCollection)
    .SetCollectionItemValidator(GetInnerObjectValidator)
    .NotNull()
    .Count(3, 5)
    .CollectionElementsAreValid();

Разберём по вызовам:

  1. SetValueValidator(GetInnerObjectValidator), можно использовать только если InnerObjectValue наследуется от IValidatableObject. Для любого объекта, который будет присвоен в InnerObjectValue, будет создан валидатор с помощью метода GetInnerObjectValidator (метод определяете вы - можно как создавать валидатор вручную, так и использовать фабрику).

  2. ModelIsValid() , можно использовать только если InnerObjectValue наследуется от INotifyDataErrorInfo. Если объектInnerObjectValue не валиден, то базовый объект также будет не валиден.

  3. SetCollectionItemValidator(GetInnerObjectValidator), можно использовать только если элементы коллекции наследуются от IValidatableObject. По аналогии с п. 1 - для всех элементов коллекции автоматически создаётся валидатор. Если коллекция наследует INotifyCollectionChanged, то валидаторы будут создаваться для всех добавляемых в коллекцию элементов.

  4. CollectionElementsAreValid(), можно использовать только если элементы коллекции наследуются от INotifyDataErrorInfo. Чтобы работало отслеживание новых элементов и удаление старых - обязательно используйте коллекцию с INotifyCollectionChanged, например ObservableCollection<T>.

Каскадный режим

Тоже привычная для FluentValidation вещь, которая появилась в новой версии ReactiveValidation. Если для одного свойства задано несколько правил, то можно задать режим их проверки:

  1. CascadeMode.Continue (режим по умолчанию) - проверяются все правила для свойства. На экран выводится весь список.

  2. CascadeMode.Stop - если правило вернуло ошибку, то все последующие игнорируются.

Режим можно задать:

  1. Для свойства:

builder.RuleFor(vm => vm.Email)
    .WithPropertyCascadeMode(CascadeMode.Stop)
    .NotEmpty().When(vm => vm.PhoneNumber, phoneNumber => string.IsNullOrEmpty(phoneNumber))
    .Must(IsValidEmail);
  1. Для билдера:

builder.PropertyCascadeMode = CascadeMode.Stop;

// Или, если правила задаются в отдельном классе.
PropertyCascadeMode = CascadeMode.Stop;
  1. Глобально:

ValidationOptions
    .Setup()
    .UsePropertyCascadeMode(CascadeMode.Stop);

Трансформация типов свойств

Нередко встречается ситуация, когда число во ViewModel определяется как string и привязывается к TextBox. Но при этом нам нужно валидировать строку как число и, возможно, навесить дополнительные правила. Это можно сделать через Transform:

builder
    .TransformToInt(vm => vm.AgeString)
    .NotNull().WithMessage("Age should be valid integer string")
    .Between(18, 35);

Возможности:

  1. Стандартно поддерживаются методы для конвертации из строки в short, ushort, int, uint, long, ulong, float, double, decimal.

  2. Можно передать собственный метод трансформации:Func<TObject, TProp, TPropTransformed> transformer илиFunc<TProp, TPropTransformed> transformer.

  3. Можно создать свой собственный класс для трансформации, унаследовав от ReactiveValidation.Validators.PropertyValueTransformers.IValueTransformer<in TObject, out TTo>.

Асинхронная валидация

В новой версии появилась возможность асинхронно проверять свойства. Выглядит это так:

builder.RuleFor(vm => vm.Email)
    .NotEmpty().When(vm => vm.PhoneNumber, phoneNumber => string.IsNullOrEmpty(phoneNumber))
    .Must(IsValidEmail)
    .Must(CheckEmailIsInUseAsync).WithMessage("Email is already using");

...

/// <summary>
/// Check of email is valid.
/// </summary>
private static bool IsValidEmail(string? email)
{
    // Some sync logic.
}

/// <summary>
/// Async check that email is already using.
/// </summary>
private static async Task<bool> CheckEmailIsInUseAsync(string? email, CancellationToken cancellationToken)
{
    // Some async logic.
}

У асинхронной валидации есть своя специфика:

  1. Пока выполняется асинхронное правило свойство считается валидным (если не сработали другие правила). Поэтому для проверки модели необходимо дождаться завершения валидации (для этого есть метод Validator.WaitValidatingCompletedAsync). Не забудьте заблокировать пользователю интерфейс, иначе можно ждать до тех пор, пока ему не надоест менять свойства.

  2. Желательно использовать CascadeMode.Stop для свойств с асинхронной валидацией. Иначе, если в начале есть синхронные правила с ошибкой (например, проверка формата номера телефона регуляркой), то всё равно сработает асинхронное правило, и сервер может вернуть 400 ошибку.

  3. Если есть асинхронное правило для свойства, то все правила после него также становятся асинхронными (сделано для корректной работы CascadeMode.Stop) .

  4. Помните, что свойство может быстро меняться (например, пользователь быстро печатает свой почтовый адрес). Чтобы не долбить сервер запросами по каждому новому символу, можно использовать Throttle (подробно описано здесь, за идею спасибо @WizMe). Также в асинхронную версию метод Must передаётся CancellationToken, чтобы отменять валидацию, которая уже не актуальна из-за изменения значения свойства.

Встроенная локализация

Локализация была еще в прошлой версии, но сейчас я постарался её сделать более удобной.

Встроенные валидаторы поддерживают следующие языки: русский, английский, немецкий и чешский. Культура берётся из CultureInfo.CurrentUICulture, или можно задать явно:

ValidationOptions.LanguageManager.Culture = culture;

Пользовательские локализованные сообщения извлекаются с помощью провайдера, который вам необходимо настроить. Из коробки поддерживается провайдер .resx файлов:

ValidationOptions
    .Setup()
    .UseStringProvider(new ResourceStringProvider(
        Samples.Resources.Default.ResourceManager,
        new Dictionary<string, ResourceManager>
        {
            { nameof(Samples.Resources.Additional), Samples.Resources.Additional.ResourceManager },
        }));

Он позволяет зарегистрировать файл ресурсов по умолчанию (Default.ResourceManager), а также дополнительные (в примере с именем Additional).

Если у вас локализация расположена в файлах, БД или еще где-то, вы можете создать собственную реализацию с помощью интерфейса ReactiveValidation.Resources.StringProviders.IStringProvider.

Сообщения извлекаются из провайдера по ключам:

builder.RuleFor(vm => vm.Password)
    .NotEmpty()
    .MinLength(8, ValidationMessageType.Warning)
        .WithLocalizedMessage("key");
// Или.
builder.RuleFor(vm => vm.Password)
    .NotEmpty()
    .MinLength(8, ValidationMessageType.Warning)
        .WithLocalizedMessage("resource", "key");

Кроме того, можно локализовать имена свойств:

[DisplayName(DisplayNameKey = "key")]
public string? PhoneNumber { get; set; }
// Или.
[DisplayName(DisplayNameResource = "resource", DisplayNameKey = "key")]
public string? PhoneNumber { get; set; }

Если требуется менять культуру "на лету", включите это в настройках:

ValidationOptions
    .Setup()
    .TrackCultureChanged();

Для такой валидации требуются переопределение разметки шаблона ошибок. О нём ниже.

Шаблоны ошибок

Интерфейс INotifyPropertyChanged изначально не предназначен для того, чтобы через него передавались разные уровни ошибок. Также стандартные шаблоны что в WPF, что в Avalonia используют ToString, чтобы получить текст ошибки, и это не даёт сделать Binding на изменяемое свойство сообщения. Поэтому в пакетах ReactiveValidation.Wpf и ReactiveValidation.Avalonia они переопределены.

Avalonia

В 11 версии Авалонии очень сильно меняли темы. Причём различия есть даже между превью версией и релиз кандидат. По этой причине тема для Авалонии версии 0.10 поддерживается ReactiveValidation.Avalonia до версии 2.0.3 включительно. Версия 2.0.4-pre поддерживает только версию Avalonia 11.0.0-rc1.1 и выше.

Переопределённые шаблоны подключаются как обычная тема:

xmlns:rvThemes="clr-namespace:ReactiveValidation.Avalonia.Themes;assembly=ReactiveValidation.Avalonia"
...
<Application.Styles>
    <FluentTheme />
    <rvThemes:FluentTheme />
</Application.Styles>

<!-- or -->

<Application.Styles>
    <default:SimpleTheme />
    <rvThemes:SimpleTheme />
</Application.Styles>

Также есть возможность поменять цвет кистей, о том как это сделано написано здесь.

WPF

<!-- Подключить словарь ресурсов. -->
<Application.Resources>
    <ResourceDictionary>
        <ResourceDictionary.MergedDictionaries>
            <ResourceDictionary Source="/ReactiveValidation.Wpf;component/Themes/Generic.xaml" />
        </ResourceDictionary.MergedDictionaries>
    </ResourceDictionary>
</Application.Resources>

<!-- Использовать шаблон. -->
<TextBox Validation.ErrorTemplate="{StaticResource WpfErrorTemplate}" />
<!-- Или -->
<TextBox Validation.ErrorTemplate="{StaticResource ExtendedErrorTemplate}" />

<!-- Или прописать в стили. -->
<Style TargetType="TextBox">
    <Setter Property="Validation.ErrorTemplate" Value="{StaticResource WpfErrorTemplate}" />
</Style>
<!-- Или -->
<Style TargetType="TextBox">
    <Setter Property="Validation.ErrorTemplate" Value="{StaticResource ExtendedErrorTemplate}" />
</Style>

Больше информации про шаблоны и их настройку можно найти здесь.

Заключение

Большое спасибо всем, кто дочитал до конца. Буду рад ответить на вопросы, услышать предложения или замечания (включая орфографические/пунктуационные и другие ошибки).

Ссылки:

  1. Проект на GitHub

  2. Документация

  3. Nuget пакеты для Avalonia 0.10 и Avalonia 11

  4. Nuget пакет для WPF

Комментарии (0)