C# / .NET ASP.NET Core изнутри: конвейер middleware, маршрутизация, связывание и доступ
0%

ASP.NET Core изнутри: конвейер middleware, маршрутизация, связывание и доступ

ASP.NET Core изнутри: путь запроса

В статье про архитектуру мы выбирали между Minimal API и контроллерами, но обошли главное: что вообще происходит с запросом между моментом, когда байты пришли в сокет, и моментом, когда вызвался ваш метод. Между ними — десяток шагов, и почти каждый инцидент вида «CORS не работает», «токен есть, а 401», «валидация не сработала» объясняется неправильным порядком именно на этих шагах.

Разберём конвейер так, чтобы вы могли отладить его в голове, не запуская отладчик.

Общая картина

Ключевое, что нужно унести из этой схемы: маршрут выбирается раньше, чем выполняется авторизация. Именно поэтому 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.

Вложенность конвейера middleware в ASP.NET Core: запрос идёт внутрь, ответ возвращается наружу

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

Три правила, за нарушение которых платят временем отладки:

  1. Всегда вызывайте await next(context), если не обрываете конвейер намеренно. Забыли — запрос молча возвращает 200 с пустым телом.
  2. После await next() заголовки уже могут быть отправлены. Проверяйте context.Response.HasStarted перед попыткой что-то дописать, иначе получите InvalidOperationException вместо ответа.
  3. Порядок — это семантика, а не стиль. 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) собирает аргументы вашего метода из частей запроса.

Полезные приёмы:

// 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 (см. тестирование).

Типичные ошибки

  1. UseAuthorization до UseRouting — политики не видят endpoint, всё либо открыто, либо не работает.
  2. UseCors не на своём месте — браузер получает ошибку CORS на корректном запросе.
  3. Scoped-зависимость в конструкторе middleware — captive dependency и падения под нагрузкой.
  4. Забытый await next() — пустой 200 без объяснений.
  5. Запись в ответ после next() без проверки HasStarted — исключение вместо ответа.
  6. Связывание прямо в доменную сущность — over-posting.
  7. Отсутствие FallbackPolicy — один забытый атрибут открывает эндпоинт всему интернету.
  8. Дефолтный ClockSkew в 5 минут — токен живёт дольше, чем вы думаете.
  9. Нет лимитов на размер тела и скорость — дешёвый DoS.
  10. Стектрейс в теле ответа — подарок атакующему.

Итог

  • Конвейер вложен, как луковица: код до 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, очереди, периодические задачи и корректная остановка сервиса.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов