Создание окончательного RequestDelegate

Создание окончательного RequestDelegate
Photo by Kelly Sikkema / Unsplash

Это седьмая статья из серии: "За кулисами минимальных API".

В этой серии статей мы рассмотрели различные элементы, необходимые для превращения эндпоинта minimal API в RequestDelegate, который может вызывать ASP.NET Core. Мы рассмотрели различные классы, извлечение метаданных из обработчика minimal API, а также то, как работает привязка в minimal API. Далее мы рассмотрели, как RequestDelegateFactory создает части выражения, которые объединяются в RequestDelegate, как для выражений аргументов (части 1 и 2), так и для ответа.

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

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

Создание RequestDelegate: RequestDelegateFactory.Create()

Для целей этой статьи я рассмотрю простой обработчик минимального API, который принимает один параметр и возвращает строку:

app.MapGet("/{name}", (string name) => $"Hello {name}!");

Основное внимание в этой статье уделено методу RequestDelegateFactory.Create(). Существует несколько перегрузок, но в итоге они вызывают перегрузку, которая принимает:

  • Delegate handler - обработчик эндпоинта минимального API (string name) => $"Hello {name}"
  • RequestDelegateFactoryOptions? options - содержит различные контекстные значения и настройки эндпоинта, как обсуждалось ранее.
  • RequestDelegateMetadataResult? metadataResult - содержит результат вызова RequestDelegateFactory.InferMetadata(), включая выведенные метаданные и кэшированный RequestDelegateFactoryContext.

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

На практике, в минимальных API, эти значения всегда предоставляются, поскольку они создаются в RequestDelegateFactory.InferMetadata(), который вызывается перед Create().

В этой статье я буду отслеживать содержимое важных переменных по мере выполнения метода Create(), чтобы мы могли видеть, как все сочетается.

Создание targetExpression.

Давайте начнем рассматривать функцию RequestDelegateFactory.Create(), рассматривая по одному утверждению за раз.

public static RequestDelegateResult Create(
    Delegate handler, 
    RequestDelegateFactoryOptions? options = null,
    RequestDelegateMetadataResult? metadataResult = null)
{
    if (handler is null)
    {
        throw new ArgumentNullException(nameof(handler));
    }

    UnaryExpression targetExpression = handler.Target switch
    {
        object => Expression.Convert(TargetExpr, handler.Target.GetType()),
        null => null,
    };

    // ...
}

Функция начинается с проверки обработчика на null, а затем создает выражение Expression под названием targetExpression, которое имеет одно из двух значений:

  • Если handler.Target имеет значение null, значит null
  • В противном случае, выражение, которое преобразует "целевой параметр" к требуемому типу.

Если вы используете статический метод обработчика, handler.Target будет равен null, в противном случае он возвращает экземпляр типа, для которого был вызван обработчик. В нашем случае, поскольку мы используем метод лямбды, экземпляром является класс закрытия (вложенный внутрь класса Program), в котором мы определили лямбду. Этот класс закрытия обычно имеет "неправильное" имя в C#, например <>, поэтому в дальнейшем мы будем использовать его.

Вам может показаться удивительным, что метод лямбда является "экземпляром", а не статическим типом. Причина в том, что компилятор создает класс закрытия для захвата контекста лямбды (даже если вы определяете лямбду с помощью ключевого слова static). Вы можете увидеть это в действии, используя опцию sharplab.io "C#", чтобы посмотреть результат эффективного сокращения Func<>.

На данном этапе у нас есть две переменные, представляющие интерес: handler и targetExpression:

Перейдем к следующим нескольким строкам Create().

Создание RequestDelegateFactoryContext и targetFactory

Следующие несколько строк относительно просты:

// ...
RequestDelegateFactoryContext factoryContext = 
    CreateFactoryContext(options, metadataResult, handler);

Expression<Func<HttpContext, object?>> targetFactory = (httpContext) => handler.Target;
// ...

Первая строка создает тип RequestDelegateFactoryContext. Этот тип отслеживает все важные данные о создании RequestDelegate и в основном представляет собой пакет свойств. Вы можете посмотреть полное определение класса здесь, но он содержит такие свойства, как:

  • AllowEmptyRequestBody—допускаются ли пустые тела запросов, или обработчик должен отбрасываться.
  • UsingTempSourceString—использует ли код разбора аргументов переменную tempSourceString.
  • ExtraLocals—выражения, определяющие дополнительные переменные, которые необходимы при связывании аргументов.
  • ParamCheckExpressions—выражения, определяющие "проверку аргументов", которая происходит после связывания.

У контекста есть еще много свойств, некоторые из которых копируются прямо из объекта options. Мы уже рассмотрели метод CreateFactoryContext() в предыдущей статье, поэтому пока оставим его в покое, но мы будем отслеживать некоторые важные свойства RequestDelegateFactoryContext в последующих шагах.

Следующая строка Create() создает выражение, определяющее функцию-фабрику, которая создает необходимый экземпляр цели метода, учитывая окружающую переменную HttpContext: httpContext => handler.Target. Это также можно было бы определить как _ => handler.Target, так как параметр не используется; насколько я могу судить, параметр HttpContext используется для облегчения композиции позже (когда вы используете фильтры). Я добавлю дополнительные переменные в нашу таблицу отслеживания:

Теперь мы переходим к функции CreateTargetableRequestDelegate(), которую мы рассматривали в предыдущей статье.

Вводим CreateTargetableRequestDelegate и создаем выражения аргументов.

Следующей строкой RequestDelegateFactory.Create() является:

// ...
Func<object?, HttpContext, Task>? targetableRequestDelegate = 
    CreateTargetableRequestDelegate(
        handler.Method, targetExpression, factoryContext, targetFactory);
// ...

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

Как видно из приведенного выше вызова метода, мы передаем в метод практически весь текущий контекст:

private static Func<object?, HttpContext, Task>? CreateTargetableRequestDelegate(
    MethodInfo methodInfo,
    Expression? targetExpression,
    RequestDelegateFactoryContext factoryContext,
    Expression<Func<HttpContext, object?>>? targetFactory = null)
{
    factoryContext.ArgumentExpressions ??= 
        CreateArgumentsAndInferMetadata(methodInfo, factoryContext);

    // ...
}

Первая строка этого метода - CreateArgumentsAndInferMetadata(). Мы уже подробно рассмотрели этот метод и метод CreateArgument(), на который он опирается для построения выражений связывания аргументов, поэтому в этой статье мы рассмотрим только выходные выражения этого метода, которых несколько!

Для нашего примера обработчика

(string name) => $"Hello {name}!"`

CreateArgumentsAndInferMetadata() создает три основных выражения:

  • Expression который передается в аргумент обработчика - выражение, состоящее из переменной: name_local. Оно добавляется в factoryContext.ArgumentExpressions.
  • Expression определение дополнительных локальных переменных, необходимых для привязки, в данном случае это аналогично локальной переменной name_local. Она добавляется в factoryContext.ExtraLocals.
  • Выражение проверки параметров, для проверки правильности привязки строкового аргумента - добавляется в factoryContext.ParamCheckExpressions. В предыдущей статье вы видели, что оно генерирует выражение, подобное следующему:
name_local = httpContext.Request.RouteValues["name"]
if (name_local == null)
{
     wasParamCheckFailure = true;
     Log.RequiredParameterNotProvided(httpContext, "string", "name", "route");
}
Вызов функции Log ссылается на "реальный" метод в RequestDelegateFactory, который вызывается в выражении с помощью Expression.Call().

CreateArgumentsAndInferMetadata() устанавливает различные другие вспомогательные значения и метаданные, но это основные значения, создаваемые в случае простой привязки строки к значению маршрута. Для других типов привязки вы должны установить дополнительные значения на factoryContext, как описано в моих предыдущих статьях. Например:

  • factoryContext.UsingTempSourceString—устанавливается в true при разборе типа TryParse(), например, при связывании параметра int.
  • factoryContext.ParameterBinders—выражение для привязки параметров BindAsync().
  • factoryContext.JsonRequestBodyParameter—Ссылка на ParameterInfo (если есть), который требует привязки к телу запроса в формате JSON.
  • factoryContext.FirstFormRequestBodyParameter—Ссылка на первый ParameterInfo (если есть) который требует привязки к телу запроса в виде формы. Обратите внимание, что несколько параметров тела запроса формы приводят к ошибке.

Если сложить все это вместе, то после вызова CreateArgumentsAndInferMetadata() мы получим следующие переменные и кэшированные значения:

Продолжим работу над CreateTargetableRequestDelegate().

Создание вызова метода Expression

Следующим шагом в CreateTargetableRequestDelegate является CreateMethodCall(), который создает выражение для фактического вызова обработчика минимального API:

private static Func<object?, HttpContext, Task>? CreateTargetableRequestDelegate(
    MethodInfo methodInfo,
    Expression? targetExpression,
    RequestDelegateFactoryContext factoryContext,
    Expression<Func<HttpContext, object?>>? targetFactory = null)
{
    // ...

    factoryContext.MethodCall = 
        CreateMethodCall(methodInfo, targetExpression, factoryContext.ArgumentExpressions);
    
    // ...
}

CreateMethodCall() — это небольшой метод, который принимает метод обработчика, выражение targetExpression и выражения аргументов и объединяет их в одно выражение:

private static Expression CreateMethodCall(
    MethodInfo methodInfo, Expression? target, Expression[] arguments) =>
    target is null ?
        Expression.Call(methodInfo, arguments) :
        Expression.Call(target, methodInfo, arguments);

Возвращаемый Expression хранится в factoryContext.MethodCall. Если вы проанализируете это, то увидите, что результирующий код выглядит примерно так; это просто вызов обработчика минимального API и передача аргумента name_local:

((Program.<>c) target).Handler.Invoke(name_local)
По сути, это вызов вашего обработчика минимального API и передача выражений аргументов.

В качестве альтернативы, если вы думаете об обработчике как о лямбде Func<string, string>, определенной на Program:

public class Program
{
    Func<string, string> handler = (string name) => $"Hello {name}!";
}

Тогда factoryContext.MethodCall выглядит просто как:

target.handler(name_local)

Для простоты я буду использовать эту нотацию до конца статьи. Добавим это в наши переменные. Мы больше не будем использовать ArgumentExpressions, поэтому я удалил их из таблицы:

В следующей части CreateTargetableRequestDelegate рассматриваются фильтры, поэтому мы пока пропустим её и перейдём к вызову метода CreateParamCheckingResponseWritingMethodCall .

Построение делегата запроса в CreateParamCheckingResponseWritingMethodCall

В следующем разделе CreateTargetableRequestDelegate() все действительно начинает быстро сходиться:

private static Func<object?, HttpContext, Task>? CreateTargetableRequestDelegate(
    MethodInfo methodInfo,
    Expression? targetExpression,
    RequestDelegateFactoryContext factoryContext,
    Expression<Func<HttpContext, object?>>? targetFactory = null)
{
    // ...

    Type returnType = methodInfo.ReturnType;
    
    // ...Filter magic
    
    Expression responseWritingMethodCall = factoryContext.ParamCheckExpressions.Count > 0 
        ? CreateParamCheckingResponseWritingMethodCall(returnType, factoryContext) 
        : AddResponseWritingToMethodCall(factoryContext.MethodCall, returnType);
    
    // ...
}

В этом разделе мы вызываем один из двух методов, в зависимости от того, есть ли какие-либо ParamCheckExpressions:

  • CreateParamCheckingResponseWritingMethodCall—вызывается для добавления любых ParamCheckExpression в RequestDelegate, затем вызывается AddResponseWritingToMethodCall
  • AddResponseWritingToMethodCall—Занимается сериализацией возвращаемого значения обработчика и записью ответа.

Я подробно описал оба этих метода в предыдущей статье, поэтому вы можете обратиться к ней для получения подробной информации о том, как строятся выражения. В нашем примере у нас есть выражение ParamCheckExpression, поэтому мы вызываем CreateParamCheckingResponseWritingMethodCall и сохраняем результат в переменной responseWritingMethodCall. Для нашего примера это выглядит следующим образом:

string name_local; // factoryContext.ExtraLocals
bool wasParamCheckFailure;

name_local = httpContext.Request.RouteValues["name"]  //
if (name_local == null)                               //
{                                                     //
     wasParamCheckFailure = true;                     // factoryContext.ParamCheckExpressions
     Log.RequiredParameterNotProvided(                //
        httpContext, "string", "name", "route");      //
}                                                     //

if(wasParamCheckFailure)
{
    httpContext.Response.StatusCode = 400;
    return Task.CompletedTask;
}

ExecuteWriteStringResponseAsync(
    httpContext,
    target.handler(name_local)); // factoryContext.MethodCall

Приведенное выше Expression состоит как из значений Expression, которые мы создали ранее, так и из нового кода, добавленного CreateParamCheckingResponseWritingMethodCall.

Последний вызов в Expression вызывает обработчик и передает результат в функцию ExecuteWriteStringResponseAsync вместе с параметром httpContext (который еще не определен). ExecuteWriteStringResponseAsync — это частная функция в RequestDelegateFactory, показанная ниже:

private static Task ExecuteWriteStringResponseAsync(
    HttpContext httpContext, string text)
{
    SetPlaintextContentType(httpContext);
    return httpContext.Response.WriteAsync(text);
}

private static void SetPlaintextContentType(HttpContext httpContext)
    => httpContext.Response.ContentType ??= "text/plain; charset=utf-8";

Теперь у нас действительно что-то получается. Выражение выше хранится в responseWritingMethodCall, поэтому мы можем не беспокоиться об отслеживании других переменных в нашей таблице; это единственное выражение, которое сейчас имеет значение. Конец близок!

Компиляция выражения в Func<object?, HttpCotnext, Task>

Заключительные строки в CreateTargetableRequestDelegate выглядят следующим образом:

private static Func<object?, HttpContext, Task>? CreateTargetableRequestDelegate(
    MethodInfo methodInfo,
    Expression? targetExpression,
    RequestDelegateFactoryContext factoryContext,
    Expression<Func<HttpContext, object?>>? targetFactory = null)
{
    // ...

    if (factoryContext.UsingTempSourceString)
    {
        responseWritingMethodCall = Expression.Block(
            new[] { TempSourceStringExpr }, responseWritingMethodCall);
    }

    return HandleRequestBodyAndCompileRequestDelegate(
        responseWritingMethodCall, factoryContext);
}

В if условно добавляется строка tempSourceString в верхнюю часть RequestDelegate, если это необходимо для целей разбора. В нашем случае она не нужна, но, если бы у нас был, например, параметр int, ее нужно было бы добавить.

Последний вызов метода - HandleRequestBodyAndCompileRequestDelegate(). Название намекает на обязанности этого метода; он отвечает за три вещи:

  • Добавление необходимого Expression для чтения из тела запроса/формы, если это требуется.
  • Добавление необходимого Expression для любых аргументов BindAsync.
  • Компиляция Expression в функцию.

В нашем примере API не читает из тела запроса (это GET-запрос), поэтому я остановлюсь на этой части.

В нашем примере API также не используются параметры BindAsync, но для полноты картины я включил генерацию кода в HandleRequestBodyAndCompileRequestDelegate, показанную ниже:

private static Func<object?, HttpContext, Task> HandleRequestBodyAndCompileRequestDelegate(
    Expression responseWritingMethodCall, 
    RequestDelegateFactoryContext factoryContext)
{
    // Do we need to bind to the body/form?
    if (factoryContext.JsonRequestBodyParameter is null && !factoryContext.ReadForm)
    {
        // No - Do we have any BindAsync() parameters?
        if (factoryContext.ParameterBinders.Count > 0)
        {
            // Yes, so we need to generate the code for reading from the custom binders calling into the delegate
            var continuation = Expression.Lambda<Func<object?, HttpContext, object?[], Task>>(
                responseWritingMethodCall, TargetExpr, HttpContextExpr, BoundValuesArrayExpr).Compile();

            var binders = factoryContext.ParameterBinders.ToArray();
            var count = binders.Length;

            return async (target, httpContext) =>
            {
                var boundValues = new object?[count];

                for (var i = 0; i < count; i++)
                {
                    boundValues[i] = await binders[i](httpContext);
                }

                await continuation(target, httpContext, boundValues);
            };
        }

        // No - we don't have any BindAsync methods
        return Expression.Lambda<Func<object?, HttpContext, Task>>(
            responseWritingMethodCall, TargetExpr, HttpContextExpr).Compile();
    }

    // Bind to the form/body
    return factoryContext.ReadForm
        ? HandleRequestBodyAndCompileRequestDelegateForForm(responseWritingMethodCall, factoryContext)
        : HandleRequestBodyAndCompileRequestDelegateForJson(responseWritingMethodCall, factoryContext);
}

Если вы проследите за ветвями, то увидите, что для нашего примера этот метод ничего не добавляет к существующему Expression. Этот метод просто компилирует имеющееся у нас Expression в Func<object?, HttpContext, Task>, который выглядит примерно следующим образом:

Task TargetableRequestDelegate(object? target, HttpContext httpContext)
{
    string name_local;
    bool wasParamCheckFailure;

    name_local = httpContext.Request.RouteValues["name"]
    if (name_local == null)
    {
        wasParamCheckFailure = true;
        Log.RequiredParameterNotProvided(httpContext, "string", "name", "route");
    }

    if(wasParamCheckFailure)
    {
        httpContext.Response.StatusCode = 400;
        return Task.CompletedTask;
    }

    return ExecuteWriteStringResponseAsync(
        httpContext,
        handler(name_local));
}

Обратите внимание, что теперь это Func<>, который может быть вызван, но это не RequestDelegate, поскольку в сигнатуре метода есть дополнительный параметр target. Чтобы создать окончательный RequestDelegate, нам нужно пройти по стеку до самого метода Create(), чтобы вызвать CreateTargetableRequestDelegate().

Построение окончательного RequestDelegate

Теперь мы почти на финишной прямой. Оставшиеся части Create() в основном занимаются наведением порядка и обработкой периферийных случаев, на которых мы не фокусируемся. Важным моментом является преобразование Func<>, хранящегося в targetableRequestDelegate, в настоящий RequestDelegate:

public static RequestDelegateResult Create(
    Delegate handler, 
    RequestDelegateFactoryOptions? options = null,
    RequestDelegateMetadataResult? metadataResult = null)
{
    RequestDelegate finalRequestDelegate = targetableRequestDelegate switch
    {
        // если targetableRequestDelegate равен null, то мы попали в крайний случай, на котором мы сейчас не фокусируемся!
        null => (RequestDelegate)handler,

        // В противном случае создаем RequestDelegate
        _ => httpContext => targetableRequestDelegate(handler.Target, httpContext),
    };

    // сохранить finalRequestDelegate в RequestDelegateResult
    return CreateRequestDelegateResult(finalRequestDelegate, factoryContext.EndpointBuilder);
}

Итак, для нашего примера finalRequestDelegate, который является экземпляром RequestDelegate (Func<HttpContext, Task>), выглядит примерно так:

(HttpContext httpContext) => TargetableRequestDelegate(Program.<>c, httpContext)

Где

  • Program.<>c это кэшированный экземпляр лямбды
  • TargetableRequestDelegate скомпилированный Expression который мы сохранили в предыдущей секции ( Func<HttpContext, object?, Task>).

Это RequestDelegate, который вызывается, когда ваш эндпоинт минимального API обрабатывает запрос.

Это (наконец-то) подводит нас к концу этой статьи о том, как создается RequestDelegate. Это запутанный и сложный процесс, но конечным результатом является простой Func<HttpContext, Task>, который может быть выполнен в ответ на запрос, что в итоге элегантно!

Конечно, мы еще не закончили. Я все обещаю, что мы рассмотрим фильтры, так что в следующем посте мы это сделаем. Надеюсь, со всей этой предысторией понять их назначение и влияние на RequestDelegate будет немного проще!

Подведение итогов

В этом посте мы рассмотрели метод RequestDelegateFactory.Create(), чтобы понять, как все выражения, которые мы рассматривали в этой серии, объединяются в конечный RequestDelegate.

Мы начали с рассмотрения targetExpression и targetFactory и выяснили, что даже статические лямбда-методы имеют "целевой" экземпляр. Затем мы перешли к CreateArgumentsAndInferMetadata() и помощнику CreateArgument(), в котором мы определяем основную часть различных компонентов Expression. Здесь определены все выражения, связанные с привязкой аргументов к HttpContext.

После определения выражений аргументов мы можем создать выражение для вызова лямбда-обработчика. Это было немного сложно показать, так как это выражение вызывает кэшированный экземпляр лямбды, передавая аргумент Expressions.

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

В HandleRequestBodyAndCompileRequestDelegate мы наконец-то вызываем Compile() и превращаем выражение в Func<object, HttpContext, Task>. Это еще не совсем RequestDelegate, но RequestDelegateFactory.Create() "обертывает" его для создания требуемой сигнатуры, передавая целевой экземпляр, что дает окончательную, требуемую, Func<HttpContext, Task> сигнатуру RequestDelegate.

С оригиналом статьи вы можете ознакомиться по ссылке.