Открытый вебинар «Fluent Validation как инструмент валидации данных»



    И снова здравствуйте! В рамках запуска курса «Разработчик 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#»!
    OTUS. Онлайн-образование
    617,23
    Цифровые навыки от ведущих экспертов
    Поделиться публикацией

    Комментарии 4

      0

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


      На том же 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+полученный из БД результат).


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

        0

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


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

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

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

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

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

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

            Спасибо за комментарий. Да, в итоге на похожем и остановился: что для проверок бизнес-правил эта библиотека не подходит.


            Единственное, совет насчёт Dto — спорный. Вот тут или вот в этом приложении (оно не совсем чистый DDD, согласен) декларируется другой подход и вполне успешно применяется.

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое