Публікація

Fluent Validation + MediatR з використанням IResult - ефективний підхід

Fluent Validation + MediatR з використанням IResult - ефективний підхід

У сучасних .NET додатках поєднання FluentValidation та MediatR стало популярним підходом для реалізації валідації запитів. Традиційно, коли валідація не проходить, ми викидаємо виняток ValidationException. Однак, у багатьох випадках це не найефективніший підхід. У цій статті ми розглянемо альтернативний метод з використанням IResult, який покращує продуктивність та зменшує використання пам’яті.

Патерн CQRS (Command Query Responsibility Segregation) у поєднанні з бібліотекою MediatR дозволяє створювати чисту та підтримувану архітектуру додатків. Додавання валідації за допомогою FluentValidation робить цей підхід ще потужнішим, дозволяючи перевіряти вхідні дані ще до того, як вони потраплять до бізнес-логіки. Але стандартний підхід з використанням винятків має свої обмеження, особливо в високонавантажених системах. .NET починаючи з версії 6.0 представив концепцію Minimal API та інтерфейс IResult, який став частиною стандартного підходу до повернення HTTP-відповідей. Цей інтерфейс надає простий, але потужний спосіб керувати відповідями без необхідності використання повноцінних контролерів. Поєднуючи його з MediatR та FluentValidation, ми можемо створити елегантне рішення для валідації запитів, яке не тільки покращує читабельність коду, але й значно підвищує продуктивність системи.

Перехід від викидання винятків до повернення результатів через IResult – це не лише зміна синтаксису, але й фундаментальне переосмислення підходу до обробки помилок у веб-додатках. Замість використання винятків, які за своєю природою призначені для обробки “виняткових” ситуацій, ми переходимо до парадигми, де валідація є невід’ємною частиною нормального потоку виконання програми, а результат валідації – очікуваним і передбачуваним результатом, а не чимось винятковим. Такий підхід особливо важливий у мікросервісних архітектурах, де валідація часто відбувається на декількох рівнях: у клієнтському додатку, в API-шлюзі, в окремих мікросервісах. Кожне викидання та обробка винятку в такому ланцюжку створюють значне навантаження на систему, яке можна уникнути, використовуючи функціональний підхід з IResult.

Традиційний підхід з винятками

Спочатку подивимось на традиційний підхід використання винятків:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class ValidationBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
     where TRequest : notnull
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;
    public ValidationBehaviour(IEnumerable<IValidator<TRequest>> validators)
    {
        _validators = validators;
    }
    public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
    {
        if (_validators.Any())
        {
            var context = new ValidationContext<TRequest>(request);
            var validationResults = await Task.WhenAll(
                _validators.Select(v =>
                    v.ValidateAsync(context, cancellationToken)));
            var failures = validationResults
                .Where(r => r.Errors.Any())
                .SelectMany(r => r.Errors)
                .ToList();
            if (failures.Any())
                throw new ValidationException(failures);
        }
        return await next();
    }
}

Проблеми з підходом на основі винятків

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

  1. Вплив на продуктивність Генерація та обробка винятків у .NET є відносно дорогою операцією порівняно зі звичайним потоком виконання коду. Кожного разу, коли система викидає виняток, .NET мусить створити новий об’єкт винятку, який згодом обробляється збирачем сміття, що створює додаткове навантаження на систему управління пам’яттю. Крім того, процес викидання винятку вимагає захоплення та розгортання стеку викликів (stack unwinding), що є набагато повільнішою операцією, ніж звичайне виконання коду. JIT-компілятор також стикається з труднощами під час оптимізації коду з обробкою винятків, що може призвести до зниження загальної продуктивності програми, особливо на критичних ділянках шляху виконання.

  2. Високе споживання пам’яті Коли в програмі викидається виняток, .NET автоматично захоплює повний стек викликів, що включає в себе детальну інформацію про всі методи в ланцюжку викликів, значення переданих параметрів та локальних змінних, а також докладний контекст виконання на момент виникнення виключної ситуації. Ця інформація займає значний обсяг пам’яті, що стає особливо проблематичним у високонавантажених системах або сценаріях, де валідація часто не проходить успішно. При частому створенні об’єктів винятків збільшується навантаження на процес керування пам’яттю та прибирання сміття, що може призвести до помітних сповільнень роботи програми під час пікових навантажень.

  3. Негативний вплив на читабельність коду Код, що покладається на винятки для управління потоком виконання бізнес-логіки, часто стає менш зрозумілим та логічно заплутаним. Винятки за своєю природою призначені для обробки дійсно виняткових ситуацій, а не для контролю стандартного потоку виконання програми. Використання їх для валідації, яка є очікуваною частиною нормальної роботи програми, порушує цей принцип. Код, що містить багато блоків try-catch для обробки різних результатів валідації, стає важчим для розуміння, підтримки та відлагодження, оскільки потік виконання програми стає менш очевидним і передбачуваним.

Покращений підхід з використанням IResult

Інтерфейс IResult з .NET Minimal API надає елегантний спосіб повернення різних типів HTTP-відповідей без використання винятків.

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public class ValidationResultBehavior<TRequest, TResult>(IServiceProvider serviceProvider)
    : IPipelineBehavior<TRequest, IResult>
    where TRequest : notnull
    where TResult : notnull, IResult
{
    public async Task<IResult> Handle(
        TRequest request,
        RequestHandlerDelegate<IResult> next,
        CancellationToken cancellationToken
    )
    {
        var validator = serviceProvider.GetService<IValidator<TRequest>>();
        if (validator is null)
        {
            return await next();
        }

        var validationResult = await validator.ValidateAsync(request, cancellationToken);
        if (!validationResult.IsValid)
        {
            var errorCode =
                validationResult.Errors.FirstOrDefault()?.ErrorCode
                ?? StatusCodes.Status400BadRequest.ToString();

            return (
                int.TryParse(errorCode, out var code) ? code : StatusCodes.Status400BadRequest
            ) switch
            {
                StatusCodes.Status403Forbidden => Results.Problem(
                    new ForbiddenResponse(
                        validationResult.Errors.FirstOrDefault()?.ErrorMessage
                            ?? "Validation failed"
                    )
                ),
                _ => Results.BadRequest(
                    new BadRequestResponse(
                        validationResult.Errors.FirstOrDefault()?.ErrorMessage
                            ?? "Validation failed"
                    )
                ),
            };
        }

        return await next();
    }
}

Переваги підходу з IResult

  1. Вища продуктивність Використання IResult замість винятків суттєво підвищує продуктивність програми, оскільки повністю усуває накладні витрати, пов’язані з генерацією та обробкою винятків. При використанні IResult система не потребує виконувати дорогу операцію розгортання стеку викликів, яка зазвичай відбувається при викиданні винятку. Крім того, цей підхід запобігає необхідності створювати та обробляти об’єкти винятків, що в свою чергу знижує навантаження на систему. JIT-компілятор також отримує можливість краще оптимізувати код, оскільки обробка винятків часто перешкоджає багатьом оптимізаціям, які компілятор міг би застосувати. В результаті програма працює швидше і ефективніше, особливо в умовах високого навантаження або на критичних шляхах виконання.

  2. Менше споживання пам’яті Повернення результатів валідації через інтерфейс IResult замість викидання винятків призводить до значно меншого використання пам’яті в програмі. При цьому підході система повністю уникає необхідності захоплювати та зберігати повний стек викликів, що є однією з головних причин високого споживання пам’яті при обробці винятків. Відсутність необхідності створювати об’єкти винятків та пов’язані з ними метадані також зменшує загальну кількість алокацій пам’яті. Як наслідок, збирач сміття працює менш інтенсивно, що позитивно впливає на загальну продуктивність програми та зменшує ймовірність виникнення проблем, пов’язаних із фрагментацією пам’яті або паузами для збору сміття під час роботи програми.

  3. Чіткіший потік контролю Використання IResult суттєво покращує читабельність коду та робить потік виконання програми більш явним і передбачуваним. Замість переривання нормального потоку виконання за допомогою винятків, цей підхід дозволяє повертати конкретні типи HTTP-відповідей, що робить код більш лінійним та зрозумілим. Логіка обробки помилок в контролерах або обробниках запитів значно спрощується, оскільки не потрібно писати численні блоки try-catch для перехоплення та обробки винятків. Це також полегшує процес тестування та відлагодження програми, оскільки результати виконання методів стають більш передбачуваними та чітко визначеними.

  4. Гнучкість у поверненні HTTP-відповідей Підхід з використанням IResult надає розробникам значно більшу гнучкість при формуванні відповідей на HTTP-запити. Розробник отримує можливість легко повертати різноманітні типи HTTP-відповідей в залежності від конкретного контексту та результатів валідації. Наприклад, можна легко повернути відповідь BadRequest у випадку, коли користувацькі дані не відповідають вимогам валідації, або відповідь Forbidden, коли виникають проблеми з авторизацією або доступом. Крім того, розробник може гнучко налаштовувати тіло відповіді для надання детальної та корисної інформації клієнтській частині програми, наприклад, включаючи конкретні повідомлення про помилки валідації, що допомагає користувачам зрозуміти, що саме потрібно виправити.

Реєстрація поведінки валідації

Щоб використовувати наш новий підхід на базі IResult, потрібно зареєструвати поведінку в DI-контейнері:

1
2
3
4
5
6
7
8
// Реєстрація валідаторів
services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());

services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly());
    cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationResultBehavior<,>));
});

Приклад використання

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// Запит
public record CreateUserCommand(string Username, string Email) : IRequest<IResult>;

// Валідатор
public class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
{
    public CreateUserCommandValidator()
    {
        RuleFor(x => x.Username)
            .NotEmpty()
            .MinimumLength(3)
            .WithErrorCode(StatusCodes.Status400BadRequest.ToString());

        RuleFor(x => x.Email)
            .NotEmpty()
            .EmailAddress()
            .WithErrorCode(StatusCodes.Status400BadRequest.ToString());
    }
}

public class CreateUserCommandHandler : IRequestHandler<CreateUserCommand, IResult>
{
    public async Task<IResult> Handle(CreateUserCommand request, CancellationToken cancellationToken)
    {
        // Логіка створення користувача...
        return Results.Created($"/users/{userId}", new UserDto { /* ... */ });
    }
}

Детальніше про помилки валідації

Можливо, ви захочете надати більш детальну інформацію про помилки валідації. Для цього можна створити спеціальний клас для відповіді:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class ValidationProblemResponse
{
    public string Title { get; } = "Validation failed";
    public int Status { get; }
    public IDictionary<string, string[]> Errors { get; }

    public ValidationProblemResponse(ValidationResult validationResult, int status = StatusCodes.Status400BadRequest)
    {
        Status = status;
        
        Errors = validationResult.Errors
            .GroupBy(e => e.PropertyName)
            .ToDictionary(
                g => g.Key,
                g => g.Select(e => e.ErrorMessage).ToArray()
            );
    }
}

І використовувати цей клас у поведінці валідації:

1
2
3
4
if (!validationResult.IsValid)
{
    return Results.BadRequest(new ValidationProblemResponse(validationResult));
}

Висновок

Використання підходу на основі IResult з FluentValidation та MediatR надає значні переваги в плані продуктивності, споживання пам’яті та чіткості коду порівняно з традиційним підходом, що базується на винятках. Цей підхід особливо корисний у високонавантажених системах, де ефективність є критичною.

Замість викидання винятків, які негативно впливають на продуктивність, ми можемо використовувати типи відповідей IResult для елегантної обробки помилок валідації, забезпечуючи при цьому інформативний зворотний зв’язок для клієнтів. Таким чином, ми отримуємо більш надійний та ефективний процес валідації запитів у нашому додатку.

Публікація захищена ліцензією CC BY 4.0 .