Start Debugging
RU

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

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

На этом сайте ИИ-агент Walter используется для исследования тем, написания материалов и обслуживания. Подготовка и проверка материалов

Если ваш 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, где она впервые появилась.

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

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

  • Вы удаляете код, и правила становятся декларативными. [Required, Length(3, 20)] string Sku на record заменяет три строки императивной проверки в каждом обработчике, принимающем Sku. Напишите ограничение один раз, рядом с данными.
  • Вы перестаёте возвращать несогласованные формы ошибок. Написанный вручную Results.BadRequest("Sku is required") возвращает голую строку на одной конечной точке и JSON-объект на другой, в зависимости от того, кто это писал. Встроенный валидатор возвращает единое тело HttpValidationProblemDetails (RFC 9457) повсюду, с ключом по имени свойства.
  • Валидация остаётся безопасной для обрезки. Функция - это генератор исходного кода на этапе компиляции, а не рефлексия во время выполнения, поэтому она публикуется чисто при Native AOT и агрессивной обрезке, по той же причине она поставляется со стеком minimal API на Native AOT.
  • Параметры query, маршрута и заголовка тоже валидируются. Ручные проверки почти всегда покрывают тело JSON и забывают про параметры пагинации. Встроенный фильтр валидирует скалярные параметры, привязанные из маршрута и строки запроса, с теми же атрибутами.

Что ломается

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

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

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

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

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

  • SDK. Установите SDK .NET 10 или 11. Подтвердите через dotnet --version (ожидайте 11.0.x или 10.0.x).
  • Web SDK. Проект должен использовать Microsoft.NET.Sdk.Web. Генератор исходного кода валидации подключается этим SDK, как только вы вызовете AddValidation().
  • Зафиксируйте текущий контракт. Захватите точные ответы 400, которые ваши конечные точки возвращают сегодня (несколько вызовов curl, сохранённых в файлы, или снапшот-тест). Вы собираетесь изменить эту форму и хотите иметь снимок “до”.
  • Найдите поверхность через grep. Найдите каждую ручную проверку, чтобы отслеживать прогресс: grep -rn "return Results.BadRequest\|return TypedResults.BadRequest" src/. Этот список - ваш бэклог миграции.
  • Инвентаризируйте асинхронные проверки. Отдельно grep -rn "await" src/ внутри ваших обработчиков и отметьте те, что управляют BadRequest. Это правила, которые не станут атрибутами.
  • Имейте набор тестов или smoke-скрипт. Вы хотите запускать что-то после каждой конечной точки, чтобы подтвердить, что валидные запросы по-прежнему проходят, а невалидные по-прежнему дают 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), и конечная точка компилируется без убранной ветки объединения.

Верификация

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

  • Собирается. dotnet build сообщает о нуле предупреждений и нуле ошибок. Обратите особое внимание на то, что нет ничего, потому что тип без public падает молча, а не громко. Это ловит следующий пункт.
  • Каждая перенесённая конечная точка действительно валидирует. Перезапустите свои вызовы curl с невалидным запросом (или тесты) против каждой перенесённой конечной точки. Конечная точка, возвращающая 201 для тела, которое вы ожидаете отклонённым, означает, что генератор не увидел тип; сначала проверьте модификатор доступа.
  • Валидные запросы по-прежнему проходят. Убедитесь, что успешный путь возвращает тот же статус успеха, что и раньше.
  • Контракт ошибки такой, какого ждут клиенты. Сравните новое тело ProblemDetails с вашим снимком “до”. Если клиент парсит словарь errors, убедитесь, что ключи совпадают с ожидаемыми им именами свойств.
  • Набор тестов зелёный. dotnet test проходит. Тесты, проверявшие ваши старые пользовательские строки ошибок, ожидаемо упадут; обновите их так, чтобы они проверяли форму ProblemDetails, а не откатывайте миграцию.
  • Нет регрессии производительности при старте. Поскольку функция генерируется в исходном коде, старт должен быть ровным или чуть быстрее (вы убрали код). Если вы оставили FluentValidation для некоторых типов, его компиляция выражений при первом использовании по-прежнему применяется к этим типам.

План отката

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

  • По конечной точке, без отката кода. Если перенесённая конечная точка ведёт себя неправильно в продакшене, добавьте к ней в цепочку DisableValidation(), чтобы выключить фильтр для этого маршрута, сохранив глобальную регистрацию и все остальные валидируемые конечные точки:

    // .NET 11, C# 14 -- disable validation on a single endpoint
    app.MapPost("/internal/import", (CreateProduct product)
        => TypedResults.Accepted($"/products/{product.Sku}", product))
       .DisableValidation();
  • Полный откат. Поскольку вы мигрировали по одной конечной точке, diff каждой самодостаточен: восстановите её ручной блок if и уберите атрибуты с record, если от них не зависит никакая другая конечная точка. Удаление глобального вызова AddValidation() полностью выключает функцию без каких-либо иных изменений кода. Ничто в этой миграции не является односторонним на уровне фреймворка.

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

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

  • Тихий no-op из-за не-public типа. Безусловно самый частый. Вы переносите атрибуты на record CreateProduct(...), сборка проходит, а валидация никогда не срабатывает, потому что тип не public. Предупреждения нет. Если перенесённая конечная точка перестаёт отклонять плохой ввод, сначала проверьте модификатор доступа.
  • Цели атрибутов у позиционных record. У позиционного record [Required] string Name читается генератором валидации напрямую. Явная цель [property: Required] string Name нужна только если какой-то другой инструмент рефлексирует над сгенерированным свойством во время выполнения. Смешение двух ожиданий - частый источник путаницы вида “почему этот атрибут игнорируется”.
  • Вложенные объекты валидируются рекурсивно, что может удивить. Если record запроса ссылается на record BillingAddress, генератор валидирует и адрес, а ключи ошибок используют путь с точкой вроде Billing.PostalCode. Если ваш старый ручной код проверял только объект верхнего уровня, вы теперь можете получать 400 для вложенных полей, которые раньше никогда не валидировали. Обычно это правильно, но это ожидаемое изменение поведения.
  • Порядок фильтров. Валидация выполняется как фильтр на уровне конечной точки. Если вы уже добавляете свои вызовы AddEndpointFilter, знайте, где валидация стоит в цепочке, особенно между группами маршрутов; правила композиции те же, что в организации конечных точек minimal API с MapGroup.
  • Не меняйте контракт ошибки молча по случайности. Если вы хотите, чтобы перенесённый ProblemDetails соответствовал определённой форме (пользовательский type URI, дополнительные члены), формируйте его централизованно, а не по конечной точке; механика описана в настройке ответов об ошибках валидации minimal API через IProblemDetailsService.

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

Связанное

Источники

Comments

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