Извлечение метаданных из обработчика Minimal API

Извлечение метаданных из обработчика Minimal API

Эта вторая статья из серии "За кулисами Minimal API" Эндрю Лока. В предыдущей статье мы достаточно подробно рассмотрели, как простой minimal API MapGet("/", () => "Hello world", превращается в RequestDelegate, который может вызываться EndpointMiddleware. В этой статье я сосредоточусь на методе RequestDelegateFactory.InferMetadata() и разберу, как он работает.

Получение метаданных от делегата

В предыдущей статье основное внимание уделялось методу CreateRouteEndpointBuilder(). Этот метод вызывается, когда приложение впервые получает запрос, и отвечает за преобразование RouteEntry и RoutePattern, связанных с эндпоинтом, в RouteEndpointBuilder. Затем всё это используется для создания RouteEndpoint, который можно вызывать для обработки данного запроса.

CreateRouteEndpointBuilder() отвечает как за составление списка метаданных для эндпоинта, так и за создание его RequestDelegate. Напомним, что RequestDelegate — это функция, которая фактически вызывается для обработки запроса и выглядит следующим образом:

public delegate Task RequestDelegate(HttpContext context);

Обратите внимание, что эта сигнатура не соответствует лямбда делегату, который мы передали в MapGet, в примере Hello World - () => «Hello world!» имеет сигнатуру, которая выглядит следующим образом:

public string SomeMethod();

CreateRouteEndpointBuilder() в сочетании с классом RequestDelegateFactory отвечает за создание функции, которая соответствует сигнатуре RequestDelegate на основе предоставленного делегата.

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

Рассмотрим немного более сложный пример, который внедряет объект HttpRequest в обработчик: MapGet("/", (HttpRequest request) => "Hello world!"). RequestDelegateFactory должен создать метод с требуемой сигнатурой RequestDelegate, но который создает аргументы, необходимые для вызова метода обработчика. Окончательный результат выглядит примерно так:

Task Invoke(HttpContext httpContext)
{
    // handler — оригинальный метод лямбда-обработчика.
    // Параметр HttpRequest автоматически создается из HttpContext аргумента
    string text = handler.Invoke(httpContext.Request);

    // Возвращаемое значение записывается в ответ, как и ожидалось.
    httpContext.Response.ContentType ??= "text/plain; charset=utf-8";
    return httpContext.Response.WriteAsync(text);
}
Инфраструктура minimal API создает такой метод с использованием Expersion, что является одной из причин высокой скорости minimal API. Это равносильно тому, что вы напишете такой код вручную для каждой из ваших эндпоинтов. Конечно, построение этих выражений не является тривиальным, поэтому эта серия довольно длинная!

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

Вывод метаданных о методе обработчика

В предыдущей статье я показал метод RouteEndpointDataSource.CreateRDFOptions(), который создает экземпляр RequestDelegateFactoryOptions на основе методов обработчика RouteEntry и RoutePattern. Этот объект представляет собой простой набор опций для управления тем, как создается окончательный RequestDelegate. Большинство свойств объекта говорят сами за себя, но я подписал их назначение ниже:

public sealed class RequestDelegateFactoryOptions
{
    // DI контейннер для приложения
    public IServiceProvider? ServiceProvider { get; init; }

    // Имена любых параметров в маршруте.
    // например для маршрута /view/{organization}/{id}
    // содержит два значения, "organization" и "id"
    public IEnumerable<string>? RouteParameterNames { get; init; }

    // True если RequestDelegate должен прерывать плохие запросы
    public bool ThrowOnBadRequest { get; init; }

    // Должен ли RequestDelegate пытаться связать тело запроса по умолчанию
    // Смотрите предыдущую статью для подробного объяснения
    public bool DisableInferBodyFromParameters { get; init; }

    // Используется для создания RequestDelegate и для применения фильтров к эндпоинтам
    public EndpointBuilder? EndpointBuilder { get; init; }
}

Вы можете прочитать, как метод CreateRouteEndpointBuilder() создает объект, вызвав CreateRDFOptions в предыдущем посте. После создания параметров у нас есть вызов RequestDelegateFactory.InferMetadata, показанный ниже.

public static RequestDelegateMetadataResult InferMetadata(
    MethodInfo methodInfo, // 👈 Информация об отражении метода обработчика
    RequestDelegateFactoryOptions? options = null) // 👈 Опции объекта
{
    // Создает "context" объекь (показан кратко)
    RequestDelegateFactoryContext factoryContext = CreateFactoryContext(options);

    // Читает информацию об аргуметах метода обработчика
    factoryContext.ArgumentExpressions = CreateArgumentsAndInferMetadata(methodInfo, factoryContext);

    // Сохраняет метаданные в объект, который в последствии используется для создания RequestDelegate
    return new RequestDelegateMetadataResult
    {
        EndpointMetadata = AsReadOnlyList(factoryContext.EndpointBuilder.Metadata),
    };
}

Этот метод принимает объект MethodInfo, который содержит всю «отражающую» информацию о методе. Обратите внимание, что в моих примерах эндпоинтов API я показывал обработчик в виде лямбда выражения, но вы получили бы то же самое, если бы использовали статический метод, метод экземпляра или локальную функцию в качестве обработчика.

InferMetadata, показанный выше, состоит из двух основных шагов:

  • Создание объекта "context"  RequestDelegateFactoryContext
  • Получение информации о параметрах метода обработчика

Метод CreateFactoryContext вызывается как здесь, в InferMetadata(), так и позже, при создании RequestDelegate в RequestDelegateFactory.Create(), поэтому он имеет несколько необязательных параметров. При выводе метаданных предоставляется только первый параметр, поэтому большая часть метода на данный момент не используется. Схема ниже показывает упрощенную версию CreateFactoryContext с учетом этого:

private static RequestDelegateFactoryContext CreateFactoryContext(
    RequestDelegateFactoryOptions? options,
    RequestDelegateMetadataResult? metadataResult = null, // 👈 всегда null в InferMetadata
    Delegate? handler = null) // 👈 всегда null в InferMetadata
{
    if (metadataResult?.CachedFactoryContext is not null)
    {
        // детали скрыты, потомучто  metadataResult ровняется null в InferMetadata и не вызывается
    }

    // ServiceProvider  не null и устанавливается в CreateRDFOptions()
    IServiceProvider serviceProvider = options?.ServiceProvider;
    
    // EndpointBuilder ровняется null в InferMetadata, поэтому всегда создает новый билдер
    var endpointBuilder = options?.EndpointBuilder ?? new RDFEndpointBuilder(serviceProvider);

    var factoryContext = new RequestDelegateFactoryContext
    {
        Handler = handler, // null, поскольку не указан в InferMetadata
        ServiceProvider = options.ServiceProvider,
        ServiceProviderIsService = serviceProvider.GetService<IServiceProviderIsService>(),
        RouteParameters = options?.RouteParameterNames?.ToList(),
        ThrowOnBadRequest = options?.ThrowOnBadRequest ?? false,
        DisableInferredFromBody = options?.DisableInferBodyFromParameters ?? false,
        EndpointBuilder = endpointBuilder
        MetadataAlreadyInferred = metadataResult is not null, // false
    };

    return factoryContext;
}

Как видно из приведенного выше фрагмента, RequestDelegateFactoryContext в основном содержит копии значений из RequestDelegateFactoryOptions. RDFEndpointBuilder — это очень простая реализация EndpointBuilder, которая предотвращает вызов Build().

private sealed class RDFEndpointBuilder : EndpointBuilder
{
    public RDFEndpointBuilder(IServiceProvider applicationServices)
    {
        ApplicationServices = applicationServices;
    }

    public override Endpoint Build() => throw new NotSupportedException();
}

После создания RequestDelegateFactoryContext следующий большой шаг — анализ параметров обработчика, чтобы понять, как их создавать (из параметров запроса, служб и т. д.), и добавление метаданных в коллекцию эндпоинта.

Анализ параметров обработчика

RequestDelegateFactory анализирует параметры MethodInfo обработчика в CreateArgumentsAndInferMetadata(), передавая метод обработчика и новый объект контекста:

private static Expression[] CreateArgumentsAndInferMetadata(
    MethodInfo methodInfo, RequestDelegateFactoryContext factoryContext)
{
    // Добавляет любые принимаемые по умолчанию метаданные.
    // При этом выполняется много рефлекшенов и построения деревьев выражений,
    // поэтому результаты кэшируются в RequestDelegateFactoryOptions.FactoryContext,
    // а затем повторно используются в RequestDelegateFactory.Create().
    var args = CreateArguments(methodInfo.GetParameters(), factoryContext);

    if (!factoryContext.MetadataAlreadyInferred)
    {
        PopulateBuiltInResponseTypeMetadata(methodInfo.ReturnType, factoryContext.EndpointBuilder);

        // Добавляет метаданные, предоставленные типом возвращаемого 
        // делегата и типами параметров, вводит данные
        EndpointMetadataPopulator.PopulateMetadata(
            methodInfo,
            factoryContext.EndpointBuilder,
            factoryContext.Parameters);
    }

    return args;
}

Комментарии и имена методов в этом методе довольно хорошо объясняют, что происходит, но здесь важно понимать, что мы берем MethodInfo и генерируем массив Expression[] (одно выражение для каждого параметра, который принимает метод обработчика).

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

В следующем разделе мы подробно рассмотрим вызов CreateArguments(), где создается выражение для каждого из параметров метода обработчика.

Создание выражений аргументов.

Первым вызовом метода в CreateArgumentsAndInferMetadata() является CreateArguments(), в котором передаются сведения ParameterInfo[] для метода обработчика, а также объект контекста. Этот метод слишком объемный для чтения в одном блоке кода, поэтому я разобью его на разделы и буду обсуждать каждый по ходу дела.

private static Expression[] CreateArguments(
    ParameterInfo[]? parameters, RequestDelegateFactoryContext factoryContext)
{
    if (parameters is null || parameters.Length == 0)
    {
        return Array.Empty<Expression>();
    }

    var args = new Expression[parameters.Length];
    
    factoryContext.ArgumentTypes = new Type[parameters.Length];
    factoryContext.BoxedArgs = new Expression[parameters.Length];
    factoryContext.Parameters = new List<ParameterInfo>(parameters);

    var hasFilters = factoryContext.EndpointBuilder.FilterFactories.Count > 0;

    // ...
}

Аргумент массива параметров содержит экземпляр ParameterInfo для каждого параметра в обработчике эндпоинта. Если обработчик не принимает никаких параметров, то массив параметров пуст, и делать больше нечего. В противном случае мы инициализируем некоторые массивы, которые используем для записи метаданных о параметрах. Мы также проверяем, применяются ли к эндпоинту какие-либо фильтры, так как мы можем избежать некоторых действий, если их нет.

Я подробно рассмотрю, как фильтры применяются в RequestDelegateFactory, в следующем посте этой серии. Кроме того, вы можете прочитать это объяснение Сафии Абдаллы, которая работала над функцией фильтров!

После инициализации всех массивов мы перебираем параметры метода и создаем Expression, используемый для привязки аргумента к запросу:

for (var i = 0; i < parameters.Length; i++)
{
    args[i] = CreateArgument(parameters[i], factoryContext);

    if (hasFilters)
    {
        // Если метод содержит фильтры, создаем expression`ы 
        // для построения EndpointFilterInvocationContext
    }

    factoryContext.ArgumentTypes[i] = parameters[i].ParameterType;
    factoryContext.BoxedArgs[i] = Expression.Convert(args[i], typeof(object));
}

Метод CreateArgument() выполняет много работы. Он принимает ParameterInfo и считывает различные метаданные, чтобы установить, каким образом создать Expression, который можно использовать для «создания» аргумента обработчику. Например, в начале этого поста вы видели, что параметр HttpRequest был создан с использованием выражения httpContext.Request:

Task Invoke(HttpContext httpContext)
{
    string text = handler.Invoke(httpContext.Request);
    // ...
}

В зависимости от источника аргумента (т. е. на основе поведения привязки модели) создается другое выражение. Например, если параметр должен быть привязан к параметру запроса, например, в эндпоинте MapGet("/", (string? search) => search), будет создано выражение, которое считывается из строки запроса:

Task Invoke(HttpContext httpContext)
{
    string text = handler.Invoke(httpContext.Request.Query["search"]);
    // ...
}

Для приведенного выше примера Expression, представляющий

httpContext.Request.Query["search"]

будет возвращен из CreateArgument() и сохранен в массиве args. Дополнительные сведения также будут установлены в RequestDelegateFactoryContext, чтобы уменьшить количество доработок в RequestDelegateFactory.Create(). При необходимости CreateArgument также выводит метаданные [Accepts] об ожидаемом формате тела запроса и добавляет их в коллекцию метаданных.

Точная логика в CreateArgument() управляет логикой привязки модели и приоритетом для minimal API и довольно сложна, поэтому я подробно рассмотрю этот метод в следующей статье.

"Запакованная" версия Expression (где результат выражения приводится к object) также создается для использования в фильтрах эндпоинтов (где это необходимо), а типы параметров сохраняются в factoryContext.

Последним шагом в методе CreateArguments() является выполнение некоторых проверок работоспособности, проверка распространенных неподдерживаемых сценариев перед возвратом сгенерированных аргументов Expression[].

// Пытались ли мы сделать вывод о привязке к телу, когда не должны?
// (подробности о том, как вычисляется DisableInferredFromBody, см. в предыдущем посте)
if (factoryContext.HasInferredBody && factoryContext.DisableInferredFromBody)
{
    var errorMessage = BuildErrorMessageForInferredBodyParameter(factoryContext);
    throw new InvalidOperationException(errorMessage);
}

// Пытались ли мы выполнить привязку как к телу JSON, так и к телу формы?
// Не может быть и того, и другого!
if (factoryContext.JsonRequestBodyParameter is not null &&
    factoryContext.FirstFormRequestBodyParameter is not null)
{
    var errorMessage = BuildErrorMessageForFormAndJsonBodyParameters(factoryContext);
    throw new InvalidOperationException(errorMessage);
}

// Пытались ли мы выполнить привязку к телу несколько раз?
if (factoryContext.HasMultipleBodyParameters)
{
    var errorMessage = BuildErrorMessageForMultipleBodyParameters(factoryContext);
    throw new InvalidOperationException(errorMessage);
}

return args;

Теперь у нас есть выражение для каждого из параметров обработчика, описывающее, как он должен быть создан с учетом доступного параметра HttpContext. Следующим шагом является чтение возвращаемого значения метода обработчика и его использование для вывода HTTP-ответа, генерируемого эндпоинтом.

Вывод типа ответа HTTP из обработчика.

Метод PopulateBuiltInResponseTypeMetadata() отвечает за эффективную попытку вывести атрибут [Produces] для обработчика на основе возвращаемого типа метода и добавить его в коллекцию метаданных. Вы можете прочитать подробную логику метода здесь, но он эффективно использует следующую последовательность.

  • Возвращаемый тип Task<T> или ValueTask<T> (или другие поддерживаемые await типы)? (Если да, возвращаемый тип обрабатывается как тип Т и выполнение продолжается.).
  • Возвращаемый тип void, Task, ValueTask, или IResult? (Если да, то не можем вывести тип ответа, поэтому выходим).
  • Возвращаемый тип string? (Если да, то добавляем [Produces("text/plain")], если нет, то добавляем [Produces(returnType)])

Обратите внимание, что начиная с .NET 7 типы IResult могут реализовывать IEndpointMetadataProvider для предоставления дополнительной [Produces] информации (например, с помощью помощников TypedResults), но IEndpointMetadataProvider не обрабатывается в методе PopulateBuiltInResponseTypeMetadata(). Вместо этого, он обрабатывается в следующем методе.

Заполнение метаданных из параметров с самоописанием.

В .NET 7 добавлены интерфейсы IEndpointMetadataProvider и IEndpointParameterMetadataProvider, чтобы параметры вашего обработчика и возвращаемые типы были «самоописываемыми». Параметры, которые реализуют один (или оба) из этих интерфейсов, могут заполнять метаданные о себе, что обычно означает, что вам нужно меньше атрибутов и гибких методов для описания ваших API для OpenAPI.

Оба интерфейса содержат один статический абстрактный метод, поэтому вы реализуете их, реализуя статический метод в своем классе:

public interface IEndpointMetadataProvider
{
    static abstract void PopulateMetadata(MethodInfo method, EndpointBuilder builder);
}

public interface IEndpointParameterMetadataProvider
{
    static abstract void PopulateMetadata(ParameterInfo parameter, EndpointBuilder builder);
}

Статическая часть важна, так как это означает, что RequestDelegateFactory может прочитать детали метаданных один раз, не имея экземпляра вашего параметра. Это происходит в методе RequestDelegateFactory.PopulateBuiltInResponseTypeMetadata(), который вызывает вспомогательный метод EndpointMetadataPopulator.PopulateMetadata(), передавая метод обработчика, EndpointBuilder и параметры обработчика.

Я полностью воспроизвел EndpointMetadataPopulator ниже и добавил несколько пояснительных комментариев. Метод PopulateMetadata() перебирает каждый из параметров метода, проверяет, реализует ли он какой-либо из интерфейсов, и, если да, вызывает реализованную функцию. Однако все это приходится делать с помощью рефлексии, что немного усложняет понимание!

internal static class EndpointMetadataPopulator
{
    public static void PopulateMetadata(
        MethodInfo methodInfo, 
        EndpointBuilder builder,
        IEnumerable<ParameterInfo>? parameters = null)
    {
        // Эта переменная массива создается здесь, чтобы массив
        // можно было переиспользовать для каждого аргумента, уменьшая алокацию
        object?[]? invokeArgs = null;
        parameters ??= methodInfo.GetParameters();

        // Получение метаданных из параметров типа
        foreach (var parameter in parameters)
        {
            if (typeof(IEndpointParameterMetadataProvider)
                .IsAssignableFrom(parameter.ParameterType))
            {
                // Параметры типа реализуют IEndpointParameterMetadataProvider
                invokeArgs ??= new object[2];
                invokeArgs[0] = parameter;
                invokeArgs[1] = builder;

                // Использование рефлексии для вызова PopulateMetadata для типа параметра
                 // Использование универсального метода между ними — хитрый способ для неявного кэширования
                PopulateMetadataForParameterMethod.MakeGenericMethod(parameter.ParameterType).Invoke(null, invokeArgs);
            }

            if (typeof(IEndpointMetadataProvider).IsAssignableFrom(parameter.ParameterType))
            {
                // Parameter type implements IEndpointMetadataProvider
                invokeArgs ??= new object[2];
                invokeArgs[0] = methodInfo;
                invokeArgs[1] = builder;

                // Использование рефлексии для вызова PopulateMetadata для типа параметра
                 // Использование универсального метода между ними — хитрый способ для неявного кэширования
                PopulateMetadataForEndpointMethod.MakeGenericMethod(parameter.ParameterType).Invoke(null, invokeArgs);
            }
        }

        // Получение метаданных из возвращаемого типа
        var returnType = methodInfo.ReturnType;
        if (AwaitableInfo.IsTypeAwaitable(returnType, out var awaitableInfo))
        {
            // If it's a Task<T> or ValueTask<T>, use the T as the returnType
            returnType = awaitableInfo.ResultType;
        }

        if (returnType is not null 
            && typeof(IEndpointMetadataProvider).IsAssignableFrom(returnType))
        {
            // Return type implements IEndpointMetadataProvider
            invokeArgs ??= new object[2];
            invokeArgs[0] = methodInfo;
            invokeArgs[1] = builder;
            
            // Используется рефлексия, чтобы вызвать PopulateMetadata для возвращаемого типа.
            PopulateMetadataForEndpointMethod.MakeGenericMethod(returnType).Invoke(null, invokeArgs);
        }
    }

    // Вспомогательные методы и свойства для эффективного вызова членов интерфейса
    private static readonly MethodInfo PopulateMetadataForParameterMethod = typeof(EndpointMetadataPopulator).GetMethod(nameof(PopulateMetadataForParameter), BindingFlags.NonPublic | BindingFlags.Static)!;
 
    private static readonly MethodInfo PopulateMetadataForEndpointMethod = typeof(EndpointMetadataPopulator).GetMethod(nameof(PopulateMetadataForEndpoint), BindingFlags.NonPublic | BindingFlags.Static)!;

    private static void PopulateMetadataForParameter<T>(ParameterInfo parameter, EndpointBuilder builder)
        where T : IEndpointParameterMetadataProvider
    {
        T.PopulateMetadata(parameter, builder);
    }

    private static void PopulateMetadataForEndpoint<T>(MethodInfo method, EndpointBuilder builder)
        where T : IEndpointMetadataProvider
    {
        T.PopulateMetadata(method, builder);
    }
}

Это подводит нас к последнему методу  RequestDelegateFactory.CreateArgumentsAndInferMetadata(). На данный момент Expression для каждого из параметров обработчика создано, включая определение того, откуда должен быть связан аргумент (на основе минимальных правил привязки модели API — подробнее об этом в следующем посте). Все метаданные, связанные с этим процессом, заполнены и добавлены в список метаданных EndpointBuilder. На этом этапе остается только вернуть RequestDelegateMetadataResult из InferMetadata(), содержащий все метаданные, которые мы получили из функции-обработчика:

public static RequestDelegateMetadataResult InferMetadata(MethodInfo methodInfo, RequestDelegateFactoryOptions? options = null)
{
    var factoryContext = CreateFactoryContext(options);
    factoryContext.ArgumentExpressions = CreateArgumentsAndInferMetadata(methodInfo, factoryContext);

    // возвращает метаданные как  IReadOnlyList<T>
    return new RequestDelegateMetadataResult
    {
        EndpointMetadata = AsReadOnlyList(factoryContext.EndpointBuilder.Metadata),
    };
}
Я был удивлен, увидев, что свойство CachedFactoryContext не установлено для возвращаемого объекта результата. Это приводит к большому количеству перестроений деревьев выражений, поэтому я поднял здесь проблему, которая исправлена для .NET 8 и, надеюсь, будет перенесена в .NET 7!

Наконец, мы добрались до конца нашего первого взгляда на RequestDelegateFactory.InferMetadata(). Один важный метод, который я упустил из виду, — это CreateArgument(), который отвечает за создание деревьев выражений, которые заполняют параметры метода обработчика из аргумента HttpContext, переданного в RequestDelegate.

В следующем посте я рассмотрю алгоритм, который CreateArgument() использует для создания каждого аргумента Expression, и, следовательно, правила привязки модели для minimal API.

Подводя итог.

В этом посте я впервые рассмотрел класс RequestDelegateFactory, который используется для создания экземпляра RequestDelegate из минимального метода обработчика API, чтобы его можно было вызвать в EndpointMiddleware. В этом посте я рассмотрел функцию InferMetadata().

InferMetadata() вызывается как часть конструкции эндпоинта для извлечения метаданных о методе обработчика, таких как типы его аргументов, тип возвращаемого значения, подразумеваемые атрибуты [Produces] и другие сведения. В рамках этого процесса InferMetadata() также строит деревья выражений, которые создают аргументы обработчика из HttpContext в окончательном RequestDelegate.

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