Фильтры 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.
Сравнительная таблица
Это та таблица, за которой вы пришли. Прочитайте её сверху вниз, и решение обычно приходит само.
| Характеристика | Фильтр endpoint | Middleware |
|---|---|---|
| Выполняется для | только совпавшей конечной точки | каждого запроса в этой ветви конвейера |
| Позиция относительно маршрутизации | после маршрутизации и привязки модели | до, во время или после (по позиции) |
| Видит аргументы 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
- Задача глобальна и не зависит от маршрута. Обработка исключений (
UseExceptionHandler), перенаправление HTTPS, HSTS, CORS, сжатие ответов, статические файлы и обработка перенаправленных заголовков должны выполняться для каждого запроса независимо от того, какая конечная точка (если вообще какая-то) совпадает. Фильтр не может выразить “выполняться для всего”, потому что фильтр привязан к конечным точкам, а у 404 нет конечной точки. Сжатие ответов, в частности, относится к конвейеру, как рассмотрено в статье добавление сжатия ответов в API на ASP.NET Core 11. - Вам нужно выполниться до маршрутизации. Переписать путь, убрать префикс или отклонить запрос до того, как маршрутизатор вообще на него посмотрит, — по своей природе задача middleware. Фильтры endpoint выполняются после того, как маршрут совпал, так что они приходят слишком поздно, чтобы повлиять на маршрутизацию.
- Вы перехватываете исключения по всему приложению.
UseExceptionHandlerи страницы исключений для разработчиков оборачивают весь нижележащий конвейер. Фильтр оборачивает только свою единственную конечную точку, поэтому исключение, брошенное во время маршрутизации или в другом middleware, никогда до него не доходит. Глобальная обработка ошибок — это задача конвейера, вот почему и настройка глобального фильтра исключений регистрируется на уровне приложения, а не на конечную точку. - Логика должна видеть запросы, которые дадут 404. Метрики, логирование запросов и ограничение частоты часто нуждаются в подсчёте или троттлинге запросов, которые никогда не совпадают с конечной точкой. Middleware видит их; фильтры — нет.
Когда выбирать фильтр endpoint
- Вам нужны привязанные аргументы. Валидация
Product, проверка того, что параметр запросаpageнаходится в диапазоне, или нормализация строки — всё это требует типизированного значения.context.GetArgument<T>(index)и изменяемый списокcontext.Argumentsдают вам именно это, и в middleware эквивалента нет. - Задача применяется к некоторым конечным точкам, а не ко всем. Фильтр присоединяется к одной конечной точке или, через
MapGroup, к их группе. Если ваша валидация имеет смысл только дляPOST /productsиPUT /products/{id}, групповой фильтр ограничивает её точно, не загрязняя глобальный конвейер. Это сочетается с модулями по ресурсам, описанными в статье организация конечных точек minimal API с помощью MapGroup. - Вы хотите проверить или переписать результат handler. Поскольку возвращаемое значение фильтра течёт обратно по цепочке, он может обернуть успешный результат в оболочку, добавить подсказки кеширования или перевести доменный результат в
IResult. Middleware может манипулировать только сырым потоком ответа, что гораздо более неуклюже после того, как handler начал запись. - Вы хотите одну и ту же логику в minimal API и контроллерах.
AddEndpointFilterтакже работает на построителе соглашений конечных точек контроллера, так что один делегат фильтра может защищать как minimal-конечную точку, так и действие MVC, разделяющие маршрут.
Единственное место, где производительность действительно входит в решение
Заманчиво потянуться к фильтру “потому что 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.
Связанные материалы
- Как добавить фильтр endpoint в minimal API в ASP.NET Core 11
- Как организовать конечные точки minimal API с помощью MapGroup в ASP.NET Core 11
- Как добавить глобальный фильтр исключений в ASP.NET Core 11
- Как добавить сжатие ответов в API на ASP.NET Core 11
- Minimal API против контроллеров в ASP.NET Core 11
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.