Первый взгляд за кулисы эндпоинтов minimal API

Данная статья, является первой из цикла статей от Эндрю Лока "Behind the scenes of minimal APIs".

Minimal API имеют репутацию быстрых, но как они этого добиваются? В этом и последующих постах я рассмотрю код, стоящий за некоторыми методами minimal API, и посмотрю, как они используются для того, чтобы взять ваш лямбда-метод и превратить его в нечто быстро выполняющееся.

В этом посте я рассматриваю код относительно высокого уровня, стоящий за вызовом MapGet("/", () => "Hello World!"), чтобы узнать основы того, как этот лямбда-метод преобразуется в RequestDelegate, который может быть выполнен ASP.NET Core.

Весь код в этой заметке основан на выпуске .NET 7.0.1.

Minimal Api на минимальном хосте.

Минимальные API были введены в .NET 6 вместе с "минимальным хостом", чтобы обеспечить более простой процесс адаптации к .NET. Вместе они устраняют множество телодвижений, связанных с приложением ASP.NET Core на базе общего хоста с использованием MVC. Базовое приложение hello world в MVC может состоять из 50–100 строк кода, распределенных как минимум по 3 классам. С минимальными API это так же просто, как:

WebApplicationBuilder builder = new WebApplication.CreateBuilder(args);
WebApplication app = builder.Build();

app.MapGet("/", () => "Привет мир!");

app.Run();
Пример минимального API на .Net 7

Очевидно, что в этих четырех строках происходит многое, от чего вы абстрагировались. У вас все еще есть практически те же возможности, что и у общего хоста, но большая их часть скрыта от вас.

В этой заметке я не буду рассматривать WebApplication, вместо этого мы рассмотрим, как лямбда-метод () => "Hello world!" превращается в RequestDelegate, который можно использовать для обработки HTTP-запросов.

Что такое RequestDelegate?

Прежде чем двигаться дальше, мы должны ответить на вопрос: "Что такое RequestDelegate"? Согласно документации, RequestDelegate — это:

Функция, которая может обрабатывать HTTP-запрос.

Что является довольно обобщенным объяснением. Но если вы посмотрите на определение делегата, то увидите, что оно описывает его довольно хорошо:

public delegate Task RequestDelegate(HttpContext context);

Итак, RequestDelegate — это делегат, который принимает HttpContext и возвращает Task.

Делегат можно рассматривать как "именованный" Func<T>. Они во многом похожи, хотя у них также есть странная семантика коллекций (которую я не буду здесь рассматривать).

Если вы знакомы с ASP.NET Core, вы также можете найти схожесть с сигнатурой метода Invoke для middleware:

public class MyMiddleware
{
    private readonly RequestDelegate _next;
    public MyMiddleware(RequestDelegate next)
        => _next = next;

    public Task Invoke(HttpContext context)
    {
        // Что нибудь делаем...
        return _next(context);
    }
}
метод Invoke

Интересным свойством RequestDelegate, которое можно увидеть из приведенного выше примера, является возможность цепочки вызовов. Так, Invoke может быть приведен к RequestDelegate и, в свою очередь, может вызвать RequestDelegate, который сам может вызвать RequestDelegate. Это создает "конвейер middleware" для вашего приложения.

Итак, если мы рассмотрим минимальный endpoint API, такой как

app.MapGet("/", () => "Привет мир!");

нам нужно взять произвольный лямбда-метод, предоставленный в качестве обработчика endpoint`а (который не является RequestDelegate, поскольку не имеет правильной сигнатуры), и создать RequestDelegate. В этом посте мы рассмотрим процесс на высоком уровне, а в следующем начнем разбираться с тонкостями создания RequestDelegate.

От MapGet до RouteEntry

Когда вы вызываете MapGet() в WebApplication (или MapPost(), или MapFallback(), или большинство других расширений Map*()), вы вызываете метод расширения IEndpointRouteBuilder. В конце концов, если вы проследите за перегрузками достаточно далеко вниз, вы достигнете частного метода Map(), который выглядит примерно так:

private static RouteHandlerBuilder Map(
    this IEndpointRouteBuilder endpoints,
    RoutePattern pattern,
    Delegate handler,
    IEnumerable<string>? httpMethods,
    bool isFallback)
{
    RouteEndpointDataSource dataSource = endpoints.GetOrAddRouteEndpointDataSource();
    return dataSource.AddRouteHandler(pattern, handler, httpMethods, isFallback);
}

Аргумент Delegate handler — это лямбда-метод обработчика, который вы передаете при маппинге метода. Обратите внимание, что на данном этапе это очень общий тип Delegate, позже нам нужно будет извлечь параметры из делегата и решить, как их построить, привязав запрос или используя сервисы из DI.

Этот метод сначала извлекает RouteEndpointDataSource из конструктора, который, по сути, содержит список маршрутов (роутов) в приложении. Мы добавляем маршрут в коллекцию, вызывая для него AddRouteHandler(). Это создает объект RouteEntry (по сути, просто пакет свойств) и добавляет его в коллекцию. Он возвращает экземпляр RouteHandlerBuilder, который позволяет вам настроить endpoint, добавив к ней, к примеру, метаданные и фильтры.

Вот, собственно, и все, что происходит при вызове MapGet, он добавляет маршрут в коллекцию endpoint`ов. Все становится интересным, когда вы вызываете app.Run() и обрабатываете запрос.

Создание эндпоинтов

Когда вы делаете свой первый запрос к ASP.NET Core, где запрос попадает в EndpointRoutingMiddleware (добавленный неявно или вызовом UseRouting()), middleware запускает построение всех ваших эндпоинтов (и графа маршрутов, которым они соответствуют).

Для каждого RouteEntry в RouteEndpointDataSource(ах) вашего приложения, оно вызывает CreateRouteEndpointBuilder(), который возвращает экземпляр RouteEndpointBuilder. Он содержит все общие метаданные об эндпоинте, такие как отображаемое имя, а также метаданные OpenAPI, метаданные авторизации и фактический RequestDelegate, который выполняется в ответ на запрос.

Следующий фрагмент показывает, как строится отображаемое имя для эндпоинта, сначала пытаясь получить для нее "разумное" имя, такое как имя конкретного метода или имя локальной функции. Если это не удается (как в нашем простом () => "Hello World!"), вы получите имя, которое описывает только шаблон HTTP:  HTTP: GET /.

var displayName = pattern.RawText ?? pattern.DebuggerToString();

// Не включайте имя метода для немаршрутных обработчиков, потому что при сборке имя будет просто "Invoke".
// ApplicationBuilder.Build(). Это было замечено в MapSignalRTests и не очень полезно. Возможно, если мы придумаем
// лучшую эвристику для определения полезного имени метода, мы сможем использовать его для всего. Inline lambdas - это
// генерируемые компилятором методы, поэтому они отфильтровываются даже для обработчиков маршрутов.
if (isRouteHandler && TypeHelper.TryGetNonCompilerGeneratedMethodName(handler.Method, out var methodName))
{
    displayName = $"{displayName} => {methodName}";
}

if (entry.HttpMethods is not null)
{
    // Добавляет метод HTTP к DisplayName, полученному с помощью шаблона + имя метода
    displayName = $"HTTP: {string.Join(", ", entry.HttpMethods)} {displayName}";
}

if (isFallback)
{
    displayName = $"Fallback {displayName}";
}

Следующий метод создает метаданные для эндпоинта. Я не собираюсь подробно рассматривать этот процесс в этом посте, но "добавление метаданных" включает в себя добавление различных объектов в список

Чтобы создать коллекцию метаданных, CreateRouteEndpointBuilder() добавляет в коллекцию метаданных следующее в таком порядке:

  • MethodInfo обработчика для выполнения.
  • Объект HttpMethodMetadata, описывающий HTTP-глаголы, на которые отвечает обработчик.
  • Для каждой route-group конвенции, применяет соглашение к конструктору, который может добавлять метаданные.
  • Читает метод и выводит все его типы параметров и их источники, кэшируя и сохраняя их как RequestDelegateMetadataResult, использует RequestDelegateFactory.
  • Это большой шаг, где происходит основная магия. Я собираюсь подробно рассмотреть этот процесс в отдельном посте.
  • Добывляет любые атрибуты, примененные к методу, в качестве метаданных (такие как [Authorize] и подобные).
  • Для каждой конвенции эндпоинта, применяет соглашение к конструктору, который может добавлять метаданные.
  • Использует ранее построенный RequestDelegateMetadataResult для построения RequestDelegate для эндпоинта.
  • Для каждой "финальной" route-group конвенции, применяет соглашение к конструктору, который может добавлять метаданные.

Все эти шаги немного расплывчаты, но на двух наиболее важных этапах создаются RequestDelegateMetadataResult и RequestDelegate. Мы рассмотрим эти шаги более подробно позже в этом и последующих постах.

После того, как RouteEndpointBuilder получит RequestDelegate и метаданные будут полностью заполнены, RouteEndpointDataSource вызывает RouteEndpointBuilder.Build(). Это завершает эндпоинт как RouteEndpoint. Как только все конечные точки построены, DfaMatcher может сделать свое дело, чтобы построить полный граф эндпоинтов, используемых в маршрутизации, который может выглядеть примерно так:

После создания DfaMatcher EndpointRoutingMiddleware может правильно выбрать конечную точку для маршрутизации и RequestDelegate, который должен быть выполнен.

Построение RequestDelegateFactoryOptions с помощью ShouldDisableInferredBodyParameters

Ранее я упоминал, что вывод метаданных об эндпоинте и создание RequestDelegate являются важными частями процесса создания эндпоинта. Первым шагом для них является создание объекта RequestDelegateFactoryOptions. Это делается в методе CreateRDFOptions(), показанном ниже.

private RequestDelegateFactoryOptions CreateRDFOptions(
    RouteEntry entry, RoutePattern pattern, RouteEndpointBuilder builder)
{
    var routeParamNames = new List<string>(pattern.Parameters.Count);
    foreach (var parameter in pattern.Parameters)
    {
        routeParamNames.Add(parameter.Name);
    }

    return new()
    {
        ServiceProvider = _applicationServices,
        RouteParameterNames = routeParamNames,
        ThrowOnBadRequest = _throwOnBadRequest,
        DisableInferBodyFromParameters = ShouldDisableInferredBodyParameters(entry.HttpMethods),
        EndpointBuilder = builder,
    };
}

Первым шагом метода является чтение всех имен параметров маршрута из шаблона маршрута для эндпоинта. Таким образом, для маршрута, такого как /users/{id}, это будет список, содержащий только идентификатор строки.

RoutePatternParser отвечает за получение строки типа /users/{id} и преобразование ее в объект RoutePattern со всеми правильно идентифицированными сегментами, параметрами, ограничениями и значениями по умолчанию. Там только 600 строк кода, поэтому я пропустил этот процесс в этом посте!

Помимо хранения имен параметров маршрута, объект options содержит некоторые другие вспомогательные функции и настройки. Поле _applicationServices — это контейнер DI (IServiceProvider) для приложения, а поле _throwOnBadRequest задается свойством RouteHandlerOptions.ThrowOnBadRequest (которое по умолчанию имеет значение true при работе в dev).

Интересная часть объекта CreateRDFOptions — вызов ShouldDisableInferredBodyParameters. Этот метод вычисляет, как следует обрабатывать параметр сложного объекта в вашем эндпоинте. Например, если у вас есть эндпоинт MapPost, который выглядит примерно так:

app.MapPost("/users", (UserModel user) => {});

тогда minimal API попытается связать объект UserModel с телом запроса. Однако, если у вас есть тот же делегат с запросом MapGet:

app.MapGet("/users", (UserModel user) => {});

тогда API попытается рассматривать UserModel как службу в DI, и, если она недоступна (что, по-видимому, не будет!) вызовет исключение InvalidOperationException.

Должна ли сложная модель пытаться выполнить привязку к телу по умолчанию или нет, управляется свойством RequestDelegateFactoryOptions.DisableInferBodyFromParameters, которое задается с помощью метода ShouldDisableInferredBodyParameters(), показанного ниже.

private static bool ShouldDisableInferredBodyParameters(IEnumerable<string>? httpMethods)
{
    static bool ShouldDisableInferredBodyForMethod(string method) =>
        // GET, DELETE, HEAD, CONNECT, TRACE, and OPTIONS normally do not contain bodies
        method.Equals(HttpMethods.Get, StringComparison.Ordinal) ||
        method.Equals(HttpMethods.Delete, StringComparison.Ordinal) ||
        method.Equals(HttpMethods.Head, StringComparison.Ordinal) ||
        method.Equals(HttpMethods.Options, StringComparison.Ordinal) ||
        method.Equals(HttpMethods.Trace, StringComparison.Ordinal) ||
        method.Equals(HttpMethods.Connect, StringComparison.Ordinal);

    // If the endpoint accepts any kind of request, we should still infer parameters can come from the body.
    if (httpMethods is null)
    {
        return false;
    }

    foreach (var method in httpMethods)
    {
        if (ShouldDisableInferredBodyForMethod(method))
        {
            // If the route handler was mapped explicitly to handle an HTTP method that does not normally have a request body,
            // we assume any invocation of the handler will not have a request body no matter what other HTTP methods it may support.
            return true;
        }
    }

    return false;
}

Логику этого метода можно резюмировать следующим образом:

  • Принимает ли эндпоинт все глаголы HTTP? Если это так, включает предполагаемую привязку к телу запроса.
  • Принимает ли конечная точка какие-либо из следующих команд: GET, DELETE, HEAD, CONNECT, TRACE, OPTIONS. Если это так, отключает предполагаемую привязку к телу запроса.
  • В противном случае включает предполагаемую привязку.

В большинстве случаев, например, при использовании MapGet или MapPost, ваши эндпоинты обрабатывают только один HTTP-метод, поэтому достаточно просто отключить привязку к телу.

Обратите внимание, что это определяет только предполагаемую привязку по умолчанию к телу запроса. Вы всегда можете переопределить его с помощью [FromBody] и принудительно выполнить такие действия, как привязка к телу запроса GET.

В следующем посте мы рассмотрим тип RequestDelegateFactory и узнаем, как работает привязка модели в минимальных API и как создается окончательный RequestDelegate.

Подводя итог


В этом посте я представил высокоуровневое описание того, как вызов MapGet() в WebApplication приводит к построению RequestDelegate для обработки запроса. Вызов MapGet() добавляет объект RouteEntry в RouteEndpointDataSource, но построение эндпоинта не происходит до тех пор, пока хост не будет запущен и вы не начнете обрабатывать запросы.

Когда middleware маршрутизации получает свой первый запрос, оно вызывает CreateRouteEndpointBuilder() для каждого RouteEntry. Это создает список всех метаданных, добавленных к эндпоинту (или к любой группе маршрутов, частью которой является эндпоинт), создает отображаемое имя для эндпоинта и создает RequestDelegate. Эти данные используются для построения графа DFA для маршрутизации эндпоинтов.

В этом посте я также начал подробно рассматривать процесс построения RequestDelegate, показывая, как попытка эндпоинта привязать сложный параметр к телу запроса контролируется поддерживаемыми HTTP-глаголами. В следующем посте мы возьмем эти детали и посмотрим, как они используются для создания метаданных о эндпоинте, а также окончательного RequestDelegate.

Оригинал статьи вы можете прочитать по ссылке.