ASP.NET Core изнутри: путь запроса
В статье про архитектуру мы выбирали между Minimal API и контроллерами, но обошли главное: что вообще происходит с запросом между моментом, когда байты пришли в сокет, и моментом, когда вызвался ваш метод. Между ними — десяток шагов, и почти каждый инцидент вида «CORS не работает», «токен есть, а 401», «валидация не сработала» объясняется неправильным порядком именно на этих шагах.
Разберём конвейер так, чтобы вы могли отладить его в голове, не запуская отладчик.
Общая картина
HTTP-сервер participant M as Конвейер middleware participant R as Матчер маршрутов participant F as Фильтры / endpoint filters participant H as Ваш обработчик participant DI as DI-скоуп запроса C->>K: TCP + TLS, HTTP-запрос K->>K: разбор строки запроса и заголовков K->>DI: создать scope на запрос K->>M: HttpContext M->>M: обработка ошибок, HTTPS, статика, CORS M->>R: UseRouting — подобрать endpoint R-->>M: выбранный endpoint и route values M->>M: UseAuthentication — кто это? M->>M: UseAuthorization — можно ли ему сюда? M->>F: вызов endpoint F->>H: модельное связывание + валидация H-->>F: результат F-->>M: IResult / IActionResult M-->>K: статус, заголовки, тело K-->>C: HTTP-ответ K->>DI: освободить scope запроса
Ключевое, что нужно унести из этой схемы: маршрут выбирается раньше, чем выполняется
авторизация. Именно поэтому UseAuthorization обязан идти после UseRouting — иначе
политике просто нечего проверять: endpoint ещё не известен, а вместе с ним неизвестны и
его атрибуты [Authorize].
Сам Kestrel — кросс-платформенный HTTP-сервер, написанный на C# поверх
System.IO.Pipelines. Он умеет HTTP/1.1, HTTP/2 и HTTP/3 и способен смотреть в интернет
напрямую, но в проде его чаще ставят за обратным прокси (nginx, YARP, ingress-контроллер)
ради TLS-терминации, балансировки и защиты — см.
«Прокси и балансировка».
Middleware: конвейер, а не набор
Middleware — это функция, которая получает HttpContext и ссылку на следующий компонент
конвейера. Каждый компонент выполняется дважды: часть кода на пути внутрь, часть — на
пути наружу, после возврата из next.
var app = builder.Build();
// Инлайновый middleware: видно обе половины
app.Use(async (context, next) =>
{
var sw = Stopwatch.GetTimestamp(); // ← код ДО: путь внутрь
await next(context); // передаём управление глубже
var elapsed = Stopwatch.GetElapsedTime(sw); // ← код ПОСЛЕ: путь наружу
app.Logger.LogInformation("{Method} {Path} -> {Status} за {Ms} мс",
context.Request.Method, context.Request.Path,
context.Response.StatusCode, elapsed.TotalMilliseconds);
});
// Терминальный middleware: next не вызывается, конвейер обрывается
app.Run(async context => await context.Response.WriteAsync("Не найдено"));
Собственный middleware обычно оформляют классом. Есть два способа, и разница принципиальная:
// 1. По конвенции: создаётся ОДИН раз (эффективно singleton).
// Scoped-зависимости внедряйте в Invoke, а не в конструктор!
public sealed class CorrelationIdMiddleware(RequestDelegate next)
{
private const string Header = "X-Correlation-Id";
public async Task Invoke(HttpContext context, ILogger<CorrelationIdMiddleware> logger)
{
var id = context.Request.Headers[Header].FirstOrDefault() ?? Guid.NewGuid().ToString("n");
context.Response.Headers[Header] = id;
using (logger.BeginScope(new Dictionary<string, object> { ["CorrelationId"] = id }))
await next(context);
}
}
// 2. Через IMiddleware: создаётся фабрикой на каждый запрос, можно scoped-зависимости
public sealed class AuditMiddleware(IAuditWriter writer) : IMiddleware
{
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
await next(context);
await writer.RecordAsync(context.Request.Path, context.Response.StatusCode,
context.RequestAborted);
}
}
Ошибка «внедрил DbContext в конструктор middleware» даёт captive dependency: scoped-сервис
навсегда захвачен singleton-объектом (см. времена жизни DI в
статье про архитектуру). Симптом —
«DbContext уже используется другим потоком» под нагрузкой.
Канонический порядок
app.UseExceptionHandler(); // 1. Внешняя обёртка: видит ошибки всех остальных
app.UseHsts(); // 2. Заголовок строгой транспортной безопасности
app.UseHttpsRedirection(); // 3.
app.UseStaticFiles(); // 4. Статику отдаём до маршрутизации — дешевле
app.UseRouting(); // 5. Выбор endpoint (но НЕ его выполнение)
app.UseCors(); // 6. После routing: политика может быть на endpoint
app.UseRateLimiter(); // 7.
app.UseAuthentication(); // 8. Кто это? Заполняет HttpContext.User
app.UseAuthorization(); // 9. Можно ли ему сюда? Читает атрибуты endpoint
app.UseOutputCache(); // 10.
app.MapControllers(); // 11. Выполнение выбранного endpoint
Три правила, за нарушение которых платят временем отладки:
- Всегда вызывайте
await next(context), если не обрываете конвейер намеренно. Забыли — запрос молча возвращает 200 с пустым телом. - После
await next()заголовки уже могут быть отправлены. Проверяйтеcontext.Response.HasStartedперед попыткой что-то дописать, иначе получитеInvalidOperationExceptionвместо ответа. - Порядок — это семантика, а не стиль.
UseCorsдоUseRoutingне увидит политику endpoint;UseAuthorizationдоUseAuthenticationвсегда получит анонимного пользователя.
Ошибки на границе HTTP
Внутренние исключения не должны утекать наружу трассировкой стека. Современный способ —
IExceptionHandler (.NET 8) плюс формат ProblemDetails (RFC 9457): машиночитаемое
описание ошибки, которое понимают клиенты и шлюзы.
public sealed class DomainExceptionHandler(IProblemDetailsService problems) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(HttpContext context, Exception ex,
CancellationToken ct)
{
// Доменные ошибки превращаем в осмысленные статусы, остальное отдаём дальше
var (status, title) = ex switch
{
OrderNotFoundException => (StatusCodes.Status404NotFound, "Заказ не найден"),
ValidationException => (StatusCodes.Status400BadRequest, "Некорректные данные"),
RateLimitedException => (StatusCodes.Status429TooManyRequests, "Слишком часто"),
_ => (0, string.Empty) // не наш случай
};
if (status == 0) return false; // false = мы не обработали, пусть летит выше
context.Response.StatusCode = status;
return await problems.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = context,
ProblemDetails = { Title = title, Status = status }
});
}
}
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<DomainExceptionHandler>();
Если вы предпочитаете Result-паттерн из статьи про идиоматику, обработчик исключений остаётся страховкой для непредвиденного: он превращает любую утечку в аккуратный 500 без внутренних деталей. Показывать стектрейс наружу нельзя — это разведданные для атакующего (см. безопасность API).
Маршрутизация
Endpoint routing разделён на две фазы: UseRouting выбирает обработчик, а
MapControllers/MapGet в конце конвейера его выполняет. Между фазами лежат
authentication, authorization, CORS — они уже знают, какой endpoint выбран, и читают его
метаданные.
// Группа маршрутов с общими настройками — идиома .NET 7+
var orders = app.MapGroup("/api/orders")
.RequireAuthorization("orders:read")
.WithTags("Orders")
.AddEndpointFilter<ValidationFilter>();
orders.MapGet("/{id:int:min(1)}", async (int id, IOrderService svc, CancellationToken ct)
=> await svc.FindAsync(id, ct) is { } o ? Results.Ok(o) : Results.NotFound());
orders.MapGet("/", async ([AsParameters] OrderQuery query, IOrderService svc,
CancellationToken ct) => Results.Ok(await svc.SearchAsync(query, ct)));
orders.MapPost("/", async (CreateOrderRequest req, IOrderService svc, CancellationToken ct) =>
{
var id = await svc.PlaceAsync(req, ct);
return Results.Created($"/api/orders/{id}", new { id });
}).RequireAuthorization("orders:write");
Что здесь работает:
- Ограничения в шаблоне (
{id:int:min(1)}) отсекают заведомо неподходящие запросы до вашего кода. Полный список:int,guid,datetime,alpha,length(n),regex(...). - Приоритет маршрутов определяется специфичностью: литеральный сегмент выигрывает у параметра, параметр с ограничением — у параметра без него. Конфликт неоднозначных маршрутов роняет приложение на старте — это хорошо, ошибка видна сразу.
AddEndpointFilter— аналог фильтров MVC для Minimal API: сквозная логика вокруг вызова обработчика (валидация, логирование, идемпотентность).
Модельное связывание и валидация
Связывание (model binding) собирает аргументы вашего метода из частей запроса.
FromRoute / FromQuery /
FromHeader / FromBody / FromServices?"} ATTR -->|"Да"| USE["Берём из указанного источника"] ATTR -->|"Нет"| SVC{"Тип зарегистрирован в DI?"} SVC -->|"Да"| DI["Внедряем сервис"] SVC -->|"Нет"| SPECIAL{"Особый тип?
HttpContext, CancellationToken,
ClaimsPrincipal, Stream"} SPECIAL -->|"Да"| CTX["Подставляем из контекста"] SPECIAL -->|"Нет"| SIMPLE{"Простой тип
или есть TryParse?"} SIMPLE -->|"Да"| ROUTE["Ищем в route values,
затем в query string"] SIMPLE -->|"Нет"| BIND{"Есть BindAsync?"} BIND -->|"Да"| CUSTOM["Своя логика связывания"] BIND -->|"Нет"| BODY["Десериализуем тело как JSON"] USE --> VALID["Валидация"] DI --> VALID CTX --> VALID ROUTE --> VALID CUSTOM --> VALID BODY --> VALID VALID -->|"ошибки"| P400["400 + ProblemDetails
со списком полей"] VALID -->|"ок"| CALL["Вызов обработчика"]
Полезные приёмы:
// 1. Группировка параметров запроса в один тип — вместо шести аргументов
public sealed record OrderQuery(
[FromQuery] string? Customer,
[FromQuery] DateOnly? From,
[FromQuery] int Page = 1,
[FromQuery] int Size = 20);
// 2. Свой разбор простого типа: работает и в маршруте, и в query
public readonly record struct Sku(string Value)
{
public static bool TryParse(string? s, IFormatProvider? _, out Sku result)
{
result = new Sku(s ?? string.Empty);
return !string.IsNullOrWhiteSpace(s) && s.Length <= 32;
}
}
// 3. Полный контроль над связыванием сложного типа
public sealed record PagingHeader(int Limit)
{
public static ValueTask<PagingHeader?> BindAsync(HttpContext ctx)
{
var raw = ctx.Request.Headers["X-Limit"].FirstOrDefault();
return ValueTask.FromResult<PagingHeader?>(
new PagingHeader(int.TryParse(raw, out var v) ? Math.Clamp(v, 1, 500) : 50));
}
}
Валидация: [ApiController] в MVC автоматически возвращает 400 с деталями по
DataAnnotations. В Minimal API валидацию подключают фильтром — обычно поверх
FluentValidation:
public sealed class ValidationFilter<T>(IValidator<T> validator) : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext ctx,
EndpointFilterDelegate next)
{
var model = ctx.Arguments.OfType<T>().FirstOrDefault();
if (model is not null)
{
var result = await validator.ValidateAsync(model, ctx.HttpContext.RequestAborted);
if (!result.IsValid)
return Results.ValidationProblem(result.ToDictionary());
}
return await next(ctx);
}
}
Две ловушки связывания, о которых стоит знать:
- Over-posting. Если связывать запрос прямо в доменную сущность, клиент сможет прислать
поля, которые вы не собирались отдавать ему на откуп (
IsAdmin,Discount). Всегда отдельный DTO запроса — это не церемония, а граница доверия. - Культура. Разбор чисел и дат в связывании идёт по инвариантной культуре; строка
"12,5"не станет12.5. Не полагайтесь на локальные форматы в API — фиксируйте ISO-8601 для дат и точку для дробей.
Аутентификация и авторизация
Две разные вещи, которые постоянно путают: аутентификация отвечает «кто вы», авторизация — «можно ли вам сюда». В ASP.NET Core они реализованы как схемы и политики.
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = builder.Configuration["Auth:Authority"]; // издатель токенов
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidAudience = "orders-api",
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30) // по умолчанию целых 5 минут
};
});
builder.Services.AddAuthorization(options =>
{
// Политика на основе claim-ов
options.AddPolicy("orders:write", p => p.RequireClaim("scope", "orders.write"));
// Политика на основе требования с собственной логикой
options.AddPolicy("same-tenant", p => p.AddRequirements(new SameTenantRequirement()));
options.FallbackPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build(); // всё закрыто по умолчанию; открываем через [AllowAnonymous]
});
FallbackPolicy — важная практика: безопасный дефолт «закрыто», а публичные эндпоинты
помечаются явно. Забытый [Authorize] перестаёт быть дырой.
Для проверок, зависящих от самого объекта («можно редактировать только свой заказ»),
используют resource-based авторизацию через IAuthorizationService.AuthorizeAsync(user, resource, policy) — политика получает и пользователя, и загруженную сущность.
Протоколы за этим — OAuth 2.0, OpenID Connect, устройство и валидация JWT, ротация
ключей — подробно разобраны в треке безопасности:
аутентификация,
OAuth и OIDC,
JWT и токены,
модели авторизации. Здесь важно другое:
как эти протоколы выражаются в конвейере — схемой, claims в HttpContext.User и политиками.
Сквозные заботы
// CORS: браузерная политика доступа с другого origin
builder.Services.AddCors(o => o.AddPolicy("spa", p => p
.WithOrigins("https://app.example.com")
.WithMethods("GET", "POST")
.AllowCredentials())); // AllowAnyOrigin + AllowCredentials запрещено спецификацией
// Ограничение частоты запросов (.NET 7+), встроено — без внешних пакетов
builder.Services.AddRateLimiter(o =>
{
o.AddFixedWindowLimiter("api", opt =>
{
opt.PermitLimit = 100;
opt.Window = TimeSpan.FromMinutes(1);
opt.QueueLimit = 0; // лишнее сразу отбрасываем
});
o.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});
// Кэш ответов на стороне сервера
builder.Services.AddOutputCache(o =>
o.AddPolicy("short", b => b.Expire(TimeSpan.FromSeconds(30)).SetVaryByQuery("page")));
Стратегии кэширования (что кэшировать, как инвалидировать, где хранить) — тема отдельной статьи в треке производительности; здесь достаточно знать, что кэш вывода живёт в конвейере и должен стоять после авторизации, иначе вы отдадите чужой ответ.
Лимиты Kestrel
Значения по умолчанию рассчитаны на «средний» сервис; для публичного API их стоит осознанно ужесточить:
builder.WebHost.ConfigureKestrel(k =>
{
k.Limits.MaxRequestBodySize = 10 * 1024 * 1024; // 10 МБ вместо 30 по умолчанию
k.Limits.MaxConcurrentConnections = 10_000;
k.Limits.KeepAliveTimeout = TimeSpan.FromSeconds(120);
k.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(30); // защита от медленных заголовков
k.Limits.MinRequestBodyDataRate = new MinDataRate(240, TimeSpan.FromSeconds(5));
});
// Таймауты на уровне эндпоинтов (.NET 8)
builder.Services.AddRequestTimeouts(o =>
o.DefaultPolicy = new RequestTimeoutPolicy { Timeout = TimeSpan.FromSeconds(15) });
MinRequestBodyDataRate — прямая защита от атак вида Slowloris: клиент, который шлёт тело
по байту в секунду, будет отключён.
Диагностика конвейера
- Логи фреймворка. Категории
Microsoft.AspNetCore.Routing,Microsoft.AspNetCore.Authorization,Microsoft.AspNetCore.Hostingна уровнеDebugпрямо говорят, какой endpoint выбран и почему политика не прошла. Это первое, что стоит включить при «загадочном 403». - Встроенные метрики (
Microsoft.AspNetCore.Hosting,Microsoft.AspNetCore.Server.Kestrel) дают длительность запроса, коды ответов, число активных соединений — подключаются одной строкой в OpenTelemetry (см. наблюдаемость). HttpContext.RequestAborted— токен отмены запроса. Прокидывайте его вниз: если клиент отключился, незачем дочитывать базу.- Developer Exception Page — только в Development. В проде —
UseExceptionHandler. - Интеграционные тесты через
WebApplicationFactoryпроверяют конвейер целиком, включая порядок middleware (см. тестирование).
Типичные ошибки
UseAuthorizationдоUseRouting— политики не видят endpoint, всё либо открыто, либо не работает.UseCorsне на своём месте — браузер получает ошибку CORS на корректном запросе.- Scoped-зависимость в конструкторе middleware — captive dependency и падения под нагрузкой.
- Забытый
await next()— пустой 200 без объяснений. - Запись в ответ после
next()без проверкиHasStarted— исключение вместо ответа. - Связывание прямо в доменную сущность — over-posting.
- Отсутствие
FallbackPolicy— один забытый атрибут открывает эндпоинт всему интернету. - Дефолтный
ClockSkewв 5 минут — токен живёт дольше, чем вы думаете. - Нет лимитов на размер тела и скорость — дешёвый DoS.
- Стектрейс в теле ответа — подарок атакующему.
Итог
- Конвейер вложен, как луковица: код до
next— на входе, после — на выходе. UseRoutingтолько выбирает endpoint; выполняется он в самом конце, а между ними — аутентификация, авторизация, CORS, лимиты.- Порядок регистрации middleware — это семантика; большинство «мистических» багов веб-слоя живут именно здесь.
- Связывание собирает аргументы из маршрута, query, заголовков, тела и DI; DTO запроса — граница доверия.
- Аутентификация даёт личность, авторизация — права; безопасный дефолт задаётся
FallbackPolicy. - Лимиты Kestrel и rate limiter — часть функциональности, а не «настройки на потом».
Источники: документация ASP.NET Core, порядок middleware, разборы внутренностей у Andrew Lock (andrewlock.net) и Steve Gordon (stevejgordon.co.uk), исходники github.com/dotnet/aspnetcore.
Что дальше
Фоновая обработка и Generic Host — что
делать с работой, которая не помещается в HTTP-запрос: BackgroundService, очереди,
периодические задачи и корректная остановка сервиса.