Web API
Как строить HTTP API на ASP.NET Core: контроллеры или Minimal API, откуда берутся параметры и как они проверяются, как проектировать ресурсы и статус-коды, отдавать ошибки, версионировать и описывать API и держать нагрузку. Это ядро собеседования на бэкенд-позицию в .NET.
Перед этой темой: Хостинг и конвейер, Внедрение зависимостей.
Чем контроллеры отличаются от Minimal API и что когда выбирать?
Заголовок раздела «Чем контроллеры отличаются от Minimal API и что когда выбирать?»Что это
- Контроллер — класс-наследник
ControllerBaseс атрибутом[ApiController]. Каждый публичный метод-action обслуживает один эндпоинт, маршрут задают атрибуты[Route]и[HttpGet]. - Minimal API — делегат, зарегистрированный на маршрут в
Program.cs:app.MapGet("/products/{id}", (int id) => ...).
Как пользоваться
- Контроллеры подключают двумя строками:
builder.Services.AddControllers()иapp.MapControllers(). - В Minimal API эндпоинты задают через
MapGet,MapPost,MapPut,MapDelete. Общий префикс выносят в группу:var orders = app.MapGroup("/api/orders");. - Ответ возвращают через
TypedResults.Ok(...)илиTypedResults.NotFound(): тип результата виден в сигнатуре.
Частая ошибка
Складывать все обработчики Minimal API в один Program.cs на тысячу строк. Эндпоинты группируют по ресурсам в методы-расширения вроде MapOrders(this IEndpointRouteBuilder app).
Как устроено
- Оба подхода регистрируют эндпоинты в общей маршрутизации:
MapControllers()строит их из атрибутов,MapGet— из делегатов. - Эндпоинт контроллера запускает MVC: создание контроллера через DI, фильтры, привязку модели, валидацию, action и форматирование результата.
- Эндпоинт Minimal API — один
RequestDelegate: параметры читаются кодом, построенным при старте, а ответ сериализуется в JSON.
Когда что выбирать
| Нужно | Подход |
|---|---|
| Новый сервис, микросервис, Native AOT | Minimal API |
Свои IModelBinder, IModelValidator, application parts, OData |
контроллеры |
Ответ в XML или другом формате по Accept |
контроллеры (форматтеры) |
Ловушки
- Без структуры Minimal API превращается в огромный
Program.cs. Группа маршрутов задаёт общий префикс, фильтры, авторизацию и метаданные для набора эндпоинтов. Results.Ok(...)возвращаетIResultи не сообщает тип ответа в описание API.TypedResultsиResults<Ok<T>, NotFound>перечисляют все варианты ответа прямо в сигнатуре.- Фильтры MVC на Minimal API не действуют: там свои фильтры эндпоинтов.
Правило
Новый API — Minimal API с группами по ресурсам и TypedResults. Контроллеры — когда без возможностей MVC не обойтись или когда это стандарт существующего кода.
Под капотом
- Обработчик Minimal API превращается в
RequestDelegateодин раз при старте. Обычно это делаетRequestDelegateFactoryчерез деревья выражений, а приPublishAot— Request Delegate Generator во время компиляции. - Action контроллера на каждый запрос проходит через
ControllerActionInvoker: создание контроллера, цепочка фильтров,ModelStateDictionary, выбор форматтера. - Типы
TypedResultsреализуютIEndpointMetadataProvider, поэтому коды и типы ответов попадают в метаданные эндпоинта без атрибутов.
Цена
- Накладные расходы MVC — аллокации контекстов и фильтров на каждый запрос. Они видны в бенчмарках, но обычно меньше одного похода в базу.
- Native AOT работает только с Minimal API и требует source-generated JSON (
JsonSerializerContext): рефлексию при публикации отрезает trimming.
Компромиссы
- Контроллеры дают единый стиль из коробки: фильтры, conventions,
[ApiController]. В Minimal API ту же дисциплину задают группы, методы-расширения и фильтры эндпоинтов — это нужно описать в правилах команды. - Подходы смешиваются в одном приложении: маршрутизация общая, а
AddEndpointFilterработает и наMapControllers(). - Переписывать контроллеры ради скорости редко окупается: выигрыш — микросекунды на запрос.
Как проверить в проде
Сравнивают не подходы, а свой эндпоинт с одинаковой логикой: BenchmarkDotNet и метрика http.server.request.duration до и после переписывания.
Итог: Minimal API — меньше инфраструктуры на запрос и путь к Native AOT, контроллеры — богатый расширяемый конвейер MVC; выбирают по возможностям, а не по моде.
using Microsoft.AspNetCore.Http.HttpResults;using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);builder.Services.AddControllers();var app = builder.Build();
// Minimal API: группа с общим префиксом, все варианты ответа — в сигнатуреvar products = app.MapGroup("/api/v2/products").WithTags("Products");products.MapGet("/{id:int}", Results<Ok<ProductDto>, NotFound> (int id) => id == 1 ? TypedResults.Ok(new ProductDto(1, "Laptop")) : TypedResults.NotFound());
app.MapControllers(); // маршруты контроллеров — из атрибутовapp.Run();
// Тот же эндпоинт на контроллере[ApiController][Route("api/v1/products")]public class ProductsController : ControllerBase{ [HttpGet("{id:int}")] public ActionResult<ProductDto> Get(int id) => id == 1 ? new ProductDto(1, "Laptop") : NotFound();}
public record ProductDto(int Id, string Name);- Как вынести эндпоинты Minimal API из
Program.cs? — Методы-расширения надIEndpointRouteBuilderиMapGroupна каждый ресурс. - Зачем
Results<Ok<T>, NotFound>, если естьIResult? — Все варианты ответа видны в сигнатуре, в описании API и в unit-тестах. - Работают ли фильтры MVC в Minimal API? — Нет, там фильтры эндпоинтов (
AddEndpointFilter, .NET 7+). - Можно ли опубликовать контроллеры в Native AOT? — Нет, MVC с Native AOT несовместим, поддерживается только Minimal API.
- Чем
ControllerBaseотличается отController? —Controllerдобавляет поддержку представлений; для API достаточноControllerBase.
Как работают привязка модели и валидация?
Заголовок раздела «Как работают привязка модели и валидация?»Что это
- Привязка модели (model binding) — фреймворк сам достаёт значения из запроса и передаёт их в параметры метода.
- Валидация — проверка заполненной модели по правилам, обычно по атрибутам из
System.ComponentModel.DataAnnotations.
Откуда берутся значения
| Атрибут | Источник |
|---|---|
[FromRoute] |
сегмент пути: /orders/{id} |
[FromQuery] |
строка запроса: ?page=2 |
[FromHeader] |
заголовок запроса |
[FromBody] |
JSON-тело запроса |
Как пользоваться
- Повесьте атрибуты на свойства DTO:
[Required],[StringLength(50)],[Range(1, 100)]. - В контроллере с
[ApiController]невалидная модель сразу даёт ответ 400, проверятьModelState.IsValidне нужно. - В Minimal API на .NET 10 вызовите
builder.Services.AddValidation().
Частая ошибка
Ждать, что [Required] у int поймает пропущенное поле. Значимый тип без значения получает 0, и атрибут считает его заполненным. Нужен int? с [Required] или [Range].
Как устроено
| Параметр | [ApiController] |
Minimal API |
|---|---|---|
| Имя совпадает с сегментом маршрута | [FromRoute] |
из маршрута |
| Другой простой тип | [FromQuery] |
из строки запроса |
| Тип зарегистрирован в DI | [FromServices] |
из DI |
| Сложный тип | [FromBody] |
из JSON-тела |
IFormFile |
[FromForm] |
из формы |
Валидация
- В контроллерах проверка идёт сразу после привязки, ошибки копятся в
ModelState.[ApiController]добавляет фильтр, который при ошибках возвращает 400 сValidationProblemDetails— словарём «поле → сообщения». - Без
[ApiController]ModelState.IsValidпроверяют в каждом action вручную. - В Minimal API с .NET 10
AddValidation()проверяет DataAnnotations иIValidatableObjectу параметров и вложенных объектов и тоже отвечает 400. Отключить проверку для эндпоинта —DisableValidation().
Ловушки
- Ошибка разбора (
abcвместоint) в Minimal API даёт 400 ещё до обработчика и без списка ошибок по полям. - В MVC при включённом nullable-контексте ненулевое ссылочное свойство (
string Name) считается[Required]. - Параметр из тела у action один: тело запроса читается один раз.
Правило
DataAnnotations — для формы данных: обязательность, длины, диапазоны. Межполевые правила — IValidatableObject или FluentValidation, бизнес-проверки — в сервисе.
Под капотом
- Порядок в MVC: resource-фильтры → привязка модели (
IModelBinderна параметр, значения из value providers) → валидация (IObjectModelValidatorобходит граф) → action-фильтры. - Автоматический 400 — action-фильтр
ModelStateInvalidFilterс очень ранним порядком (-2000), поэтому до него не доходят даже ваши action-фильтры. - Minimal API строит код привязки один раз на эндпоинт. Простые типы разбираются через
TryParseилиIParsable<T>, свои — через статическийBindAsync. - Валидацию .NET 10 строит source generator, и он видит только типы своей сборки. Если эндпоинты лежат в другой сборке,
AddValidation()вызывают там.
Цена
- Валидатор MVC обходит весь граф модели, включая коллекции: большой JSON-массив в теле заметно нагружает CPU.
[FromBody]читает тело в память целиком; предел задаётMaxRequestBodySizeKestrel, по умолчанию около 28,6 МБ.
Компромиссы
- DataAnnotations декларативны и попадают в описание API, но условные правила на них неудобны. FluentValidation гибче, а его автоматическую интеграцию с MVC авторы больше не рекомендуют — валидатор вызывают явно или из фильтра.
- По умолчанию ошибки формы — 400. Нарушения бизнес-правил часто отдают как 422 — это решение фиксируют в контракте API.
Как проверить в проде
Автоматические 400 не доходят до action и теряются из виду. Их логируют через InvalidModelStateResponseFactory и смотрят долю 400 в http.server.request.duration в разрезе http.route.
Итог: привязка и валидация — отдельные этапы перед обработчиком; атрибуты описывают форму данных, а правила домена живут в коде сервиса.
using System.ComponentModel.DataAnnotations;using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);builder.Services.AddControllers();builder.Services.AddValidation(); // .NET 10: валидация в Minimal APIvar app = builder.Build();
// customerId — из маршрута, requestId — из заголовка, order — из JSON-телаapp.MapPost("/api/customers/{customerId:int}/orders", (int customerId, [FromHeader(Name = "X-Request-Id")] string? requestId, CreateOrderRequest order) => TypedResults.Created($"/api/customers/{customerId}/orders/1", order));// тело { "sku": "", "quantity": 0 } → 400, обработчик не вызывается
app.MapControllers();app.Run();
[ApiController] // невалидные параметры → автоматический 400[Route("api/products")]public class ProductsController : ControllerBase{ [HttpGet] // ?page=2&name=phone public IActionResult Search([FromQuery, Range(1, 1000)] int page = 1, [FromQuery] string? name = null) => Ok(new { page, name });}
public record CreateOrderRequest([Required, StringLength(50)] string Sku, [Range(1, 100)] int Quantity);- Как собрать несколько параметров из query в один объект в Minimal API? —
[AsParameters](.NET 7+): свойства объекта привязываются как отдельные параметры. - Почему
[Required]уintне срабатывает? — У значимого типа всегда есть значение0; нуженint?. - Как отключить автоматический 400 у
[ApiController]? —ApiBehaviorOptions.SuppressModelStateInvalidFilter = true. - Что будет в MVC, если в JSON нет ненулевого свойства
string? — Ошибка валидации: при nullable-контексте оно считается[Required]. - Где проверять, что товар существует? — В сервисе или обработчике: это бизнес-правило, ответ 404 или 409, а не 400.
Как спроектировать REST API: ресурсы, методы, статус-коды, идемпотентность, пагинация?
Заголовок раздела «Как спроектировать REST API: ресурсы, методы, статус-коды, идемпотентность, пагинация?»Что это
REST описывает API как набор ресурсов. У ресурса есть адрес, а операции над ним выражаются стандартными HTTP-методами.
Как пользоваться
| Запрос | Действие и ответ |
|---|---|
GET /orders |
список заказов, 200 |
GET /orders/42 |
один заказ, 200 или 404 |
POST /orders |
создать заказ, 201 и заголовок Location |
PUT /orders/42 |
заменить целиком, 200 или 204 |
DELETE /orders/42 |
удалить, 204 |
Ошибки клиента — 4xx: 400 (невалидный запрос), 401 (не аутентифицирован), 403 (нет прав), 404 (нет ресурса), 409 (конфликт). Ошибки сервера — 5xx.
Частая ошибка
Глаголы в URL (/getOrders, /createOrder) и ответ 200 с { "error": "..." } внутри. Действие уже выражено методом, а ошибка — статус-кодом.
Как устроено
| Метод | Безопасный | Идемпотентный |
|---|---|---|
| GET, HEAD | да | да |
| PUT, DELETE | нет | да |
| POST | нет | нет |
| PATCH | нет | не гарантирован |
- Безопасный метод не меняет состояние сервера.
- Идемпотентный — повтор запроса оставляет сервер в том же состоянии, что и один вызов. Повторный DELETE ничего не ломает, даже если вернёт 404.
Когда что выбирать
- Полная замена — PUT, частичное изменение — PATCH (JSON Merge Patch или JSON Patch).
- Действие вне CRUD, например отмену заказа, оформляют подресурсом:
POST /orders/42/cancellation. - Долгая операция — 202 Accepted и адрес, где проверить её статус.
Пагинация
?offset=40&limit=20проста и даёт номера страниц, но медленнеет на дальних страницах и пропускает строки при вставках.- Keyset:
?after=40&limit=20, гдеafter— ключ последней строки. Стабильна и быстра по индексу, но не умеет прыгать на произвольную страницу.
Ловушки
- 401 — «кто вы?», 403 — «знаю, кто вы, но нельзя».
- Без серверного максимума запрос с
limit=1000000положит базу.
Правило
Ресурс — существительное, действие — метод, результат — код. Любой список — с лимитом на сервере и указателем на следующую страницу.
Под капотом
- Сеть ненадёжна: если ответ потерялся, клиент не знает, выполнился ли запрос. Повтор PUT безопасен, повтор POST создаёт дубль.
Idempotency-Key— распространённая практика (черновик IETF, не RFC). Клиент шлёт уникальный ключ, сервер сохраняет результат первой обработки и на повтор с тем же ключом возвращает его без повторного выполнения.- ETag — версия представления ресурса.
GETотдаётETag, аPUTсIf-Matchпроходит, только если версия не изменилась, иначе 412 Precondition Failed. Это оптимистичная блокировка поверх HTTP; в EF Core ей соответствует concurrency token.
Цена
- Ключи идемпотентности требуют хранилища с TTL, атомарной записи ключа вместе с операцией и ответа на «ключ ещё в обработке», обычно 409.
OFFSET Nзаставляет базу прочитать и выбросить N строк: время растёт с номером страницы. ЗапросWHERE id > @after ORDER BY idс лимитом идёт по индексу за одинаковое время.COUNT(*)для «всего страниц» на больших таблицах дорог, его часто убирают.
Компромиссы
- Курсор делают непрозрачным, например base64 от ключа сортировки: реализацию можно менять без смены контракта.
- HATEOAS — ссылки на возможные действия в ответе — полная модель REST, но на практике обходятся ссылкой на следующую страницу и машиночитаемым описанием API.
- Строгий REST не догма: поиск со сложными фильтрами допустимо оформить как
POST /orders/search.
Как проверить в проде
Ищут дубли сущностей от ретраев и следят за долей 409 и 412 по http.route. Медленные дальние страницы видны в плане запроса и в журнале медленных запросов базы.
Итог: надёжный API рассчитан на повторы и конкуренцию: идемпотентность для ретраев, ETag для параллельных правок, keyset для больших коллекций.
var builder = WebApplication.CreateBuilder(args);var app = builder.Build();var db = new List<Order>(); // вместо базы данныхvar orders = app.MapGroup("/api/orders");
// GET /api/orders?after=40&limit=20 — keyset-пагинация по Idorders.MapGet("/", (int? after, int? limit) =>{ int size = Math.Clamp(limit ?? 20, 1, 100); // серверный максимум var items = db.Where(o => o.Id > (after ?? 0)).OrderBy(o => o.Id).Take(size).ToList(); return TypedResults.Ok(new Page<Order>(items, items.Count == size ? items[^1].Id : null));});
// POST создаёт ресурс: 201 и заголовок Locationorders.MapPost("/", (NewOrder dto) =>{ var order = new Order(db.Count + 1, dto.Sku, dto.Quantity); db.Add(order); return TypedResults.Created($"/api/orders/{order.Id}", order);});
// DELETE идемпотентен: повторный вызов тоже вернёт 204orders.MapDelete("/{id:int}", (int id) =>{ db.RemoveAll(o => o.Id == id); return TypedResults.NoContent();});
app.Run();
record Order(int Id, string Sku, int Quantity);record NewOrder(string Sku, int Quantity);record Page<T>(List<T> Items, int? NextAfter);- Чем PUT отличается от PATCH? — PUT заменяет ресурс целиком и идемпотентен, PATCH меняет часть, и идемпотентность не гарантирована.
- Что вернуть на повторный DELETE? — 204 или 404 — допустимы оба; состояние сервера не меняется, это и есть идемпотентность.
- Как сделать POST безопасным для ретраев? — Заголовок
Idempotency-Keyи сохранённый результат первой обработки. - Почему OFFSET-пагинация медленная? — База читает и отбрасывает все пропущенные строки; keyset идёт по индексу.
- Когда отвечать 202 Accepted? — Операция принята, но выполнится позже; в ответе — адрес для проверки статуса.
Как обрабатывать ошибки в API: ProblemDetails, UseExceptionHandler и IExceptionHandler?
Заголовок раздела «Как обрабатывать ошибки в API: ProblemDetails, UseExceptionHandler и IExceptionHandler?»Что это
ProblemDetails — стандартное тело ответа с ошибкой (RFC 9457, раньше RFC 7807) с типом содержимого application/problem+json:
type— ссылка-идентификатор вида ошибки;title— короткое описание;status— HTTP-код;detail— подробности этого случая.
Зачем
Клиент разбирает все ошибки одним кодом, а не угадывает формат каждого эндпоинта.
Как пользоваться
builder.Services.AddProblemDetails();app.UseExceptionHandler();— исключение превращается в 500 с ProblemDetails.app.UseStatusCodePages();— пустые ответы 4xx и 5xx получают тело ProblemDetails.- Ожидаемые ошибки возвращайте явно:
TypedResults.NotFound(),TypedResults.Problem(...).
Частая ошибка
Отдавать клиенту exception.ToString(). Stack trace раскрывает устройство сервиса и помогает атакующему, а подробностям место в логе.
Как устроено
UseExceptionHandlerстоит в начале конвейера и оборачивает остальные middleware вtry/catch.- При исключении он очищает ответ, если отправка ещё не началась, и ставит код 500.
- Затем по очереди вызывает зарегистрированные
IExceptionHandler, пока один не вернётtrue. - Если никто не обработал, пишет ProblemDetails через
IProblemDetailsServiceили выполняет запрос заново по пути вроде/error.
Когда что выбирать
| Ситуация | Инструмент |
|---|---|
| Ожидаемый исход: нет ресурса, конфликт | TypedResults.NotFound(), TypedResults.Conflict() |
| Своё исключение → статус-код и тело | IExceptionHandler (.NET 8+) |
| Простое сопоставление «тип → код» | ExceptionHandlerOptions.StatusCodeSelector (.NET 9+) |
| Общие поля во всех ответах | ProblemDetailsOptions.CustomizeProblemDetails |
Ловушки
IExceptionHandlerбезapp.UseExceptionHandler()никогда не вызывается.app.UseExceptionHandler()без аргументов и безAddProblemDetails()падает при старте сInvalidOperationException.- Если ответ уже начал отправляться, например при стриминге, код не изменить: соединение просто оборвётся.
- В Development необработанное исключение показывает Developer Exception Page со стеком; в проде её быть не должно.
Правило
Исключения — для неожиданного, ожидаемое — результатом. Один формат ошибок на весь API и traceId в каждом ответе.
Под капотом
DefaultProblemDetailsWriterпишет ответ, только если заголовокAcceptдопускаетapplication/jsonилиapplication/problem+json. ИначеTryWriteAsyncвернётfalse, и нужен запасной ответ.- Writer добавляет
traceIdизActivity.Current: это W3C trace id, тот же, что в логах и распределённой трассировке. - Контроллеры с
[ApiController]сами превращают результаты с кодом от 400 без тела, напримерNotFound(), в ProblemDetails черезProblemDetailsFactory.
Цена
- Исключение — захват стека и раскрутка кадров, на порядки дороже
return. Поток исключений на обычных путях (валидация, «не найдено») заметен в профиле CPU. - Поэтому доменные исходы выражают типом результата:
Results<Ok<T>, NotFound, Conflict>или своимResult.
Компромиссы
- С .NET 10 middleware не пишет логи и метрики для исключений, обработанных
IExceptionHandler. Важные для алертов ошибки логируют в самом обработчике или возвращают старое поведение:SuppressDiagnosticsCallback = _ => false. - Стабильный
type, например URI вашей документации, — часть контракта: клиенты ветвятся по нему, аtitleиdetailможно менять. - Детали исключения в ответе допустимы только в Development.
Как проверить в проде
- Метрика
aspnetcore.diagnostics.exceptions(.NET 8+) считает исключения в middleware по типу; обработанныеIExceptionHandlerс .NET 10 в неё по умолчанию не попадают. http.server.request.durationс атрибутомerror.typeпоказывает, на какихhttp.routeрастёт доля 5xx.- По
traceIdиз ответа клиента ошибку находят в логах и трассах.
Итог: ProblemDetails — контракт ошибок: стабильный type для клиента, traceId для оператора и никаких внутренностей наружу.
using Microsoft.AspNetCore.Diagnostics;
var builder = WebApplication.CreateBuilder(args);builder.Services.AddProblemDetails();builder.Services.AddExceptionHandler<NotFoundExceptionHandler>(); // .NET 8+var app = builder.Build();
app.UseExceptionHandler(); // исключение → 500 и ProblemDetails без stack traceapp.UseStatusCodePages(); // пустые 4xx и 5xx → ProblemDetails
app.MapGet("/api/orders/{id:int}", (int id) => id == 1 ? TypedResults.Ok(new { Id = 1 }) : throw new KeyNotFoundException($"Order {id} not found"));// /api/orders/2 → 404, в теле: type, title, status, detail, traceId
app.MapGet("/api/crash", () => { throw new InvalidOperationException("Connection failed: password=secret"); });// → 500, текста исключения в ответе нет
app.Run();
sealed class NotFoundExceptionHandler(IProblemDetailsService problemDetails) : IExceptionHandler{ public async ValueTask<bool> TryHandleAsync(HttpContext context, Exception exception, CancellationToken ct) { if (exception is not KeyNotFoundException) return false; // не наше — дальше по цепочке context.Response.StatusCode = StatusCodes.Status404NotFound; return await problemDetails.TryWriteAsync(new() { HttpContext = context, Exception = exception, ProblemDetails = { Title = "Resource not found", Detail = exception.Message } }); }}- Чем
IExceptionHandlerлучше своего middleware сtry/catch? — Встроен вUseExceptionHandler: очистка ответа, цепочка обработчиков, ProblemDetails и DI из коробки. - Что будет, если исключение случилось после начала отправки ответа? — Код и заголовки не изменить; middleware пробросит исключение, и соединение оборвётся.
- Зачем
UseStatusCodePages? — Пустые 4xx и 5xx, например 404 маршрутизации, получают тело ProblemDetails. - Почему ожидаемые ошибки не выражают исключениями? — Исключения дороги и прячут поток управления; лучше
Results<...>или свой Result-тип. - Что изменилось в .NET 10 для обработанных исключений? — Они больше не логируются по умолчанию; вернуть поведение —
SuppressDiagnosticsCallback.
Чем фильтры отличаются от middleware и какие они бывают?
Заголовок раздела «Чем фильтры отличаются от middleware и какие они бывают?»Что это
Фильтр — класс или атрибут, который ASP.NET Core вызывает вокруг action контроллера. Он может выполнить код до action, после него или вообще не дать action выполниться.
Зачем
Чтобы убрать повторяющийся код из action: замер времени, проверку заголовка, аудит, единый формат ответа.
Как пользоваться
- Реализуйте интерфейс, например
IAsyncActionFilterс методомOnActionExecutionAsync. - Подключите фильтр глобально (
options.Filters.Add<T>()вAddControllers) или атрибутом на контроллер либо action. - Фильтру с зависимостями из DI нужен
[ServiceFilter(typeof(...))]или[TypeFilter(typeof(...))].
Частая ошибка
Писать в фильтре то, что должно работать для всех запросов, например CORS. Фильтр не вызовется для статических файлов, Minimal API и запросов, которые не дошли до action.
Как устроено
Порядок внутри MVC:
- Authorization — первыми, могут сразу вернуть 401 или 403.
- Resource — оборачивают всё остальное и выполняются до привязки модели.
- Привязка модели и валидация.
- Action — до и после метода action, видят аргументы и результат.
- Exception — ловят исключения из создания контроллера, привязки, action-фильтров и action.
- Result — до и после выполнения
IActionResult.
Когда что выбирать
| Задача | Где |
|---|---|
| Сжатие, CORS, логирование HTTP | middleware |
| Проверка аргументов, аудит action | action-фильтр или фильтр эндпоинта |
| Исключения одного контроллера → особый ответ | exception-фильтр |
| Глобальная обработка ошибок | UseExceptionHandler |
Ловушки
- Глобальные фильтры оборачивают фильтры контроллера, а те — фильтры action. Изменить порядок можно через
IOrderedFilter.Order. - Result-фильтры не вызываются, если authorization- или resource-фильтр прервал конвейер. Для этого случая есть
IAlwaysRunResultFilter. - Экземпляр фильтра-атрибута переиспользуется между запросами: поле с состоянием в нём — гонка, а зависимости в конструктор атрибута не передать.
Правило
Всё, что не зависит от выбранного action, — в middleware. Всё, что работает с аргументами, ModelState или результатом action, — в фильтр.
Под капотом
- Эндпоинт контроллера запускает
ControllerActionInvoker. Список фильтров для action строится один раз и кешируется, а на запрос invoker проходит его как машину состояний. - Фильтр эндпоинта получает
EndpointFilterInvocationContextс аргументами обработчика:GetArgument<T>(index). Код доnextвыполняется в порядке регистрации, послеnext— в обратном. AddEndpointFilterFactoryполучаетMethodInfoобработчика при построении эндпоинта и может не добавлять фильтр, если сигнатура не подходит.
Цена
- Каждый фильтр — лишний вызов на запрос, а
TypeFilterи transient-фильтр черезServiceFilterещё и создают объект. Десятки глобальных фильтров заметны на горячих эндпоинтах. - Resource-фильтр стоит до привязки модели: он может отклонить запрос, не читая тело, и сэкономить работу.
Компромиссы
- Exception-фильтр знает контроллер и action, но не ловит ошибки middleware, resource- и result-фильтров и сериализации ответа. Глобальная сетка —
UseExceptionHandler. - В смешанном приложении общую логику (аудит, проверку версии клиента) пишут фильтром эндпоинта и вешают и на
MapGroup, и наMapControllers(). - Фильтры не видны в
Program.cs: много атрибутов на контроллерах прячут поведение. Глобальная регистрация в одном месте читается проще.
Как проверить в проде
На уровне логирования Debug для Microsoft.AspNetCore.Mvc MVC пишет план выполнения фильтров каждого типа. Время отдельных фильтров видно в трассе, если обернуть их в Activity.
Итог: фильтры дают контекст action ценой привязки к MVC; сквозную логику держат в middleware, глобальные ошибки — в UseExceptionHandler.
using System.Diagnostics;using Microsoft.AspNetCore.Mvc.Filters;
var builder = WebApplication.CreateBuilder(args);builder.Services.AddControllers(o => o.Filters.Add<TimingFilter>()); // глобальный action-фильтрvar app = builder.Build();
app.MapControllers();
// Minimal API: фильтр эндпоинта видит аргументы обработчикаapp.MapGet("/api/items/{id:int}", (int id) => TypedResults.Ok(id)) .AddEndpointFilter(async (context, next) => { int id = context.GetArgument<int>(0); if (id <= 0) return TypedResults.Problem("Id must be positive", statusCode: 400); // обработчик не вызван return await next(context); });
app.Run();
// Action-фильтр: после привязки модели, вокруг метода actionpublic class TimingFilter(ILogger<TimingFilter> logger) : IAsyncActionFilter{ public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { var sw = Stopwatch.StartNew(); ActionExecutedContext executed = await next(); // выполняется сам action logger.LogInformation("{Action}: {Ms} ms, failed: {Failed}", context.ActionDescriptor.DisplayName, sw.ElapsedMilliseconds, executed.Exception is not null); }}- В каком порядке выполняются фильтры MVC? — Authorization → resource → привязка модели → action → exception → result.
- Поймает ли exception-фильтр ошибку сериализации ответа? — Нет: исключения result-фильтров и выполнения результата он не видит.
- Как внедрить зависимость в фильтр-атрибут? —
[ServiceFilter]берёт фильтр из DI,[TypeFilter]создаёт его черезObjectFactory. - Как прервать конвейер в фильтре? — Присвоить
context.Resultи не вызыватьnext. - Есть ли фильтры в Minimal API? — Да,
AddEndpointFilterиIEndpointFilterс .NET 7; их можно повесить на группу маршрутов.
Как версионировать API и описывать его в OpenAPI?
Заголовок раздела «Как версионировать API и описывать его в OpenAPI?»Что это
- Версия API — номер контракта. Клиент выбирает её в запросе, и сервер отвечает по правилам этой версии.
- OpenAPI (раньше Swagger) — документ JSON или YAML со списком путей, параметров, тел запросов, ответов и кодов.
Зачем
Мобильное приложение старой версии нельзя обновить мгновенно. Пока оно живёт, /v1 должен работать как раньше, а новые клиенты переходят на /v2.
Как пользоваться
- Добавьте пакет
Microsoft.AspNetCore.OpenApi, вызовитеbuilder.Services.AddOpenApi()иapp.MapOpenApi(). Документ появится по адресу/openapi/v1.json. - Встроенного UI нет: страницу документации подключают отдельно, например Swagger UI или Scalar.
- Простейшее версионирование — группа маршрутов на версию:
app.MapGroup("/api/v1").
Частая ошибка
Менять формат ответа внутри существующей версии: переименовать поле или сделать его обязательным. Старые клиенты перестают разбирать ответ.
Как устроено
| Где передать версию | Пример |
|---|---|
| Сегмент пути | /api/v2/orders |
| Query-параметр | /api/orders?api-version=2.0 |
| Заголовок | X-Api-Version: 2.0 |
| Media type | Accept: application/json; v=2.0 |
- Asp.Versioning (
Asp.Versioning.Httpдля Minimal API,Asp.Versioning.Mvcдля контроллеров) читает версию из запроса, выбирает эндпоинт и с опциейReportApiVersionsпишет поддерживаемые версии в заголовокapi-supported-versions. Microsoft.AspNetCore.OpenApiстроит документ из метаданных эндпоинтов: маршрута, параметров,TypedResults,Produces,WithSummary, а с .NET 10 — и XML-комментариев.
Когда что выбирать
- Сегмент пути нагляден, легко маршрутизируется на шлюзе и виден в логах — его выбирают чаще всего.
- Query и заголовок оставляют URL ресурса прежним, но версию легко забыть передать, поэтому нужна версия по умолчанию.
Ловушки
- Без настройки все версии попадают в один документ. Нужен отдельный документ на версию:
AddOpenApi("v2")иWithGroupName("v2")у эндпоинтов; Asp.Versioning 10 с пакетомAsp.Versioning.OpenApiделает это сам. WithOpenApi()в .NET 10 устарел (предупреждение ASPDEPR002): описание правят трансформерами документа и операций.- Документ в проде раскрывает поверхность атаки, поэтому шаблон отдаёт его только в Development.
Правило
Версию меняют только при ломающем изменении. Обратно совместимое — новое необязательное поле, новый эндпоинт — добавляют в текущую версию.
Под капотом
- Asp.Versioning добавляет эндпоинтам метаданные версий и свою политику сопоставления: эндпоинты с одним шаблоном различаются версией из запроса, а неподдерживаемая версия получает ошибку в формате ProblemDetails.
Microsoft.AspNetCore.OpenApiчитает метаданные из API Explorer. С .NET 10 он работает на Microsoft.OpenApi 2.0: OpenAPI 3.1 со схемами JSON Schema 2020-12 и YAML черезMapOpenApi("/openapi/{documentName}.yaml").- Пакет
Microsoft.Extensions.ApiDescription.ServerсOpenApiGenerateDocumentsOnBuildгенерирует документ при сборке, без развёрнутого сервиса.
Цена
- Каждая живая версия — код, тесты и мониторинг. Обычно поддерживают текущую и предыдущую, остальные выводят по графику.
- Документ строится по запросу к
/openapi/...; в проде его кешируют (CacheOutput()) или отдают готовым файлом.
Компромиссы
- Code-first (документ из кода) удобен одной .NET-команде. Contract-first (сначала YAML, потом код) — когда контракт согласуют несколько команд; тогда сгенерированный документ сверяют с эталоном в CI.
- Устаревание объявляют заранее: Asp.Versioning помечает версии как deprecated и публикует политику отключения, клиент видит её в заголовках.
- Одна версия на весь API проще клиентам, отдельные версии ресурсов дают меньше дублирования кода.
Как проверить в проде
- Считайте запросы по версиям, например по
http.routeс сегментом пути: без этих данных старую версию не отключить. - В CI сравнивайте OpenAPI-документ с прошлым релизом утилитой вроде oasdiff и валите сборку на ломающих изменениях.
Итог: версионирование — политика жизни контрактов, а OpenAPI позволяет проверять её автоматически, а не на ревью.
using Microsoft.AspNetCore.Http.HttpResults;
var builder = WebApplication.CreateBuilder(args);builder.Services.AddOpenApi();var app = builder.Build();app.MapOpenApi(); // документ: GET /openapi/v1.json
// v1: старые клиенты получают прежний контрактvar v1 = app.MapGroup("/api/v1/orders").WithTags("Orders");v1.MapGet("/{id:int}", (int id) => TypedResults.Ok(new OrderV1(id, 100m))) .WithSummary("Get order (v1)");
// v2: ломающее изменение — сумма стала объектом с валютойvar v2 = app.MapGroup("/api/v2/orders").WithTags("Orders");v2.MapGet("/{id:int}", Results<Ok<OrderV2>, NotFound> (int id) => id > 0 ? TypedResults.Ok(new OrderV2(id, new Money(100m, "EUR"))) : TypedResults.NotFound()) .WithSummary("Get order (v2)") .ProducesProblem(StatusCodes.Status500InternalServerError);
app.Run();
record OrderV1(int Id, decimal Total);record OrderV2(int Id, Money Total);record Money(decimal Amount, string Currency);Метаданные WithTags, WithSummary, ProducesProblem и типы TypedResults попадают в OpenAPI-документ без атрибутов.
- Нужна ли новая версия, если в ответ добавили поле? — Нет, это обратно совместимо, если клиенты игнорируют незнакомые поля.
- Чем встроенный OpenAPI отличается от Swashbuckle? — Пакет Microsoft, в шаблонах с .NET 9, трансформеры вместо фильтров, без встроенного UI.
- Как получить документ без запуска сервиса? —
Microsoft.Extensions.ApiDescription.ServerиOpenApiGenerateDocumentsOnBuildгенерируют его при сборке. - Как сообщить клиентам, что версия устаревает? — Пометить её deprecated в Asp.Versioning, отдавать
DeprecationиSunset, назвать дату отключения. - Где версию удобнее разбирать на API Gateway? — В сегменте пути: маршрутизация по префиксу без разбора заголовков.
Как сделать API быстрым и устойчивым под нагрузкой и как это измерить?
Заголовок раздела «Как сделать API быстрым и устойчивым под нагрузкой и как это измерить?»Что это
Скорость API — время ответа на один запрос. Устойчивость — способность держать много запросов и не падать от пиков.
Как пользоваться
- Async до конца. База и сеть — только через
await:ToListAsync,SaveChangesAsync,GetAsyncуHttpClient. - Меньше данных. Списки — постранично с максимальным размером страницы; из базы — только нужные поля через
Selectв DTO. - Сжатие.
AddResponseCompression()уменьшает JSON в несколько раз. - Защита.
AddRateLimiter()ограничивает число запросов от клиента,AddRequestTimeouts()отменяет слишком долгие.
Частая ошибка
Вызвать .Result или .Wait() у задачи в обработчике. Поток пула блокируется, и под нагрузкой сервис встаёт, хотя процессор свободен.
Как устроено
| Приём | Что даёт |
|---|---|
| Rate limiting (.NET 7+) | отказ лишним запросам до обработки |
| Request timeouts (.NET 8+) | отмена RequestAborted по таймауту, иначе ответ 504 |
| Сжатие ответов | меньше трафика ценой CPU (Brotli, Gzip) |
IAsyncEnumerable<T> в ответе |
JSON-массив уходит по мере чтения, без буфера |
Когда что выбирать
- Fixed window, sliding window и token bucket ограничивают число запросов за период, concurrency limiter — число одновременных. Политику вешают через
RequireRateLimitingи делят по пользователю или ключу API. - Таймаут — для эндпоинтов с непредсказуемо долгой работой: отчёты, внешние вызовы.
- Сжатие в приложении — если перед ним нет прокси, который сжимает сам.
Ловушки
RejectionStatusCodeпо умолчанию — 503, а клиенту правильнее 429 Too Many Requests.- Таймаут только отменяет токен: код, который не передаёт
CancellationTokenв базу, продолжит работать. Под отладчиком таймауты не срабатывают. EnableForHttpsу сжатия по умолчанию выключен из-за атак CRIME и BREACH: не сжимайте ответы, где секреты смешаны с данными пользователя.
Правило
Сначала измерьте, куда уходит время: база, сеть, сериализация. Оптимизируйте узкое место, а лимиты и таймауты ставьте всегда.
Под капотом
- Когда обработчики ждут базу, растут
http.server.active_requestsи очередь пула потоков, и p99 уходит в секунды раньше, чем загрузится CPU. - Буферизованный ответ на десятки мегабайт — это объекты в LOH и сборки Gen 2. Стриминг
IAsyncEnumerable<T>иAsAsyncEnumerable()в EF Core держат в памяти элемент, а не весь список. - Синхронный ввод-вывод с телом запроса запрещён с ASP.NET Core 3.0 (
AllowSynchronousIO = false): он блокировал бы поток пула. - HTTP/2 мультиплексирует запросы в одном соединении, HTTP/3 убирает блокировку очереди при потере пакетов. В Kestrel HTTP/3 включают явно (
Http1AndHttp2AndHttp3), и он требует TLS.
Цена
- Rate limiter в памяти считает запросы одного экземпляра: на пяти подах лимит фактически впятеро выше. Общий лимит — на шлюзе или в распределённом хранилище.
- Партиция по IP за прокси видит адрес прокси, пока не настроены forwarded headers, и уязвима к подмене адресов.
- Стриминг фиксирует статус 200 до конца ответа: ошибка посередине даёт оборванный JSON, а не ProblemDetails.
Компромиссы
- Кеш — отдельный слой: output caching для целых ответов и
HybridCacheдля данных разбирают в теме о кешировании. - Ранний отказ дешевле: 429 за миллисекунды лучше таймаута через 30 секунд, который клиент повторит.
Как проверить в проде
- Нагрузочный тест (k6, NBomber, bombardier) с ростом RPS до деградации: смотрят p95 и p99 и долю ошибок, а не среднее.
dotnet-counters monitor --counters Microsoft.AspNetCore.Hosting,System.Runtime— время запросов, активные запросы, очередь пула потоков, аллокации.aspnetcore.rate_limiting.requestsпоказывает отказы лимитера,kestrel.queued_connections— упор в приём соединений.
Итог: устойчивость — это слои защиты и измерения по хвостам задержек, а не одна оптимизация.
using System.Runtime.CompilerServices;using System.Threading.RateLimiting;
var builder = WebApplication.CreateBuilder(args);builder.Services.AddRequestTimeouts();builder.Services.AddRateLimiter(o =>{ o.RejectionStatusCode = StatusCodes.Status429TooManyRequests; // по умолчанию 503 o.AddPolicy("per-ip", ctx => RateLimitPartition.GetFixedWindowLimiter( ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ => new FixedWindowRateLimiterOptions { PermitLimit = 100, Window = TimeSpan.FromSeconds(1) }));});var app = builder.Build();app.UseRateLimiter();app.UseRequestTimeouts();
// Через 2 с отменяется RequestAborted; необработанная отмена → 504app.MapGet("/api/report", async (CancellationToken ct) =>{ await Task.Delay(TimeSpan.FromSeconds(5), ct); return TypedResults.Ok("done");}).WithRequestTimeout(TimeSpan.FromSeconds(2)).RequireRateLimiting("per-ip");
// Стриминг: элементы уходят клиенту по мере готовности, без буфера на весь ответapp.MapGet("/api/events", (CancellationToken ct) => ReadEventsAsync(ct)).RequireRateLimiting("per-ip");
app.Run();
static async IAsyncEnumerable<EventDto> ReadEventsAsync([EnumeratorCancellation] CancellationToken ct){ for (int i = 0; i < 1_000; i++) { await Task.Delay(10, ct); // имитация чтения из базы yield return new EventDto(i, DateTimeOffset.UtcNow); }}
record EventDto(int Id, DateTimeOffset At);- Какой код вернёт rate limiter по умолчанию? — 503; обычно ставят 429 и заголовок
Retry-After. - Остановит ли request timeout запрос к базе? — Только если токен
RequestAbortedпередан в вызов; сам таймаут лишь отменяет токен. - Почему лимитер в памяти не работает на нескольких экземплярах? — Каждый считает свои запросы; общий лимит — на шлюзе или в распределённом хранилище.
- Зачем стримить
IAsyncEnumerable<T>? — Ответ уходит по мере чтения, в памяти не весь список; но статус 200 зафиксирован до конца. - Почему сжатие лучше отдать прокси? — Меньше CPU в приложении, а серверные модули сжатия быстрее middleware ASP.NET Core.