Миграция 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, где она впервые появилась.
Зачем уходить от ручных проверок
Ручной шаблон работает, поэтому аргумент в пользу миграции касается сопровождаемости и корректности, а не “старый способ сломан”:
- Вы удаляете код, и правила становятся декларативными.
[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соответствовал определённой форме (пользовательскийtypeURI, дополнительные члены), формируйте его централизованно, а не по конечной точке; механика описана в настройке ответов об ошибках валидации minimal API через IProblemDetailsService.
Ментальная модель на вынос: эта миграция - не переход на новый фреймворк, а удаление императивного кода-стража и повторное выражение тех же правил декларативно на ваших типах запроса. Выразимые атрибутом правила переезжают в DataAnnotations, межполевые - в IValidatableObject, переиспользуемые - в пользовательский ValidationAttribute, а асинхронные остаются явными в обработчике после того, как форма уже гарантированно валидна. Делайте по одной конечной точке за раз, проверяйте каждую вызовом невалидного запроса, и шаблонный блок if, открывавший каждый обработчик, просто исчезает.
Связанное
- Как валидировать тела запросов в minimal API без контроллеров в ASP.NET Core 11 для полной настройки встроенной функции, на которую вы мигрируете.
- Валидация minimal API против FluentValidation в ASP.NET Core 11 для решения, что оставить в библиотеке валидации, а что перенести во встроенную.
- Как настроить ответы об ошибках валидации minimal API через IProblemDetailsService в ASP.NET Core 11 для формирования тела 400 после миграции.
- Minimal API против контроллеров в ASP.NET Core 11 для решения о модели конечной точки, лежащего под этим.
- Как организовать конечные точки minimal API с MapGroup в ASP.NET Core 11 для того, где фильтр валидации стоит относительно групповых фильтров.
Источники
- Microsoft Learn, What’s new in ASP.NET Core in .NET 10 (встроенная валидация minimal API,
AddValidation, генератор исходного кода,DisableValidation,ProblemDetails). - Microsoft Learn, System.ComponentModel.DataAnnotations namespace (
ValidationAttribute,IValidatableObject,ValidationResult). - Tim Deschryver, ASP.NET 10: Validating incoming models in Minimal APIs (валидация вложенных объектов, обнаружение генератором, форма ошибки).
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.