Хелоу всем, кто сюда заглянул. Ваш писатель-программист пришел к вам с новой статьей про валидаторы. В прошлой части (прошлая часть не так популярна, поскольку это была моя первая статья. Со временем я ее переписала, но рейтинг уже не исправить) мы разобрались как:
делать кастомные сообщения (и поняли, что там есть подводные камни)
использовать Custom и Must для сложной логики
Теперь новый челлендж. Проект растёт, валидаторов становится много. И вы замечаете, что одни и те же правила переезжают из класса в класс:
// Валидатор пользователя RuleFor(x => x.Email) .NotEmpty().WithMessage("Email обязателен") .EmailAddress().WithMessage("Неверный формат") .MaximumLength(256); // Валидатор заказа (там тоже есть Email для уведомлений) RuleFor(x => x.NotificationEmail) .NotEmpty().WithMessage("Email обязателен") .EmailAddress().WithMessage("Неверный формат") .MaximumLength(256); // Валидатор контрагента RuleFor(x => x.ContactEmail) .NotEmpty().WithMessage("Email обязателен") .EmailAddress().WithMessage("Неверный формат") .MaximumLength(256);
Если ваш коллега увидит этот код, он либо уволится, либо проклянет вас до седьмого колена. Давайте сделаем мир лучше.
Уровень 1. Extension Methods (самое простое)
Создаём отдельный статический класс для наших кастомных правил:
public static class MyValidationRules { public static IRuleBuilderOptions<T, string> ValidEmail<T>( this IRuleBuilder<T, string> ruleBuilder) { return ruleBuilder .NotEmpty().WithMessage("Email обязателен") .EmailAddress().WithMessage("Неверный формат email") .MaximumLength(256).WithMessage("Email не может быть длиннее 256 символов"); } }
Теперь везде пишем красиво:
RuleFor(x => x.Email).ValidEmail(); RuleFor(x => x.NotificationEmail).ValidEmail(); RuleFor(x => x.ContactEmail).ValidEmail();
Коллега спасен, код ста�� чище, вы — герой.
А как это работает: extension method "приклеивается" к IRuleBuilder и возвращает настроенное правило. FluentValidation это поддерживает из коробки.
Когда подходит: когда правила статические, не зависят от внешних сервисов и просто повторяются.
Уровень 2. Расширения с параметрами
Иногда правила почти одинаковые, но с нюансами:
public static class MyValidationRules { public static IRuleBuilderOptions<T, string> Required<T>( this IRuleBuilder<T, string> ruleBuilder, string fieldName = null) // параметр для кастомизации { var builder = ruleBuilder .MaximumLength(500) .WithMessage(fieldName == null ? "Поле слишком длинное" : $"{fieldName} не может быть длиннее 500 символов"); return builder .NotEmpty() .WithMessage(fieldName == null ? "Поле обязательно" : $"{fieldName} обязательно"); } } // Используем RuleFor(x => x.FirstName).Required("Имя"); RuleFor(x => x.LastName).Required("Фамилия"); RuleFor(x => x.Description).Required(); // будет просто "Поле обязательно"
Важно: лямбды в WithMessage вычисляются в момент валидации, поэтому подстановка fieldName сработает правильно (вспоминаем первую часть статьи).
Уровень 3. Когда extension'ов мало
У расширений есть ограничение: они статичны. Если валидации нужно сходить в базу данных или вызвать внешний сервис — extension не поможет.
Пример: проверка уникальности email.
// ЭТО НЕ СРАБОТАЕТ public static class BadIdea { public static IRuleBuilderOptions<T, string> UniqueEmail<T>( this IRuleBuilder<T, string> ruleBuilder, IUserRepository repo) // нельзя передавать сервисы так! { // ... } }
Почему? Потому что extension method вызывается при создании правил (один раз при старте приложения), а репозиторий должен жить в рамках запроса. Здесь нужен другой подход.
Уровень 4. Кастомные валидаторы с DI
Создаём полноценный класс, который получает зависимости через конструктор:
public class UniqueEmailValidator<T> : PropertyValidator<T, string> { private readonly IUserRepository _userRepo; private readonly Func<T, int?> _currentUserIdSelector; // Конструктор с зависимостями public UniqueEmailValidator( IUserRepository userRepo, Func<T, int?> currentUserIdSelector = null) { _userRepo = userRepo; _currentUserIdSelector = currentUserIdSelector; } public override string Name => "UniqueEmailValidator"; public override async Task<bool> IsValidAsync( ValidationContext<T> context, string email, CancellationToken cancellationToken) { if (string.IsNullOrEmpty(email)) return true; // пустые проверяются другими правилами // Получаем ID текущего пользователя (если передан селектор) int? currentUserId = null; if (_currentUserIdSelector != null) { currentUserId = _currentUserIdSelector(context.InstanceToValidate); } // Проверяем уникальность var exists = await _userRepo.IsEmailTakenAsync( email, currentUserId, cancellationToken); return !exists; } protected override string GetDefaultMessageTemplate(string errorCode) { return "Email {PropertyValue} уже используется"; } }
Использование в валидаторах будет такое:
public class RegisterUserValidator : AbstractValidator<RegisterUserCommand> { public RegisterUserValidator(IUserRepository userRepo) { RuleFor(x => x.Email) .ValidEmail() // наше расширение .SetValidator(new UniqueEmailValidator<RegisterUserCommand>(userRepo)); } } public class UpdateUserValidator : AbstractValidator<UpdateUserCommand> { public UpdateUserValidator(IUserRepository userRepo) { RuleFor(x => x.Email) .ValidEmail() .SetValidator(new UniqueEmailValidator<UpdateUserCommand>( userRepo, currentUserIdSelector: cmd => cmd.UserId // исключаем текущего )); } }
Что тут происходит:
Валидатор создаётся через DI (или руками) с нужными зависимостями
SetValidator прикрепляет его к конкретному свойству
В процессе валидации вызывается наш код с доступом к БД
Уровень 5. Композиция через Include
Когда нужно переиспользовать целый набор правил для сложного объекта:
// Валидатор адреса (сам по себе) public class AddressValidator : AbstractValidator<Address> { public AddressValidator() { RuleFor(x => x.Country).NotEmpty().MaximumLength(100); RuleFor(x => x.City).NotEmpty().MaximumLength(200); RuleFor(x => x.Street).NotEmpty().MaximumLength(300); RuleFor(x => x.ZipCode).Matches(@"^\d{5,6}$"); } } // Валидатор пользователя использует AddressValidator public class UserValidator : AbstractValidator<User> { public UserValidator(AddressValidator addressValidator) { RuleFor(x => x.Name).NotEmpty(); RuleFor(x => x.Email).ValidEmail(); // Просто включаем валидатор адреса RuleFor(x => x.HomeAddress) .SetValidator(addressValidator); } }
Когда нужно: у вас есть вложенные объекты (адреса, паспортные данные, контактная информация), которые валидируются одинаково везде.
Что важно помнить
Extension methods — для простых повторяющихся правил без зависимостей. 90% случаев.
Кастомные PropertyValidator — когда нужен DI и доступ к сервисам.
SetValidator — для вложенных объектов.
Include — для наследования и переиспользования целых валидаторов.
Не создавайте монстров. Один валидатор на 500 строк с кучей условий — это боль. Лучше 10 маленьких.
Типичные грабли
Теперь об ошибках, которые можно тут допустить:
Грабли 1: Кто-то переэнтузиазмил
// Не надо так RuleFor(x => x.Age).CommonAgeRule(); // правило на 2 строки // Проще написать явно: GreaterThan(18).LessThan(100)
Грабли 2: Забыть зарегистрировать в DI
// Валидатор падает с NullReferenceException services.AddScoped<UserValidator>(); // а AddressValidator не зарегистрирован!
Грабли 3: Singleton валидатор с состоянием
services.AddSingleton<EmailValidator>(); // один на все запросы // А внутри валидатора: private List<string> _errors = new(); // БУДЕТ РАСТИ ВЕЧНО
На этом я рассказала вам всё, что узнала за год. Со следующей частью вернусь в следующем, получается ахаха! (если еще вообще что-то есть)
