Валідація вкладених об’єктів у .NET

Вступний пост цього блогу присвячений проблемі перевірки вкладених об'єктів передачі даних (DTO) в сучасному .NET. Вкладеність означає, що кореневий об'єкт може посилатися на інші DTO, які в свою чергу можуть посилатися на інші, утворюючи циклічний граф невідомого розміру. Для кожного вузла графа перевіряються його дані за допомогою типових правил перевірки: наявність значення (nullability), діапазон, довжина, регулярні вирази тощо. Що стосується типів DTO, давайте визначимо наступні конвенції:

  • Воно може мати атрибути DataAnnotation, у тому числі користувацькі.
  • Воно може реалізовувати IValidatableObject.
  • Воно повинно уникати сторонніх залежностей, якщо це можливо.

Можливо, ви здогадалися, що саме граф є складною частиною. Дійсно, вбудований DataAnnotations.Validator за замовчуванням не виконує вкладену перевірку, і це було стандартною поведінкою протягом десятиліть. Але виправити це — дуже просто, правда? Просто реалізуйте будь-який спосіб обходу графа з виявленням циклів! Так, але це також не так просто. У цьому пості я порівняю популярні сторонні бібліотеки, які підтримують вкладену перевірку. Заглянувши в майбутнє, можна помітити велику різницю в продуктивності навіть серед надійних, готових до використання рішень.

Існує багато способів визначити правила перевірки в .NET, кожен з яких має свої переваги та недоліки. Наприклад:

  • Атрибути: явні, корисні для генерації документації OpenAPI.
  • IValidatableObject: більш гнучке, але все ще самодостатнє.
  • Зовнішні рішення: універсальний підхід. Вони залишають DTO чистими і надають максимальну гнучкість (FluentValidation — найкращий приклад цього підходу).
  • Ручна перевірка: найбільш наївний підхід, який просто містить інлайнові конструкції if, без оголошення правил перевірки. Як результат, цей підхід має незрівнянно високу продуктивність на шкоду масштабованості, і не підходить для графів невідомої довжини/топології. Згодом він використовується як базова точка для вимірювання.

Щоб закінчити цей довгий вступ і зекономити час усім, давайте виокремимо, що не охоплюється в цій статті:

  • Модульна перевірка в ASP.NET. Хоча вона повністю підтримує атрибути DataAnnotations, все ж є невід'ємною частиною великої та складної платформи, що працює як на серверній стороні застосунку, так і в Web API, включаючи ModelState, зворотну сумісність з версіями тощо... ця тема безумовно заслуговує на окрему статтю.
  • Перевірка IOptions. Іронічно, з появою [ValidateObjectMembers] та [ValidateEnumeratedItems] в .NET 8, OptionsBuilder тепер підтримує перевірку вкладених опцій. І тепер є принаймні 3 різних алгоритми перевірки, що поставляються з ASP.NET.

Що таке перевірка?

Припустимо, ми обробляємо електронну адресу користувача для реєстрації. Що потрібно перевірити?

  • Адреса повинна бути у правильному форматі. Це перевірка.
  • Домен адреси не повинен бути в нашому чорному списку. Це бізнес-правило.
  • Адреса повинна бути унікальною в нашій базі даних. Це бізнес-правило.

Яка різниця? Перевірка — це чиста функція. Вона детермінована (однаковий вхід — однаковий вихід) і не має побічних ефектів. Ось чому пошук домену в списку — це не перевірка: такі списки можуть змінюватися, тому вони не детерміновані. Хороше правило для звичайних корпоративних розробників, таких як я:

  • Перевірка: самодостатня (нам потрібні лише дані з самого DTO)
  • Бізнес-правило: усе, що стосується змінних даних (база даних, API, файлові системи тощо)

І моя порада: не змішуйте їх. Перевіряйте вхідні дані до того, як контрольний потік потрапить до вашої бізнес-логіки.
Так само, як робить це ASP.NET з прив'язкою моделей. Незалежно від архітектури додатку, в багатьох випадках ви насправді хочете швидко виявляти некоректні/шкідливі вхідні дані та уникати непотрібного виділення ресурсів для ваших скопійованих і транзитних сервісів. Потім тестування: перевірка чистих функцій за допомогою тестів — це тривіально. Ну, принаймні, це набагато простіше зробити окремо, ніж створювати макети бази даних та кількох API для одночасної перевірки. Покладіть трохи зусиль у якість даних, що надходять у вашу доменну логіку, і ви отримаєте більш чітке та лаконічне доменне програмування.

Для детальнішого ознайомлення, будь ласка, прочитайте пост Марка Сіменна Validation and business rules, де ця тема розглядається дуже детально. Дозвольте сказати кілька слів про бібліотеки, що розглядаються, і ми нарешті перейдемо до бенчмарків.

DataAnnotationsValidator

Наш перший кандидат — пакет DataAnnotationsValidator.NETCore. Він давно застарілий і має проблеми з продуктивністю, тому рекомендується уникати його використання. Однак ця бібліотека добре ілюструє ідею багатьох саморобних рішень:

  • Використання рефлексії для читання метаданих.
  • Рекурсивний пошук в глибині для обходу графа.
  • Використання множини для виявлення циклів.

MiniValidation

Жива та добре спроектована, MiniValidation пропонує безперебійну роботу з вкладеною перевіркою. Реалізуючи подібний пошук в глибині для обходу графа DTO, вона додає кешування метаданих, що дає набагато кращу продуктивність.

FluentValidation

FluentValidation безсумнівно є найбільш популярною сторонньою бібліотекою для перевірки в .NET. Вона є надійним вибором, якщо вам потрібні чисті POCO або кілька карт перевірки для кожного типу. Однак її продуктивність може вас здивувати.

Бенчмарк: DataAnnotation та FluentValidation

Наш перший бенчмарк — це перевірка типової DTO, позначеної за допомогою DataAnnotation, яка містить як один вкладений об'єкт, так і колекцію таких об'єктів (кожен з яких має бути перевірений):

public class Parent  
{  
 [Range(1, 9999)]  
 public int Id { get; set; }  

 [Required(AllowEmptyStrings = false)]  
 [StringLength(12, MinimumLength = 12)]  
 public string? Name { get; set; }  

 [Required]  
 public Child? Child { get; set; }  

 [Required]  
 public List<Child> Children { get; init; } = new(0);  
}  

public class Child : IChild  
{  
 [Required]  
 public DateTime? ChildCreatedAt { get; set; }  

 [AllowedValues(true)]  
 public bool ChildFlag { get; set; }  
}

Звісно, FluentValidation не використовує ці атрибути, тому її валідатори створюються окремо, повторюючи ті самі правила:

public class ParentValidator : AbstractValidator<Parent>  
{  
 public ParentValidator()  
 {  
 RuleFor(x => x.Id).InclusiveBetween(1, 9999);  
 RuleFor(x => x.Name).NotEmpty().Length(min: 12, max: 12);  
 RuleFor(x => x.Child).NotNull().SetValidator(new ChildValidator());  
 RuleForEach(x => x.Children).NotNull().SetValidator(new ChildValidator());  
 }  
}  

public class ChildValidator : AbstractValidator<Child>  
{  
 public ChildValidator()  
 {  
 RuleFor(x => x.ChildCreatedAt).NotNull();  
 RuleFor(x => x.ChildFlag).Equal(true);  
 }  
}

Нарешті, бенчмарк Manual використовує явні перевірки if і служить як базова точка для порівняння. Кожен бенчмарк запускається на одній і тій самій колекції Parent.
Ось результати залежно від розміру колекції:

| Метод | Розмір | Середнє | Виділено пам'яті |  
|--------------------------|-------|---------|-----------|  
| Manual | 100 | 3 мкс | 34 КБ |  
| MiniValidation | 100 | 162 мкс | 427 КБ |  
| DataAnnotationsValidator | 100 | 302 мкс | 614 КБ |  
| FluentValidation | 100 | 314 мкс | 946 КБ |  

| Manual | 1000 | 33 мкс | 343 КБ |  
| MiniValidation | 1000 | 1586 мкс | 4260 КБ |  
| DataAnnotationsValidator | 1000 | 3084 мкс | 6150 КБ |  
| FluentValidation | 1000 | 3300 мкс | 9586 КБ |  

| Manual |10000 | 342 мкс | 3437 КБ |  
| MiniValidation |10000 |16237 мкс | 42619 КБ |  
| DataAnnotationsValidator |10000 |31223 мкс | 61480 КБ |  
| FluentValidation |10000 |32364 мкс | 95911 КБ |

Як і очікувалося, DataAnnotationsValidator показав погані результати, але FluentValidation… ще гірші як по часу, так і по пам'яті! Спочатку я думав, що є помилка (але її не було). Потім я намагався знайти налаштування FluentValidation, які можуть допомогти оптимізувати продуктивність (але їх майже не було, за винятком “fail fast”, див. нижче). Розподіл результатів залишався тим самим. Але подивіться на MiniValidation! Та сама алгоритм, але оптимізований для продуктивності, дає вражаюче підвищення у 2 рази порівняно з DataAnnotationsValidator.

Тестування: IValidatableObject

Як ви, ймовірно, знаєте, IValidatableObject є альтернативою явним атрибутам DataAnnotations, при цьому вся логіка валідації інкапсульована всередині DTO. Цей тест використовує ті самі правила валідації, але реалізовані у методі Validate, тому все зводиться до проходження графу та виклику Validate на кожному вузлі. FluentValidation цього разу не включено.

public class ChildValidatableObject : IValidatableObject  
{  
 public DateTime? ChildCreatedAt { get; set; }  
 public bool ChildFlag { get; set; }  

 public IEnumerable Validate(ValidationContext validationContext)  
 {  
 if (ChildCreatedAt == null)  
 {  
 yield return new ValidationResult("foo error message #2",   
 new[] { nameof(ChildCreatedAt) });  
 }  

 if (ChildFlag == false)  
 {  
 yield return new ValidationResult("foo error message #3",   
 new[] { nameof(ChildFlag) });  
 }  
 }  
}
| Метод | Розмір | Середнє | Виділено пам'яті |  
|--------------------------------|-------:|----------:|----------:|  
| Manual з викликом IVO.Validate | 100 | 21 мкс | 109 КБ |  
| MiniValidation + IVO | 100 | 59 мкс | 199 КБ |  
| DataAnnotationsValidator + IVO | 100 | 151 мкс | 442 КБ |  

| Manual з викликом IVO.Validate | 1000 | 206 мкс | 1093 КБ |  
| MiniValidation + IVO | 1000 | 565 мкс | 1992 КБ |  
| DataAnnotationsValidator + IVO | 1000 | 1511 мкс | 4421 КБ |  

| Manual з викликом IVO.Validate | 10000 | 2141 мкс | 10937 КБ |  
| MiniValidation + IVO | 10000 | 6608 мкс | 19921 КБ |  
| DataAnnotationsValidator + IVO | 10000 | 16254 мкс | 44219 КБ |

Знову ж таки, MiniValidation виграє з ще більшим відривом. Тепер давайте об'єднаємо результати і подивимося на загальну продуктивність (значення округлені для зручності):

| Метод | Розмір | Середнє | Виділено пам'яті |  
|--------------------------------- |-----: |----------:|----------:|  
| Manual | 10000 | 342 мкс | 3437 КБ |  
| Manual з викликом IVO.Validate | 10000 | 2141 мкс | 10937 КБ |  
| MiniValidation + IVO | 10000 | 6608 мкс | 19921 КБ |  
| MiniValidation | 10000 | 16237 мкс | 42619 КБ |  
| DataAnnotationsValidator + IVO | 10000 | 16254 мкс | 44219 КБ |  
| DataAnnotationsValidator | 10000 | 31223 мкс | 61480 КБ |  
| FluentValidation | 10000 | 32364 мкс | 95912 КБ |

Ви можете помітити, що MiniValidation + IValidatableObject дають найкращі результати серед усіх сторонніх бібліотек.

Тестування: Fail fast

І все ж FluentValidation має функцію, якої бракує іншим конкурентам: CascadeMode.Stop.
Це гнучке рішення, яке можна налаштовувати на різних рівнях (правило, клас, глобальний):

public class FailfastChildValidator : AbstractValidator  
{  
 public FailfastChildValidator()  
 {  
 ClassLevelCascadeMode = CascadeMode.Stop;  
 //Усі правила оголошуються звичайним чином  
 //...  
 }  
}
| Метод | Розмір | Середнє | Виділено пам'яті |  
|----------------------------- |------ |---------:|----------:|  
| FluentValidation + Fail Fast | 10000 | 9012 мкс | 38556 КБ |  
| FluentValidation | 10000 | 32364 мкс | 95911 КБ |

Звісно, версія з fail-fast значно швидша. Більшість часу я надаю перевагу повному звіту про валідацію, але fail-fast — це опція, яку варто згадати, коли мова йде про продуктивність.

Підсумок

У цьому дописі я обговорив проблему валідації вкладених об'єктів у .NET. Оскільки вбудований валідатор DataAnnotations не обходить складні властивості, нам доводиться покладатися на сторонні бібліотеки для цього. Я пояснив різницю між валідацією та бізнес-правилами і чому це важливо.

Що стосується результатів бенчмарку:

  • Бібліотека MiniValidation показує найкращу загальну продуктивність.
  • FluentValidation, попри свою популярність, зазвичай в 2 рази повільніша. Існують деякі швидші альтернативи, такі як Validot, але я хотів би залишити навантаження бенчмаркінгу її розробникам.

Але не зрозумійте мене неправильно. Якщо ви хочете розділити ваші правила від DTO і отримати просте, стабільне та перевірене на продуктивності рішення — просто використовуйте FluentValidation, оскільки різниця в продуктивності в багатьох випадках незначна. Якщо вам потрібні самодокументовані DTO — використовуйте MiniValidation. А для коду, орієнтованого на продуктивність — інтегруйте ваші перевірки безпосередньо в код, де це можливо.

Очевидним наступним кроком у розвитку бібліотек загального призначення для валідації є, звісно, впровадження ̶C̶h̶a̶t̶G̶P̶T̶ генераторів джерела. Генератор валідації, такий як цей, може потенційно усунути розрив у продуктивності між бібліотеками для загального використання та вбудованою валідацією. Насправді, ми вже маємо всю необхідну технологію в складі .NET, тож стежте за новинами!

Усі коди з цієї статті доступні на Github: https://github.com/ilya-chumakov/PaperSource.DtoGraphValidation.

Перекладено з: Nested validation in .NET

Leave a Reply

Your email address will not be published. Required fields are marked *