Start Debugging

Миграция minimal API с ручных проверок валидации на встроенную валидацию в ASP.NET Core 11

Пошаговое руководство по замене написанных вручную if-проверок в обработчиках minimal API на ASP.NET Core 11 встроенным валидатором на основе DataAnnotations и генерации исходного кода: что ломается, какие ручные правила переносятся, а какие нет, и как убедиться, что контракт 400 ProblemDetails остаётся прежним.

Если ваш minimal API на ASP.NET Core создан до .NET 10, то, вероятно, каждый обработчик начинается с блока проверок if (string.IsNullOrWhiteSpace(...)) return Results.BadRequest(...). Эта миграция заменяет написанные вручную проверки встроенной валидацией, которая появилась в .NET 10 и осталась неизменной в .NET 11: вызовите builder.Services.AddValidation(), перенесите правила на ваши record запроса в виде атрибутов DataAnnotations и удалите ручные проверки-стражи. Для типичного сервиса из 15-40 конечных точек рассчитывайте на полдня-день механической работы. Ломается не фреймворк, а ваши предположения: часть ваших ручных правил делала то, что атрибуты выразить не могут (асинхронные запросы, межсервисные проверки), а форма вашего ответа об ошибке может измениться, если вы собирали payload BadRequest вручную вместо возврата ProblemDetails. Миграция стоит того, потому что она удаляет код, делает валидацию декларативной и переиспользуемой и остаётся безопасной для обрезки (trim) при Native AOT. Кроме того, она обратима по одной конечной точке за раз, так что её можно выполнять инкрементально. Всё изложенное ниже нацелено на .NET 11 с Microsoft.NET.Sdk.Web и C# 14; функция идентична в .NET 10, где она впервые появилась.

Зачем уходить от ручных проверок

Ручной шаблон работает, поэтому аргумент в пользу миграции касается сопровождаемости и корректности, а не “старый способ сломан”:

Что ломается

Сам фреймворк не ломается, но эти пять вещей меняются, и вам нужно знать, какие из ваших ручных правил переживут перенос:

ОбластьИзменениеСерьёзность
Тело ответа об ошибкеГолые строки или пользовательские тела BadRequest(...) становятся ProblemDetails (RFC 9457) с ключом по свойствувысокая
Асинхронные правила / правила БДif (await repo.SkuExists(...)) не может стать атрибутом; ему нужно другое местовысокая
Доступность типа запросаrecord должен быть public, иначе генератор ничего не выпускает и валидация молча не срабатываетвысокая
Правила между полямиБлоки if для нескольких свойств переходят в IValidatableObject, а не в атрибутысредняя
Тесты, проверяющие текст ошибкиТесты, проверяющие ваши старые пользовательские строки сообщений, упадут на новом ProblemDetailsсредняя
Тип возврата обработчикаОбработчики могут убрать ветку BadRequest; фильтр выдаёт 400 до запуска обработчиканизкая

Две строки высокой серьёзности, определяющие форму вашей миграции, - это тело ошибки и асинхронные правила. Если клиенты парсят ваше текущее тело 400, планируйте изменение контракта или слой совместимости. Если какая-то ручная проверка ожидает ввод-вывод, это правило вообще не становится атрибутом, и притворяться иначе - самый частый способ провалить эту миграцию.

Предварительный контрольный список

Прежде чем трогать обработчик:

Шаги миграции

1. Включите встроенную валидацию

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

// .NET 11, C# 14 -- Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddValidation();   // registers the validation services and endpoint filter
var app = builder.Build();

С текущим web SDK .NET 10 или 11 генератор исходного кода активен автоматически, как только присутствует AddValidation(). Если вы скопировали обрезанный .csproj или ваша сборка предшествует GA, добавьте пространство имён перехватчика явно:

<!-- only needed if the generator's interceptors are not picked up automatically -->
<PropertyGroup>
  <InterceptorsNamespaces>$(InterceptorsNamespaces);Microsoft.AspNetCore.Http.Validation.Generated</InterceptorsNamespaces>
</PropertyGroup>

Проверка: приложение по-прежнему собирается и запускается. dotnet build сообщает о нуле ошибок. Поведение пока не изменилось, потому что ни один из ваших типов не несёт атрибутов.

2. Перенесите правила уровня поля на record запроса

Возьмите одну конечную точку. Прочитайте её ручные проверки и переведите каждое выразимое атрибутом правило в атрибут DataAnnotations на типе запроса. Вот “до”, обработчик, несущий свою валидацию встроенно:

// .NET 11, C# 14 -- BEFORE: manual checks
app.MapPost("/products", (CreateProduct product) =>
{
    if (string.IsNullOrWhiteSpace(product.Sku) || product.Sku.Length is < 3 or > 20)
        return Results.BadRequest("Sku must be 3 to 20 characters.");
    if (string.IsNullOrWhiteSpace(product.Name) || product.Name.Length < 2)
        return Results.BadRequest("Name is required and must be at least 2 characters.");
    if (product.Quantity is < 1 or > 10_000)
        return Results.BadRequest("Quantity must be between 1 and 10000.");

    return Results.Created($"/products/{product.Sku}", product);
});

public record CreateProduct(string Sku, string Name, int Quantity);

И “после”: правила переезжают на record в виде атрибутов, обработчик теряет свой блок-страж, а тип становится public, чтобы генератор мог его видеть.

// .NET 11, C# 14 -- AFTER: declarative validation
using System.ComponentModel.DataAnnotations;

app.MapPost("/products", (CreateProduct product) =>
    TypedResults.Created($"/products/{product.Sku}", product));

public record CreateProduct(
    [Required, Length(3, 20)] string Sku,
    [Required, MinLength(2)] string Name,
    [Range(1, 10_000)] int Quantity);

Обратите внимание, что тип стал public. Это самая частая причина, по которой перенесённая конечная точка молча перестаёт валидировать: генератор может выпускать код только для типов, которые он может назвать, поэтому record, оставленный с доступностью на уровне файла по умолчанию, не получает ни валидации, ни ошибки.

Проверка: curl -i -X POST .../products -d '{"sku":"x","name":"","quantity":0}' возвращает 400 с телом ProblemDetails, перечисляющим Sku, Name и Quantity. Валидное тело по-прежнему возвращает 201.

3. Перенесите правила между полями в IValidatableObject

Атрибуты валидируют по одному члену за раз. Любая ручная проверка, сравнивавшая два поля (“конец после начала”, “скидка требует причины”), не становится атрибутом. Вместо этого реализуйте IValidatableObject на record запроса:

// .NET 11, C# 14 -- cross-field rule ported from a manual if-block
using System.ComponentModel.DataAnnotations;

public record DateRange(
    [Required] DateOnly Start,
    [Required] DateOnly End) : IValidatableObject
{
    public IEnumerable<ValidationResult> Validate(ValidationContext context)
    {
        if (End <= Start)
        {
            yield return new ValidationResult(
                "End must be after Start.",
                [nameof(End)]);   // attaches the error to the End member
        }
    }
}

Массив имён членов, который вы передаёте вторым аргументом, управляет тем, под каким ключом окажется ошибка, поэтому передавайте поле, которое ваш UI должен подсветить. Validate выполняется после проверок атрибутов, так что null в Start уже сообщается [Required] до запуска вашей логики между полями.

Проверка: отправьте диапазон, где End <= Start, и убедитесь, что ключ ошибки - End, а не пустой ключ уровня модели.

4. Переселите асинхронные и межсервисные проверки

Это шаг, в который вливается бэклог миграции из вашего предыдущего grep по await. Правило вроде “отклонить, если SKU уже есть в каталоге” обращается к базе данных, а ни DataAnnotations, ни IValidatableObject не могут ожидать. У вас есть два честных варианта:

Оставьте эту конкретную проверку явным вызовом внутри обработчика, после того как встроенный валидатор уже гарантировал, что форма валидна:

// .NET 11, C# 14 -- async rule stays explicit, but shape is pre-validated
app.MapPost("/products", async (CreateProduct product, IProductRepository repo, CancellationToken ct) =>
{
    // Built-in validation already ran: Sku is non-null, 3-20 chars, Quantity in range.
    if (await repo.SkuExistsAsync(product.Sku, ct))
        return Results.Conflict($"A product with SKU {product.Sku} already exists.");

    await repo.AddAsync(product, ct);
    return TypedResults.Created($"/products/{product.Sku}", product);
});

Или, если у вас много асинхронных правил, оставьте FluentValidation именно для этих типов запроса, а простыми пусть занимается встроенная функция. Компромиссы изложены в валидация minimal API против FluentValidation; короткая версия: асинхронные правила и богатая условная логика - две причины держать библиотеку валидации. Не пытайтесь втиснуть вызов DbContext в IValidatableObject; он синхронный и либо заблокирует поток, либо толкнёт вас на территорию взаимной блокировки с .Result.

Проверка: проверка уникальности по-прежнему отклоняет дублирующийся SKU (теперь как 409 или любой выбранный вами статус), а впервые встреченный SKU по-прежнему успешен.

5. Переиспользуйте правило с пользовательским ValidationAttribute

Если одна и та же ручная проверка появлялась в трёх обработчиках (“эта дата не может быть в прошлом”), не копируйте IValidatableObject в три record. Напишите один ValidationAttribute, и генератор подхватит его, как любой встроенный:

// .NET 11, C# 14
using System.ComponentModel.DataAnnotations;

public sealed class NotInPastAttribute : ValidationAttribute
{
    protected override ValidationResult? IsValid(object? value, ValidationContext context)
    {
        if (value is DateOnly date && date < DateOnly.FromDateTime(DateTime.UtcNow.Date))
        {
            return new ValidationResult(
                ErrorMessage ?? "Date cannot be in the past.",
                [context.MemberName!]);
        }
        return ValidationResult.Success;
    }
}

public record Booking([Required, NotInPast] DateOnly When);

Это основанный на атрибутах ответ на дублирующиеся блоки if: правило живёт один раз и применяется везде, где используется тип.

Проверка: конечная точка, ранее повторявшая ту же проверку даты, теперь отклоняет прошедшую дату через атрибут, а остальные конечные точки, разделяющие тип, наследуют правило.

6. Удалите мёртвый код-страж и упростите типы возврата

Как только правила конечной точки становятся декларативными, полностью уберите ручной блок if и упростите объявленный тип возврата обработчика. Поскольку 400 выдаёт фильтр конечной точки до выполнения тела обработчика, обработчику больше не нужна ветка BadRequest в его объединении Results<...>:

// .NET 11, C# 14 -- return type no longer needs BadRequest
app.MapPost("/products", (CreateProduct product)
    => TypedResults.Created($"/products/{product.Sku}", product))
   .Produces<CreateProduct>(StatusCodes.Status201Created)
   .ProducesValidationProblem();   // documents the 400 the filter produces

Проверка: OpenAPI по-прежнему объявляет 400 (через ProducesValidationProblem), и конечная точка компилируется без убранной ветки объединения.

Верификация

После миграции партии конечных точек пройдитесь по этому списку, прежде чем считать дело завершённым:

План отката

Эта миграция обратима и, что лучше, инкрементальна, так что полный откат нужен редко. Два уровня:

Подводные камни, с которыми мы столкнулись

Реальные проблемы, стоившие времени, и их решения:

Ментальная модель на вынос: эта миграция - не переход на новый фреймворк, а удаление императивного кода-стража и повторное выражение тех же правил декларативно на ваших типах запроса. Выразимые атрибутом правила переезжают в DataAnnotations, межполевые - в IValidatableObject, переиспользуемые - в пользовательский ValidationAttribute, а асинхронные остаются явными в обработчике после того, как форма уже гарантированно валидна. Делайте по одной конечной точке за раз, проверяйте каждую вызовом невалидного запроса, и шаблонный блок if, открывавший каждый обработчик, просто исчезает.

Связанное

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад