И снова здравствуйте! В рамках запуска курса «Разработчик C#» мы провели традиционный открытый урок, посвящённый инструменту Fluent Validation. На вебинаре рассмотрели, как избавиться от кучи if-ов на примере проверки корректности заполнения данных покупателя, изучили внутреннюю реализацию библиотеки и способы применения подхода Fluent Interface на практике. Вебинар провёл Алексей Ягур, Team Lead в компании YouDo.



Зачем нужна валидация?


Википедия говорит нам, что валидация (от лат. validus «здоровый, крепкий, сильный») — это доказательство того, что требования конкретного пользователя, продукта, услуги или системы удовлетворены. Как правило, валидация проводится по мере необходимости, предполагая как анализ заданных условий применения, так и оценку соответствия характеристик продукции имеющимся требованиям. Результатом валидации становится вывод о возможности применения продукции для конкретных условий.

Что касается инструмента Fluent Validation, то его знание позволит нам:

  • сэкономить время при решении задач, связанных с валидацией данных;
  • привести разрозненные самодельные проверки к единому виду;
  • похвастаться своими знаниями о валидации за чашкой кофе коллегам :)

Но это всё теория, давайте лучше перейдём к практике.

Валидация на практическом примере: интерактив


Итак, практическая реализация валидации на языке C# выглядит следующим образом:



У нас есть класс Customer, у которого простейший набор полей: FirstName — имя, LastName — фамилия, Age — возраст. И есть некий класс CustomerManager, который сохраняет, как мы видим, в CustomerRepository нового пользователя (покупателя) и выводит нам в консоль информацию о том, что покупатель успешно добавлен.

Давайте попробуем добавить кастомера и менеджера, который будет управлять кастомерами:

void Main()
{
 var customer = new Customer
 {
  FirstName = "Томас Георгиевич",
  LastName = "Вальдемаров",
  Age = 57,
};
 
 var manager = new CustomerManager();
 manager.Add(customer);
}

Результатом выполнения станет вывод в консоли следующего текста:

Покупатель Томас Георгиевич Вальдемаров успешно добавлен.

Как видим, пока всё хорошо. Но что произойдёт, если в нашей базе данных внезапно начнут появляться «испорченные» данные. Например, если в поля будет вноситься некорректная информация (номер телефона вместо имени, возраст со знаком минус и т. п.):

{
  FirstName = "+79123456789",
  LastName = "valde@mar.ru",
  Age = -14,
};

В результате мы увидим, что кастомер с непонятным набором данных тоже будет добавлен:

Покупатель +79123456789 valde@mar.ru успешно добавлен.

Естественно, иметь такие данные в нашем репозитории мы не хотим. Как нам обезопасить себя? Самый простой вариант — возвращать ошибку, если у нас, к примеру, не все символы — буквы. Для этого задаём условие для FirstName с помощью if, а если условие не выполняется — прекращаем работу функции с помощью return и выводим на консоль надпись «Ошибка в имени». То же самое проделываем и с LastName. Что касается Age, то тут делаем проверку диапазона цифр, например:

if (customer.Age < 14 || customer.Age > 180)

Теперь давайте предположим, что нам нужно добавить дополнительные поля для покупателя, например, телефон. Мы будем валидировать телефон с помощью условия, согласно которому введённые значения должны начинаться с "+79" и иметь в своём составе только цифры. Всё это уже само по себе будет представлять довольно громоздкую конструкцию, а если мы захотим добавить ещё и e-mail?

Как бы там ни было, после выполнения вышеописанных операций мы получим кучу if-ов и большую простыню кода. Разобраться в таком коде постороннему разработчику будет непросто. Что же делать?

Подключаем Fluent Validation


У LINQPad есть возможность подключить библиотеку Fluent Validation, что мы и делаем. Кроме того, создаём ещё один класс CustomerValidator, который будет валидатором. Соответственно, все необходимые правила прописываем в нём. Вносим дополнительные коррективы, а многочисленные if-ы удаляем, т. к. в них отпадает необходимость.

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

void Main()
{
 var customer = new Customer
 {
  FirstName = "Alex2",
  LastName = "Petrov1",
  Age = 10,
  Phone = "+791234567893",
  Email = "adsf@fadsf3.com"
 };
 
 var manager = new CustomerManager();
 manager.Add(customer);
}
 
class Customer
{
 public string FirstName { get; set; }
 public string LastName { get; set; }
 public int Age { get; set; }
 public string Phone { get; set; }
 public string Email { get; set; }
}
 
class CustomerManager
{
 CustomerRepository _repository;
 CustomerValidator _validator;
 
 public CustomerManager()
 {
  _repository = new CustomerRepository();
  _validator = new CustomerValidator();
 }
 
 public void Add(Customer customer)
 {
  if (!ValidateCustomer(customer))
  {
   return;
  }
 
  _repository.Add(customer);
  Console.WriteLine($"Покупатель {customer.FirstName} {customer.LastName} успешно добавлен.");
 }
 
 private bool ValidateCustomer(Customer customer)
 {
  var result = _validator.Validate(customer);
  if (result.IsValid)
  {
   return true;
  }
 
  foreach(var error in result.Errors)
  {
   Console.WriteLine(error.ErrorMessage);
  }
  return false;
 }
}
 
class CustomerValidator : AbstractValidator<Customer>
{
 public CustomerValidator()
 {
  var msg = "Ошибка в поле {PropertyName}: значение {PropertyValue}";
 
  RuleFor(c => c.FirstName)
  .Must(c => c.All(Char.IsLetter)).WithMessage(msg);
 
  RuleFor(c => c.LastName)
  .Must(c => c.All(Char.IsLetter)).WithMessage(msg);
 
 RuleFor(c => c.Age)
  .GreaterThan(14).WithMessage(msg)
  .LessThan(180).WithMessage(msg);

  RuleFor(c => c.Phone)
  .Must(IsPhoneValid).WithMessage(msg)
  .Length(12).WithMessage("Длина должна быть от {MinLength} до {MaxLength}. Текущая длина: {TotalLength}");
 
  RuleFor(c => c.Email)
  .NotNull().WithMessage(msg)
  .EmailAddress();
 }
 
 private bool IsPhoneValid(string phone)
 {
  return !(!phone.StartsWith("+79")
  || !phone.Substring(1).All(c => Char.IsDigit(c)));
 }
}
 
class CustomerRepository
{
 Random _random;
 
 public CustomerRepository()
 {
  _random = new Random();
 }
 
 public void Add(Customer customer)
 {
  var sleepInSeconds = _random.Next(2, 7);
  Thread.Sleep(1000 * sleepInSeconds);
 }
}

И ещё немного теории


Хочется добавить ещё несколько слов про Fluent Validation. Этот инструмент называется именно так за счёт «текучего» интерфейса. Опять же, Википедия нам говорит, что текучий интерфейс — это способ реализации объектно-ориентированного API, нацеленный на повышение читабельности исходного кода программы. Определение, как мы видим, содержит много красивых и длинных слов, что не всегда понятно. Но можно сказать и иначе:
«Текучий интерфейс — это способ реализации объектно-ориентированного API, при котором методы возвращают тот же интерфейс, на котором были вызваны».
Алексей Ягур
Что касается самой библиотеки, то она включает в себя следующие составные части:

  1. Основная логика. Вот ссылка на GitHub, по которой можно посмотреть основную логику.
  2. Вспомогательная логика. За эту логику отвечает FluentValidation.ValidatorAttribute.
  3. Контекстно-зависимая часть. Смотрим FluentValidation.AspNetCore, FluentValidation.Mvc5 и FluentValidation.WebApi.
  4. Тесты. Соответственно, нас интересуют FluentValidation.Tests.AspNetCore, FluentValidation.Tests.Mvc5, FluentValidation.Tests.WebApi и FluentValidation.Tests.

На этом всё, пошаговые этапы написания кода смотрите в видео. Кроме того, возможно вам будет интересен дополнительный интерактив на тему «Переменные в текстах ошибок», который преподаватель провёл ближе к концу вебинара.

До встречи на курсе «Разработчик C#»!

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


  1. questor
    22.10.2019 19:06

    Простите, но это капец какой короткий пересказ официальной справки!


    На том же fluentvalidation непросто сделать вот что. Как-то понадобилось мне в базе держать данные нескольких пользователей (допустим, заметки) и запрещать показывать чужие. Поэтому даже в самом простом запросе


    public class Query : IRequest<NoteDto>
    {
        public Query(int currentUserId, int noteId)
        {
            this.CurrentUserId = currentUserId;
            this.NoteId = noteId;
        }
    
        public int CurrentUserId { get; set; }
    
        public int NoteId { get; }
    }

    Я либо дважды лезу в базу (на валидаторе в первый раз, на IRequestHandler<Query, NoteDto> — второй), либо отказываюсь от использования валидатора в handler'е и пишу по-старинке, либо пишу громоздкий некрасивый код (потому что надо проверять связку query+полученный из БД результат).


    И в общем, это как-то не особо вдохновляет.


    1. Veikedo
      22.10.2019 19:45

      Конкретно для вашего случая (если у вас EF например), проще было бы просто условие в фильтре поставить:


      var note = _db.Notes.FirstOrDefault(x => x.UserId == currentUserId && x.Id == noteId)

      Но вообще это можно решить с помощью RuleSet'ов


    1. TimurNes
      22.10.2019 22:05
      +1

      Как-то понадобилось мне в базе держать данные нескольких пользователей (допустим, заметки) и запрещать показывать чужие.

      А это не задача FluentValidation. Это — задача слоя бизнес логики, в вашем примере — QueryHandler'а, который должен либо самостоятельно определить текущего юзера, либо получить эти данные из Query, а затем либо делегировать выборку репозиторию (читай — sql запросу), либо самостоятельно отфильтровать данные.

      FluentValidation — это библиотека исключительно для Presentation Layer и предназначена только для первичной валидации данных, пришедших от пользователя. Например, что email — это email, а не случайная строка, или что возраст — больше нуля, но меньше 120 и, в случае ошибки — детально сообщить об этом юзеру. С чем и справляется блестяще.
      Бизнес слою же вообще желательно работать только с ValueObject, которые должны быть реализованы так, чтобы их в принципе было невозможно создать невалидными, но это уже оффтопик.

      И, к вашему примеру, добавлю еще, что RequestHandler не должен возвращать DTO. Опять же, потому что RequestHandler — это слой бизнес логики (модели), а DTO — это Presentation Layer. По этому меняем NoteDto на Note (Entity), в контроллер добавляем AutoMapper (или собственный маппер Note -> NoteDto) и радуемся тому что слои не залезают друг в дружку, а код — чистый, красивый и лаконичный.