Генерация выражений аргументов для minimal API

Генерация выражений аргументов для minimal API
Photo by Raimond Klavins / Unsplash

Эта четвертая статья из серии "За кулисами Minimal API" Эндрю Лока. В предыдущей статье этой серии мы рассмотрели метод CreateArgument() и увидели, что этот метод отвечает за определение того, как работает привязка модели в minimal API, выбирая источник для привязки и генерируя выражение для неё.

Мы пропустили генерацию Expression в предыдущей статье, в этой мы погрузимся в неё и посмотрим на (эффективный) код, который генерирует CreateArgument().

Краткий обзор RequestDelegateFactory.CreateArgument().

В данной серии статей мы рассматриваем наиболее значимые методы и классы, участвующие в построении метаданных эндпоинтов, в создании RequestDelegate, который ASP.Net Core в последствии выполняет:

  • RouteEndpointDataSource - хранит "сырую" информацию о каждом эндпоинте ( delegate обработчика, RoutePattern и т. д.), инициирует их сборку в RequestDelegate и сопоставление метаданных.
  • RequestDelegateFactory.InferMetadata()- отвечает за чтение метаданных об аргументах обработчика эндпоинта и возвращаемом значении, а также за построение Expression для каждого аргумента, который впоследствии может быть использован для построения RequestDelegate.
  • RequestDelegateFactory.Create()- отвечает за создание RequestDelegate, который ASP.NET Core вызывает путем построения и компиляции Expression из обработчика эндпоинта.

До сих пор мы фокусировались на функции InferMetadata(), а в этой статье мы рассмотрим метод, который она вызывает: CreateArgument(). CreateArgument() создает выражение, которое может создать параметр обработчика, если у вас есть доступ к переменной HttpContext httpContext.

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

app.MapGet("/{id}", (string id, HttpRequest request, ISomeService service) => {});

Чтобы выполнить обработчик, ASP.NET Core в итоге должен сгенерировать выражение, которое выглядит примерно следующим образом: Я выделил код, который  генерирует CreateArgument() с помощью 👈:

Task Invoke(HttpContext httpContext)
{
    // Разбор и привязка к модели параметра `id` из RouteValues
    bool wasParamCheckFailure = false;
    
    string id_local = httpContext.Request.RouteValues["id"];  // 👈
    if (id_local == null)                                     // 👈
    {                                                         // 👈
        wasParamCheckFailure = true;                          // 👈
        Log.RequiredParameterNotProvided(httpContext, "string", "id", "route");  // 👈
    }                                                         // 👈

    if(wasParamCheckFailure)
    {
        httpContext.Response.StatusCode = 400;
        return Task.CompletedTask;
    }
     
    // handler — это оригинальный метод лямбда-обработчика.
    // Параметр HttpRequest был автоматически создан из аргумента HttpContext.
    // а параметр ISomeService получен из контейнера DI
    string text = handler.Invoke(
        id_local,                                                      // 👈
        httpContext.Request,                                           // 👈
        httpContext.RequestServices.GetRequiredService<ISomeService>() // 👈
    ); 

    // Возвращаемое значение записывается в ответ, как и ожидалось
    httpContext.Response.ContentType ??= "text/plain; charset=utf-8";
    return httpContext.Response.WriteAsync(text);
}

Большая часть этого кода (хотя и не весь) генерируется CreateArgument() как дерево выражений. В частности, он генерирует код, который создает аргументы обработчика на основе окружающей переменной httpContext, и проверяет, что значения являются действительными (не null).

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

Деревья выражений.

Фундаментальная концепция, используемая RequestDelegateFactory, — это деревья выражений и их компиляция в исполняемый делегат.

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

Метод CreateArgument() потенциально создает несколько деревьев выражений для каждого параметра:

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

Все это станет более очевидным, когда мы рассмотрим некоторые примеры, но я хотел выделить их, чтобы указать на то, что nullability является большой частью генерируемых деревьев Expression и вносит свой вклад в то, является ли параметр действительным или нет.

Например эндпоинт :

app.MapGet("/", (string query, int id) => {})

в котором параметры query и id не допускают null в значении. Сгенерированное выражение будет отличаться от выражения для эндпоинта в котором параметры query и id допускают null в значении или имеют значение по умолчанию:

app.MapGet("/", (string? query, int id = -1) => {})

В первом случае сгенерированное выражение должно отслеживать, были ли предоставлены ожидаемые параметры в строке запроса. Парсинг запроса в параметр int id должен быть проверен для обоих обработчиков. Как вы можете себе представить, все это значительно усложняет генерируемые выражения!

Генерация выражений для известных типов.

Как вы видели в предыдущей статье, CreateArgument() сначала проверяет наличие атрибутов [From*], применяемых к параметрам, чтобы определить источник привязки, но я пока пропущу этот раздел, чтобы рассмотреть привязку параметров к известным типам.

Как я описывал ранее, вы можете инжектировать такие типы, как HttpContext и CancellationToken, и они автоматически привязываются к свойствам HttpContext, инжектированного в конечный RequestDelegate. Поскольку эти свойства доступны напрямую, это самые простые выражения для генерации (поэтому мы с них и начинаем)!

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

static readonly ParameterExpression HttpContextExpr = 
    Expression.Parameter(typeof(HttpContext), "httpContext");

static readonly MemberExpression HttpRequestExpr = 
    Expression.Property(HttpContextExpr, typeof(HttpContext).GetProperty(nameof(HttpContext.Request))!);

Здесь у нас есть два типа выражений. Первое - ParameterExpression, оно определяет тип и имя параметра, передаваемого в метод. Второе — это MemberExpression, которое описывает доступ к свойству. Поскольку HttpContextExpr передается в вызов Expression.Property, это эквивалентно коду, который выглядит следующим образом:

httpContext.Request

Не очень похоже, правда? 😄 Но это все, что нужно сгенерировать CreateArgument() для встроенных типов; это выражение точно определяет, как создать аргумент, который передается обработчику с параметром HttpRequest. То же самое относится и к большинству других простых "известных" типов. Например, рассмотрим этот обработчик, который использует только встроенные типы:

app.MapGet("/", (HttpContext c, HttpRequest r, CancellationToken c, Stream s) => {})

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

Task Invoke(HttpContext httpContext)
{
    handler.Invoke(
        httpContext,
        httpContext.Request,
        httpContext.RequestAborted,
        httpContext.Body); 

    return Task.CompletedTask;
}

Довольно аккуратно!

Конечно, не все может быть так просто. Теперь, когда мы рассмотрели простые случаи, давайте рассмотрим кое-что посложнее: генерацию выражений для привязки к значениям маршрута, строке запроса и заголовкам.

Генерация выражений для привязки к значениям маршрута, строке запроса и заголовкам.

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

  • Все они открыты для HttpRequest: HttpRequest.RouteValues, HttpRequest.Query и HttpRequest.Headers.
  • Все они представлены в виде коллекции элементов, доступ к которым осуществляется с помощью индексатора ( Item), принимающего ключ в string .
  • Каждое из индексированных значений может возвращать объект, представляющий несколько значений, например, объект StringValues.

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

Вместо этого мы применим другой подход: Я покажу пример обработчика, и мы посмотрим на эффективный код, сгенерированный для него. Затем мы немного подправим его (например, сделаем его nullable или изменим тип параметра) и посмотрим, как изменится код. Это позволит нам изучить все различные выражения, не увязая в попытках проследить логику (но вы, конечно, можете прочитать исходный текст, если хотите)!

Привязка необязательного строкового параметра к значению запроса.

Мы начнем с простого обработчика, который по желанию привязывается к значению строки запроса:

app.MapGet("/", (string? q) => {});

Если вы обратитесь к схеме из моей предыдущей статьи, то увидите, что CreateArgument() решит привязать этот аргумент к значению строки запроса (а не к значению маршрута, поскольку таковых не существует). Поскольку параметр является необязательным (поэтому не требует проверки на null) и представляет собой строку (поэтому не требует преобразования), результирующий RequestDelegate выглядит примерно так:

Task Invoke(HttpContext httpContext)
{
    handler.Invoke(
        httpContext.Request.Query["q"] != null 
            ? httpContext.Request.Query["q"]
            : (string) null); 

    return Task.CompletedTask;
}

Здесь есть небольшое дублирование в сгенерированном выражении, что упрощает работу с некоторыми другими сценариями, но в остальном здесь не так много тем для обсуждения, так что давайте двигаться дальше.

Привязка необязательного строкового параметра со значением по умолчанию.

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

app.MapGet("/", Handler);
void Handler(string q = "N/A") { }

Как же это влияет на сгенерированный код? Как вы можете видеть ниже, он очень похож! Вместо возврата к null мы возвращаемся к значению по умолчанию, как и следовало ожидать:

Task Invoke(HttpContext httpContext)
{
    handler.Invoke(
        httpContext.Request.Query["q"] != null 
            ? httpContext.Request.Query["q"]
            : (string) "N/A"); // 👈 Использование значения по умолчанию вместо `null`.

    return Task.CompletedTask;
}

Итак, мы рассмотрели необязательный случай, перейдем к случаю, когда параметр является обязательным.

Привязка обязательного строкового параметра.

Теперь мы вернемся к исходному обработчику, но преобразуем его в параметр string вместо string? :

app.MapGet("/", (string q) => {});

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

Во-первых, мы определяем выражение локальной переменной, которая имеет тип параметра ( string) и названа в соответствии с параметром:

string q_local;

Далее у нас есть выражение связывания, которое считывает значение из строки запроса, проверяет его на null, устанавливает переменную wasParamCheckFailure и при необходимости записывает ошибку в журнал:

q_local = httpContext.Request.RouteValues["q"]
if (q_local == null)
{
     wasParamCheckFailure = true;
     Log.RequiredParameterNotProvided(httpContext, "string", "q", "query");
}

Исключение wasParamCheckFailure создается позже, в методе RequestDelegateFactory.Create(), а Log здесь ссылается на статический вспомогательный метод самой RequestDelegateFactory.

Последнее выражение -— это то, что передается в функцию-обработчик, которая в данном примере просто q_local .

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

Task Invoke(HttpContext httpContext)
{
    bool wasParamCheckFailure = false; // Добавлено с помощью RequestDelegateFactory.Create()
    string q_local;

    q_local = httpContext.Request.RouteValues["q"]
    if (q_local == null)
    {
        wasParamCheckFailure = true;
        Log.RequiredParameterNotProvided(httpContext, "string", "q", "query");
    }

    if(wasParamCheckFailure) // Добавлено с помощью RequestDelegateFactory.Create()
    {
        httpContext.Response.StatusCode = 400;
        return Task.CompletedTask;
    }

    handler.Invoke(q_local); 

    return Task.CompletedTask;
}

Теперь все определенно становится немного сложнее! Давайте добавим еще один уровень сложности и рассмотрим привязку к чему-либо, кроме строки, где нам нужно вызвать TryParse для создания значения.

Привязка необязательного параметра int к значению запроса.

Давайте вернемся немного назад к нашему изначальному примеру обработчика, только теперь мы будем привязывать параметр int к значению запроса.

app.MapGet("/", (int? q) => {});

Простой переход от string? к int? оказывает большое влияние на генерируемый код, поскольку теперь ему необходимо учитывать:

  • Работа с nullable типами. Nullable типы — странные, поверьте мне 😅
  • Парсинг string в int и обработка ошибок
  • Временные переменные для хранения промежуточных значений

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

Task Invoke(HttpContext httpContext)
{
    string tempSourceString; // Добавляется из RequestDelegateFactory.Create()
    bool wasParamCheckFailure = false; // Added by RequestDelegateFactory.Create()
    int? q_local;

    tempSourceString = httpContext.Request.RouteValues["q"]
    if (tempSourceString != null)
    {
        if (int.TryParse(tempSourceString, out int parsedValue))
        {
            q_local = (int?)parsedValue;
        }
        else
        {
            wasParamCheckFailure = true;
            Log.ParameterBindingFailed(httpContext, "Int32", "q", tempSourceString)
        }
    }

    if(wasParamCheckFailure) // Добавляется из RequestDelegateFactory.Create()
    {
        httpContext.Response.StatusCode = 400;
        return Task.CompletedTask;
    }

    handler.Invoke(q_local); 

    return Task.CompletedTask;
}

Давайте посмотрим, как добавление значения по умолчанию изменяет сгенерированный код.

Привязка необязательного параметра int со значением по умолчанию.

Как и в случае со строкой, мы перейдем к использованию ненулевого параметра со значением по умолчанию, используя локальную функцию:

app.MapGet("/", Handler);
void Handler(int q = 42) { }

Созданный RequestDelegate в этом случае очень похож на предыдущий пример. Есть три основных отличия:

  • Как и следовало ожидать, переменные имеют значение int, а не int?
  • Мы можем напрямую присвоить q_local в вызове TryParse, потому что теперь это int, а не int?. Это означает, что мы также можем инвертировать условие if, немного упростив ситуацию
  • Мы добавляем предложение else для случая, когда tempSourceString равен null, и присваиваем q_local значение по умолчанию.

RequestDelegate выглядит следующим образом:

Task Invoke(HttpContext httpContext)
{
    string tempSourceString; // Добавляется из RequestDelegateFactory.Create()
    bool wasParamCheckFailure = false; // Добавляется из  RequestDelegateFactory.Create()
    int q_local; // 👈 int вместо int?

    tempSourceString = httpContext.Request.RouteValues["q"]
    if (tempSourceString != null)
    {
        // Здесь мы можем напрямую присвоить q_local 👇
        if (!int.TryParse(tempSourceString, out q_local)) // 👈 что означает, что мы можем инвертировать это предложение if
        {
            wasParamCheckFailure = true;
            Log.ParameterBindingFailed(httpContext, "Int32", "q", tempSourceString)
        }
    }
    else
    {
        q_local = 42; // 👈 Присвоить значение по умолчанию, если значение отсутствует
    }

    if(wasParamCheckFailure) // Added by RequestDelegateFactory.Create()
    {
        httpContext.Response.StatusCode = 400;
        return Task.CompletedTask;
    }

    handler.Invoke(q_local); 

    return Task.CompletedTask;
}

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

Привязка обязательного параметра int

В нашем примере последнего обработчика у нас есть необходимый параметр int:

app.MapGet("/", (int q) => {});

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

  • Дополнительная предварительная проверка того, что значение, полученное из источника, не является нулевым.
  • Отсутствует else  по умолчанию

В результате RequestDelegate будет выглядеть так:

Task Invoke(HttpContext httpContext)
{
    string tempSourceString; // Добавлено из RequestDelegateFactory.Create()
    bool wasParamCheckFailure = false; // Добавлено из RequestDelegateFactory.Create()
    int q_local;

    tempSourceString = httpContext.Request.RouteValues["q"]
    
    if (tempSourceString == null) // 👈 Дополнительная проверка
    {
        wasParamCheckFailure = true;
        Log.RequiredParameterNotProvided(httpContext, "Int32", "q");
    }
    
    if (tempSourceString != null)
    {
        if (!int.TryParse(tempSourceString, out q_local))
        {
            wasParamCheckFailure = true;
            Log.ParameterBindingFailed(httpContext, "Int32", "q", tempSourceString)
        }
    } // 👈 Тут нет else блока

    if(wasParamCheckFailure) // Добавлено из RequestDelegateFactory.Create()
    {
        httpContext.Response.StatusCode = 400;
        return Task.CompletedTask;
    }

    handler.Invoke(q_local); 

    return Task.CompletedTask;
}

На данный момент мы рассмотрели сгенерированные выражения для:

  • Известных типов, таких как HttpContext и HttpRequest
  • Привязки обязательных и необязательных string значений к значениям маршрута, строке запроса и заголовкам.
  • Привязки обязательных и необязательных значений TryParse() (на примере, int) к значениям маршрута, строке запроса и заголовкам.

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

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

Метод RequestDelegateFactory.CreateArgument() отвечает за создание деревьев выражений для привязки аргументов обработчика minimal API к HttpContext. RequestDelegateFactory.Create() использует эти деревья выражений для создания конечного RequestDelegate, который ASP.NET Core выполняет для обработки запроса.

В этой заметке я показал примеры деревьев выражений, созданных для определенных типов параметров. Я начал с того, что показал, как известные типы, такие как HttpRequest и CancellationToken, генерируют выражения, связывающие аргументы со свойствами HttpContext. Это самые простые случаи, поскольку они не требуют никакой проверки.

Далее мы рассмотрели привязку строковых параметров к строке запроса, значениям маршрута и заголовка. Выражения для необязательных параметров (string?) и параметров со значениями по умолчанию были относительно простыми, но для обязательных значений (string) мы увидели, что требуется некоторая валидация.

Гораздо больше проверки требуется при использовании типов параметров, которые должны быть разобраны из строки, например значения int. Независимо от того, используете ли вы необязательные или требуемые значения, они требуют определенной степени проверки, что приводит к большему количеству сгенерированного кода в выражении. В следующем посте мы продолжим рассмотрение выражений, генерируемых в CreateArgument() для других типов параметров.

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