Тонкости работы short-circuit routing в ASP.NET Core 8.0

В статье детально и с примерами рассказываю про short-circuit routing — новую фичу Minimal API в ASP.NET Core 8.0. Она позволяет игнорировать добавленные middleware при вызове отдельных endpoint-ов. Рассмотрим, как это работает, сравним методы и немного поговорим про то, как добавляются middleware в приложение на ASP.NET Core.

Не читайте эту статью, если вам нужно просто прикрутить short-circuit routing и не париться, как оно работает, — для этого достаточно документации и обзора от Andrew Lock. У меня же после них осталось больше вопросов, чем ответов, потому я залез по самые локти в код и разобрался. Если вам тоже интересно — добро пожаловать под кат.

Short-circuit routing. Если бы мы знали, что это такое

В ASP.NET Core 8.0 появилась новая фича — short-circuit routing. Это механизм, который позволяет проигнорировать часть middleware при выполнении определённых запросов. После прочтения статьи вы будете знать, как проигнорировать почти все middleware.

Когда это полезно. Например, у нас есть Web API с добавленными middleware и оно доступно в глобальной сети. На него приходит большое количество нецелевых запросов: веб-скрейпинг поисковиков, DoS-атаки, шаблонные запросы от ботов. Всё это нагружает нашу систему, а short-circuit routing обходит логику внутри подключённых middleware, снижает нагрузку на приложение и смежные системы: БД, логирование и т.п.

Конкретный пример, который используют в обзорах на short-circuit routing: поисковые системы запрашивают файл robots.txt. Они посылают GET-запрос вида http (s)://my.host.com/robots.txt. Такой вызов запускает выполнение программной логики всех подключённых middleware, включая кастомные. Это происходит, даже если в нашем приложении запрошенный API-ресурс отсутствует. Для подобных запросов хочется иметь инструмент, который может обходить подключённые middleware и вызывать логику конкретных endpoint-ов напрямую.

Сразу оговорюсь: эта задача появилась не вчера и решается сторонними средствами. Например, в том же nginx есть гибкая система настройки чёрных и белых списков для конкретных URL. Но разработчики ASP.NET Core считают, что такой механизм полезно иметь и на уровне приложения.

Приложение без short-circuit routing

Создаём приложение на .NET 8:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.UseStaticFiles();
app.MapGet("/foo", () => Task.CompletedTask);
app.Run();

Третья строчка добавляет StaticFilesMiddleware — здесь он нужен только, чтобы протестировать прохождение запроса. Вместо него можно выбрать другой middleware, например:  HttpLoggingMiddleware, CorsMiddleware, AuthorizationMiddleware или кастомный.

Чтобы фиксировать обращения к StaticFilesMiddleware, в appsettings.json добавляем логирование уровня Debug:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "Microsoft.AspNetCore.StaticFiles.StaticFileMiddleware": "Debug"
    }
  },
  "AllowedHosts": "*"
}

Запускаем приложение и отправляем в него любой запрос, например: */foo, */bar или */robots.txt. Независимо от того, существует ли API-ресурс с соответствующим маршрутом, мы видим в логе запись dbug: Microsoft.AspNetCore.StaticFiles.StaticFileMiddleware[n]. Это подтверждает, что запрос проходит через StaticFileMiddleware.

Метод ShortCircuit

Теперь добавляем в приложение немного уличной магии short-circuit routing endpoint по маршруту /bar:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.UseStaticFiles();
app.MapGet("/foo", () => Task.CompletedTask);
app.MapGet("/bar", () => Task.CompletedTask).ShortCircuit();
app.Run();

Запускаем приложение и отправляем запрос по пути */bar. В логе не видим записей от StaticFileMiddleware — это подтверждает, что он не был вызван. Следовательно, чтобы обходить middleware при запросе, достаточно лишь 2 раза в день использовать простой советский добавить к endpoint-у .ShortCircuit() — метод расширения для EndpointConventionBuilder.

Если посмотреть под капот, заметим, что к метадате endpoint-а добавлен объект ShortCircuitMetadata. Его наличие и позволяет приложению на уровне EndpointRoutingMiddleware определить, для каких endpoint-ов нужно пропускать остальные middleware. Подробнее об этом я рассказываю ниже.

Сигнатура метода .ShortCircuit() имеет один необязательный параметр statusCode типа nullable int, который принимает код ответа. Значение этого параметра сохраняется в ShortCircuitMetadata, но учитывается оно только если в логике endpoint-а код ответа не возвращается в явном виде:

app.MapGet("/foo", () => Task.CompletedTask).ShortCircuit(400); //Вернёт 400
app.MapGet("/bar", () => Results.Ok()).ShortCircuit(400); //Вернёт 200

Почему значение statusCode учитывается или нет, понятно из исходного кода EndpointRoutingMiddleware:

if (shortCircuitMetadata.StatusCode.HasValue)
{
    httpContext.Response.StatusCode = shortCircuitMetadata.StatusCode.Value;
}

if (endpoint.RequestDelegate is not null)
{
    //Выполнение логики Endpoint-а
}

Сначала в Response.StatusCode устанавливается код ответа из ShortCircuitMetadata, если он был указан. Затем выполняется логика endpoint-а, где возвращаемый код ответа может быть заменён другим значением.

Вызов .ShortCircuit() никак не влияет на обработку информации о методе со стороны OpenAPI. То есть если в приложении используется Swagger с настройками из коробки, на странице Swagger-а видим оба метода из примера выше. У каждого в описании будет только один возвращаемый код ответа — 200. Для пути */foo это не соответствует реально возвращаемому коду ответа, но легко решается добавлением .Produces(400).

app.MapGet("/foo", () => Task.CompletedTask).Produces(400).ShortCircuit(400);

У .ShortCircuit() есть ограничения: попытка использовать его вместе с определёнными атрибутами и методами выдаст исключение InvalidOperationException при вызове. Дело в том, что наличие этих атрибутов и методов добавляет к метадате endpoint-а объекты, которые используются в соответствующих middleware. А short-circuit routing предполагает, что middleware не будут вызваны. В таблице перечислены атрибуты и методы, которые конфликтуют с использованием .ShortCircuit().

Атрибуты и методы

Добавляемый объект

Где используется

[AuthorizeAttribute]

IAuthorizeData

AuthorizationMiddleware

.RequireAuthorization ()

[EnableCorsAttribute]

ICorsMetadata

CorsMiddleware

.RequireCors ()

[FromFormAttribute] без .DisableAntiforgery () для запросов POST, PUT и PATCH

IAntiforgeryMetadata

AntiforgeryMiddleware

Для закрепления, ещё раз разберём пример, который уже успел стать каноничным. В приложение без API-ресурса robots.txt добавлен short-circuit routing endpoint:

app.MapGet("/robots.txt", () => Task.CompletedTask).ShortCircuit(404);

Практическая польза от такого добавления в том, что теперь запрос по пути */robots.txt не вызывает добавленные middleware и нагрузка на приложение снижается.

Метод MapShortCircuit

Ещё один способ создавать short-circuit routing — вызвать у WebApplication метод .MapShortCircuit(). Этот метод расширения для IEndpointRouteBuilder создаёт пустые endpoint-ы, которые не привязаны к типу запроса и не выполняют никаких собственных инструкций при вызове.

Сигнатура метода .MapShortCircuit() имеет два обязательных параметра:

  1. statusCode типа int — возвращаемый код ответа;

  2. routePrefixes типа params string[] — cписок префиксов маршрутов.

Разберёмся, почему это не маршруты, а префиксы маршрутов. В исходном коде видно, что каждый routePrefix преобразуется в конструкцию routePrefix/{**catchall}. Шаблон /{**catchall} гарантирует, что маршрут будет валиден для всех путей запросов, начинающихся с переданного префикса.

Все endpoint-ы, созданные через .MapShortCircuit(), при запросе будут выбираться в последнюю очередь, если не найден другой endpoint. Очерёдность выбора endpoint-а зависит от значения свойства Order, а в случае с .MapShortCircuit() значение выставляется равным int.MaxValue.

Рассмотрим, как работает сопоставление маршрутов и путей запроса:

app.MapShortCircuit(400, "foo");
app.MapGet("/foo/bar", () => Task.CompletedTask);

В данном примере short-circuit routing работает для запросов */foo, */foo/baz и */foo/bar/baz — возвращает код ответа 400. При этом он не работает для запроса */foobar, так как путь не соответствует маске — в ответе видим 404. Не работает short-circuit и для GET запроса */foo/bar, так как первым будет выбран endpoint с полностью совпавшим маршрутом — в ответе видим код 200.

Логика с выставлением свойства Order = int.MaxValue натолкнула меня на интересную мысль: получается, что добавление вызова .MapShortCircuit(404, "/") в программу, создаёт некий белый список. Теперь запросы по всем путям, для которых не найдено endpoint-ов, пропускают вызовы middleware. При этом вызывающая сторона видит ожидаемый код ответа 404.

Сравнение методов ShortCircuit и MapShortCircuit

Теперь сравним работу .MapShortCircuit() и .ShortCircuit():

app.MapGet("/foo", () => Task.CompletedTask).ShortCircuit(400); //Вернёт 400
app.MapGet("/bar", () => Results.Ok()).ShortCircuit(400); //Вернёт 200
app.MapGet("/baz", () => TypedResults.Ok()).ShortCircuit(400); //Вернёт 200
app.MapShortCircuit(400, "foo"); //Вернёт 400
app.MapShortCircuit(200, "bar", "baz"); //Вернёт 200

Может показаться, что эти примеры идентичны: мы получаем одинаковые результаты на GET-запросы */foo, */bar и */baz — коды ответов 400, 200 и 200 соответственно. На самом деле методы .MapShortCircuit() и .ShortCircuit() принципиально отличаются:

ShortCircuit

MapShortCircuit

Добавляем к существующему endpoint-у, который может иметь параметры, собственную логику, разные ответы, иметь OpenAPI-описание и работать с фильтрами.

Создаёт заглушки с указанными маршрутами и кодом ответа, но без собственной логики. При вызове возвращается ответ с заданным кодом и пустым телом.

Не меняет тип запроса endpoint-а, к которому добавлен. Может использоваться для endpoint-ов без указания типа запроса, добавленных через .Map().

Работает для любого типа запроса при совпадении пути. Создаёт endpoint-ы по заданным маршрутам через .Map() без указания типа запроса и вызывает для них .ShortCircuit().

Не обязан устанавливать код ответа: он может быть возвращен логикой endpoint-а.

Обязан устанавливать код ответа — общий для всего списка передаваемых префиксов маршрутов.

Не меняет маршрут endpoint-а.

Подставляет значения префиксов маршрутов в шаблон routePrefix/{**catchall}.

Не меняет значение свойства Order у endpoint-а.

Устанавливает endpoint-ам свойство Order = int.MaxValue.

Не влияет на отображение в Swagger-е.

Endpoint-ы не отображаются в Swagger-е из коробки.

Фантастические middleware и где они обитают

Возвращаемся к статье Andrew Lock и его схеме, которая показывает очерёдность прохождения http-вызова через добавленные middleware.

Расположение middleware из обзора от Andrew Lock

Расположение middleware из обзора от Andrew Lock

Глядя на такую схему, можно подумать, что StaticFileMiddleware всегда располагается перед EndpointRoutingMiddleware, но в реальном приложении чаще всего будет не так. В обзоре ничего нет о том, что такой порядок не прибит гвоздями, может меняться и, скорее всего, будет иным.

Можно предположить, что расположение StaticFileMiddleware, EndpointRoutingMiddleware и CorsMiddleware автор взял из официальной документации:

Расположение middleware из официальной документации

Расположение middleware из официальной документации

Но в официальной документации есть несколько важных комментариев к схеме:

You have full control over how to reorder existing middlewares or inject new custom middlewares as necessary for your scenarios.

The Routing middleware in the preceding diagram is shown following Static Files. This is the order that the project templates implement by explicitly calling app.UseRouting. If you don’t call app.UseRouting, the Routing middleware runs at the beginning of the pipeline by default. 

Здесь прямо сказано, что такой порядок — лишь пример. Реальный порядок расположения middleware устанавливает разработчик приложения, и EndpointRoutingMiddleware будет в самом начале, если в приложении отсутствует app.UseRouting.

Это важно, так как внутри EndpointRoutingMiddleware проверяется наличие ShortCircuitMetadata у endpoint-a. И, если ShortCircuitMetadata наличествует, то никакие другие middleware для текущего http-запроса не вызываются. Разбираем на примере приложения с добавленными HttpLoggingMiddleware, EndpointRoutingMiddleware и StaticFileMiddleware:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpLogging(_ => { });
var app = builder.Build();

app.UseHttpLogging();
app.UseRouting();
app.UseStaticFiles();

app.MapGet("/foo", () => Task.CompletedTask);
app.MapGet("/bar", () => Task.CompletedTask).ShortCircuit();
app.Run();

Чтобы увидеть в логе очерёдность прохождения вызова через разные middleware, добавим в appsettings.json соответствующие уровни логирования:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware": "Information",
      "Microsoft.AspNetCore.Routing.EndpointRoutingMiddleware": "Debug",
      "Microsoft.AspNetCore.StaticFiles.StaticFileMiddleware": "Debug"
    }
  },
  "AllowedHosts": "*"
}

Запускаем приложение и делаем запросы */foo и */bar. В логе видим, что для */foo вызываются все middleware в том же порядке, в каком они добавлены в код. При вызове */bar видим, что записи от StaticFileMiddleware в логе нет — значит, на уровне EndpointRoutingMiddleware сработал short-circuit routing и до выполнения StaticFileMiddleware дело не дошло.

Запись от HttpLoggingMiddleware видим дважды: до вызова endpoint-а логируется запрос, после — ответ. Так происходит потому, что после вызова endpoint-а все middleware вызываются повторно в обратном порядке. И для short-circuit routing будут повторно вызваны все middleware, находящиеся до EndpointRoutingMiddleware.

Если из кода примера убрать app.UseRouting(), запустить приложение и сделать запрос */bar, в логе увидим только записи от EndpointRoutingMiddleware, потому что теперь он стоит первым и прерывает вызов остальных middleware.

Итог

Моё мнение относительно short-circuit routing в ASP.NET Core 8.0 — фича интересная и уникальная, но имеет неочевидные нюансы. Надеюсь, мне удалось помочь вам разобраться в деталях и понять, что происходит под капотом.

Мем я на месте в кофейне

Мем я на месте в кофейне

Общее же мнение такое: Minimal API с момента появления в .NET 6 активно развивается и обрастает новым функционалом, а в чём-то уже превосходит по возможностям контроллеры. Надеюсь увидеть развитие технологии в следующих версиях .NET.

Благодарю за прочтение!

© Habrahabr.ru