Извлечение метаданных из обработчика 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);
}
Expersion

Некоторая информация о функции-обработчике и ее параметрах явно добавляется в качестве метаданных в эндпоинт, а также используется для непосредственного построения 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 для каждого параметра в обработчике эндпоинта. Если обработчик не принимает никаких параметров, то массив параметров пуст, и делать больше нечего. В противном случае мы инициализируем некоторые массивы, которые используем для записи метаданных о параметрах. Мы также проверяем, применяются ли к эндпоинту какие-либо фильтры, так как мы можем избежать некоторых действий, если их нет.

Сафии Абдаллы

После инициализации всех массивов мы перебираем параметры метода и создаем 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()

"Запакованная" версия 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

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

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

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

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

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

コスプレ衣装は写真映えだけでなく、移動や休憩のしやすさも含めて選びたいところです。写真映えを意識する場面では、Fate スペースエレシュキガル 衣装を確認して全体のシルエットを見られます。写真で見える部分を意識して準備すると、キャラクターの印象を整えやすくなります。

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