Start Debugging

Фильтры endpoint против middleware в ASP.NET Core 11: что выбрать?

Руководство по выбору для ASP.NET Core 11: middleware выполняется для каждого запроса до того, как ваш handler привяжет данные, фильтры endpoint выполняются только для совпавшей конечной точки, после привязки, и могут видеть типизированные аргументы. Включает сравнительную таблицу, сценарии выбора каждого варианта, правила порядка и детали, которые вынуждают выбор.

Используйте middleware, когда логика должна выполняться для каждого запроса, до или независимо от того, какая конечная точка совпадает: обработка исключений, CORS, аутентификация, сжатие ответов, статические файлы, перенаправленные заголовки. Используйте фильтр endpoint, когда логике нужны привязанные аргументы handler или она должна применяться только к некоторым конечным точкам: валидация ввода, нормализация аргументов, аудит по конечной точке. Самая точная проверка: если вашему коду нужна типизированная модель, которую handler вот-вот получит, ему нужен фильтр, потому что фильтр выполняется после привязки модели и может прочитать context.GetArgument<T>(index). Если он должен выполняться независимо от того, совпал ли маршрут, ему нужен middleware, потому что middleware выполняется до того, как маршрутизация разрешит конечную точку. Всё, что следует далее, — это детали, стоящие за этим выбором. Эта статья ориентирована на .NET 11 (Preview 6 на момент написания, GA в ноябре 2026 года) с Microsoft.NET.Sdk.Web и C# 14, но обе возможности стабильны начиная с ASP.NET Core 7, так что каждый пример здесь выполняется без изменений на .NET 8, 9 и 10.

Сравнительная таблица

Это та таблица, за которой вы пришли. Прочитайте её сверху вниз, и решение обычно приходит само.

ХарактеристикаФильтр endpointMiddleware
Выполняется длятолько совпавшей конечной точкикаждого запроса в этой ветви конвейера
Позиция относительно маршрутизациипосле маршрутизации и привязки моделидо, во время или после (по позиции)
Видит аргументы handlerда, типизированные через GetArgument<T>(index)нет, только сырой HttpContext
Может изменять привязанные аргументыда, context.Arguments изменяемнет, привязка ещё не произошла
Механизм короткого замыканиявернуть IResult вместо nextне вызывать next(context)
Управление областью действияна конечную точку или на MapGroupна приложение или на ветвь через Map/UseWhen
Регистрация.AddEndpointFilter(...)app.Use(...) / app.UseMiddleware<T>()
Тип возвращаемого значенияValueTask<object?>Task
Выполняется, когда конечная точка не совпаланикогдада, если размещён до выполнения конечной точки
Переиспользуем на контроллерах MVCда, также на конечных точках контроллерада, по всему конвейеру

Строки, которые действительно определяют выбор, — это первые три. Middleware находится в конвейере запросов, и каждый запрос, проходящий через этот сегмент, выполняет его, даже запрос, который даст 404, потому что ни одна конечная точка не совпала. Фильтр endpoint привязан к конкретному handler маршрута и выполняется только тогда, когда этот handler выбран, что происходит после того, как UseRouting сопоставил запрос, и после того, как фреймворк привязал значения маршрута, строку запроса и тело запроса к параметрам handler. Эта разница во времени — вся суть.

Что видит middleware, и когда

Middleware — это цепочка компонентов, каждый из которых получает HttpContext и делегат next. Вы регистрируете их в Program.cs по порядку, и порядок и есть поведение: запросы текут сверху вниз, ответы текут обратно снизу вверх.

// .NET 11, C# 14 -- Program.cs
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.Use(async (context, next) =>
{
    // Runs for EVERY request, including ones that will 404.
    var sw = System.Diagnostics.Stopwatch.StartNew();
    await next(context);
    sw.Stop();
    app.Logger.LogInformation(
        "{Method} {Path} -> {Status} in {Elapsed}ms",
        context.Request.Method, context.Request.Path,
        context.Response.StatusCode, sw.ElapsedMilliseconds);
});

app.MapGet("/hello/{name}", (string name) => $"Hi {name}");

app.Run();

Этот middleware для замера времени измеряет весь запрос, включая маршрутизацию и любой 404. У него есть доступ только к context.Request.Path как к строке. Он не может увидеть, что name привязался к "world", потому что в тот момент, когда выполняется внешний middleware, привязка ещё не произошла. Middleware работает на уровень ниже системы типов вашего handler.

Позиция относительно UseRouting важнее, чем ожидает большинство. В современной модели minimal hosting WebApplication вставляет маршрутизацию автоматически, но вы можете вызвать app.UseRouting() явно, чтобы контролировать, где происходит разделение. Middleware, зарегистрированный до маршрутизации, выполняется до того, как конечная точка вообще выбрана. Middleware, зарегистрированный после UseRouting, может прочитать метаданные выбранной конечной точки через context.GetEndpoint(), вот как UseAuthorization узнаёт, какую политику применять. Именно поэтому канонический порядок таков: UseRouting, затем UseAuthentication, затем UseAuthorization, а затем выполнение конечной точки: авторизации нужны метаданные конечной точки, которые произвела маршрутизация.

Что видит фильтр endpoint, и когда

Фильтр endpoint оборачивает вызов одного handler маршрута. Он выполняется после маршрутизации и после привязки, поэтому у него есть единственное, что middleware не может получить: фактические, типизированные аргументы, которые ваш handler вот-вот получит.

// .NET 11, C# 14
app.MapPost("/orders", (Order order) => Results.Created($"/orders/{order.Id}", order))
    .AddEndpointFilter(async (context, next) =>
    {
        // The Order is already bound. Middleware could never see this.
        var order = context.GetArgument<Order>(0);
        if (order.Quantity < 1)
        {
            return Results.Problem("Quantity must be at least 1.");
        }
        return await next(context);
    });

Тип возвращаемого значения фильтра — ValueTask<object?>. Возврат любого IResult (например, Results.Problem) вызывает короткое замыкание и записывает этот результат в ответ, так и не вызвав handler. Возврат await next(context) выполняет handler и передаёт его результат обратно по цепочке, так что фильтр может также трансформировать ответ на выходе. Поскольку фильтр видит привязанный Order, валидация естественно живёт здесь. Компоненту middleware, пытающемуся выполнить ту же работу, пришлось бы перечитать и заново десериализовать тело запроса самостоятельно, дублируя работу, которую фреймворк уже сделал. Полные механизмы AddEndpointFilter, форма на основе класса IEndpointFilter и порядок фильтров рассмотрены в статье как добавить фильтр endpoint в minimal API; эта статья о том, когда вообще выбрать его вместо middleware.

Когда выбирать middleware

Когда выбирать фильтр endpoint

Единственное место, где производительность действительно входит в решение

Заманчиво потянуться к фильтру “потому что middleware выполняется для всего, а это расточительно”. Сопротивляйтесь тому, чтобы преподносить это как состязание в пропускной способности. Обе возможности лёгкие: фильтр — это делегат, возвращающий ValueTask<object?>, а компонент middleware — делегат, возвращающий Task, и накладные расходы на вызов любого из них пренебрежимо малы рядом с любым реальным handler, который обращается к базе данных или сериализует JSON. Существенная разница не в стоимости вызова, а в том, сколько вызовов происходит. Компонент middleware, размещённый рано в конвейере, выполняется для каждого запроса, так что дорогая работа там (запрос к базе данных, крупное выделение памяти) оплачивается каждым 404 и каждым пингом health-check. Та же работа в фильтре endpoint выполняется только тогда, когда эта конечная точка выбрана. Так что правило производительности не “фильтры быстрее”, а “ограничивайте работу тем местом, где она нужна”. Если сквозная задача действительно применяется к каждому маршруту, middleware — правильный и не более медленный дом для неё. Если она применяется к горстке конечных точек, фильтр избегает её выполнения на тысячах запросов, которые никогда не коснутся этих конечных точек. Это решение об области действия, замаскированное под решение о производительности, и это честная версия утверждения.

Детали, которые выбирают за вас

Несколько жёстких ограничений полностью отменяют предпочтение.

Фильтр не может выполниться до маршрутизации, никогда. Если ваше требование — “отклонить запрос до того, как маршрутизатор его увидит” или “переписать URL”, фильтр физически на это не способен, потому что он живёт внутри выполнения конечной точки, которое следует за маршрутизацией. Это вынуждает middleware.

Middleware не может увидеть привязанную модель без повторного выполнения работы. Если ваше требование — “валидировать десериализованное тело запроса”, middleware пришлось бы буферизовать и десериализовать тело самостоятельно, а затем фреймворк десериализует его снова для handler. Эта двойная привязка — сильный сигнал того, что вам нужен был фильтр. Это вынуждает фильтр.

Исключения выходят за область действия фильтра. Фильтр оборачивает только свою конечную точку, так что он не может быть вашей страховочной сетью на уровне приложения. Если вы поместите единственную обработку исключений в фильтр, исключение, брошенное в другом middleware или во время маршрутизации, пройдёт мимо и попадёт в обработчик 500 по умолчанию. Глобальная обработка ошибок вынуждает middleware.

Модели порядка различаются, и их смешивание сбивает людей с толку. Middleware вкладывается по порядку регистрации в Program.cs. Фильтры вкладываются по порядку, в котором вы сцепляете вызовы .AddEndpointFilter: зарегистрированный первым выполняет свой код до next первым, а свой код после next последним. Когда вы наслаиваете оба, вся цепочка фильтров конечной точки выполняется внутри самой внутренней точки конвейера middleware, после того как UseRouting, UseAuthentication и UseAuthorization выполнились. Так что авторизация всегда выполняется до любого фильтра endpoint, что обычно и является желаемым, но это означает, что фильтр — неподходящее место для реализации схемы аутентификации. Аутентификация вынуждает middleware.

Терминальное поведение противоположно. Компонент middleware, который не вызывает next, вызывает короткое замыкание просто тем, что не продолжает. Фильтр вызывает короткое замыкание, возвращая IResult. Если вы пишете фильтр и забываете вернуть что-либо на пути короткого замыкания, вы получаете ошибку компиляции или null-результат вместо молча проглоченного запроса, что является небольшим, но реальным эргономическим преимуществом фильтров.

Рекомендация, сформулированная заново

По умолчанию так: сквозные задачи, которые должны выполняться для каждого запроса или до маршрутизации, — это middleware. Задачи, которым нужны типизированные аргументы handler или которые применяются к подмножеству конечных точек, — это фильтры endpoint. Аутентификация, CORS, обработка исключений, сжатие и статические файлы — это middleware, и так будет всегда. Валидация, нормализация аргументов, аудит по конечной точке и формирование результата — это фильтры endpoint. Пограничный случай — логика авторизации на конечную точку: если ей нужны только claims из HttpContext.User, подойдёт любой вариант, но предпочтите фильтр, чтобы политика жила рядом с конечной точкой, которую она защищает; если ей нужны привязанные аргументы для принятия решения (проверки доступа на уровне строки по привязанному id сущности), это должен быть фильтр. Когда вы действительно не можете решить, задайте единственный вопрос, который разрешает почти каждый случай: нужно ли этому коду видеть аргументы, которые получит мой handler? Да — значит фильтр. Нет, и он должен выполняться независимо от маршрута, — значит middleware.

Связанные материалы

Источники

Comments

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

< Назад