Изучение логики привязки модели minimal API

Изучение логики привязки модели minimal API
Photo by Miguel A Amutio / Unsplash

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

Краткий обзор RequestDelegateFactory

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

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

До сих пор мы фокусировались на функции InferMetadata(), а в этой статье мы рассмотрим метод, который она вызывает: CreateArgument(). Напомню, вот как выглядит InferMetadata():

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

    return new RequestDelegateMetadataResult
    {
        EndpointMetadata = AsReadOnlyList(factoryContext.EndpointBuilder.Metadata),
    };
}

CreateArgumentsAndInferMetadata(), в свою очередь, вызывает CreateArguments(), передавая подробности о параметрах обработчика эндпоинта как ParameterInfo[]:

private static Expression[] CreateArgumentsAndInferMetadata(
    MethodInfo methodInfo, RequestDelegateFactoryContext factoryContext)
{
    var args = CreateArguments(methodInfo.GetParameters(), factoryContext);
    // ...
    return args;
}

И CreateArguments() в итоге вызывает CreateArgument() для каждого ParameterInfo:

private static Expression[] CreateArguments(
    ParameterInfo[]? parameters, RequestDelegateFactoryContext factoryContext)
{
    // ...
    var args = new Expression[parameters.Length];

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

В этой статье мы подробно рассмотрим структуру метода CreateArgument().

Обязанности CreateArgument

Исходя из названия и сигнатуры, можно подумать, что обязанности CreateArgument просты, и в некотором смысле так оно и есть: он создает выражение (Expression), которое может создать параметр обработчика, получив доступ к переменной httpContext HttpContext.

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

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

Чтобы выполнить обработчик, ASP.NET Core в конечном итоге необходимо сгенерировать выражение, которое выглядит примерно так:

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

    if(wasParamCheckFailure)
    {
        // привязка не удалась, возвращаем код 400
        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() в виде дерева Expression. Из этого участка кода видно, что CreateArgument() косвенно отвечает за другие различные функции:

  • Он определяет порядок привязки моделей. Тот факт, что параметр id привязывается к параметру маршрута, а не к строке запроса, определяется в CreateArgument().
  • Он определяет поведение при неудачном связывании моделей. Здесь рассматривается разное поведение необязательных и обязательных параметров, а также исключения при их разборе.

В этой статье мы сосредоточимся на первой из этих обязанностей, разберем как код в CreateArgument() определяет поведение привязки модели minimal API.

Здесь я не буду рассматривать сгенерированные деревья выражений; мы рассмотрим их в следующей статье. В этой мы рассмотрим, как CreateArgument выбирает, какое дерево выражений генерировать.

Защита от недопустимых значений в CreateArgument().

Как и следовало ожидать, первое, что делает функция CreateArgument(), — это защищает от недопустимых аргументов. Он специально проверяет 2 вещи:

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

private static Expression CreateArgument(
    ParameterInfo parameter, RequestDelegateFactoryContext factoryContext)
{
    if (parameter.Name is null)
    {
        throw new InvalidOperationException(
            $"Обнаружен параметр типа '{parameter.ParameterType}' без имени. Параметры должны иметь имя.");
    }

    if (parameter.ParameterType.IsByRef)
    {
        var attribute = "ref";

        if (parameter.Attributes.HasFlag(ParameterAttributes.In))
        {
            attribute = "in";
        }
        else if (parameter.Attributes.HasFlag(ParameterAttributes.Out))
        {
            attribute = "out";
        }

        throw new NotSupportedException(
            $"Параметр по ссылке '{attribute} {TypeNameHelper.GetTypeDisplayName(parameter.ParameterType, fullName: false)} {parameter.Name}' не поддерживается.");
    }
 
    // ..
}

Когда эти простые проверки пройдены, мы остаемся с основной частью метода CreateArgument(), который, по сути, представляет собой гигантский оператор if...else if! Именно он определяет основное поведение приоритета привязки к модели в minimal API.

Понимание приоритета привязки модели в minimal API.

Вместо того чтобы воспроизводить все 150(!) строк if`ов, мы начнем с рассмотрения важных категорий, которые использует CreateArgument(), в порядке приоритета:

  1. Имеет ли параметр атрибут [From*]? Если да, привязываем к соответствующему источнику. Например, если параметр имеет атрибут [FromHeader], привязываем его к значению заголовка.
  2. Является ли параметр "известным" типом, таким как HttpContext или HttpRequest? Если да, используем соответствующий тип из запроса.
  3. Есть ли у параметра метод BindAsync()? Если да, вызываем его, чтобы связать параметр.
  4. Является ли метод строкой, или у него есть метод TryParse (например, встроенные типы, такие как int и Guid, а также пользовательские типы). Если да: - Были ли параметры маршрута успешно разобраны из RoutePattern? (Если да, есть ли параметр маршрута, соответствующий имени параметра? Если да, выполняем привязку из значений маршрута. Если нет подходящего параметра маршрута, привязка выполняется из строки запроса.) -Если параметры маршрута не были успешно разобраны из RoutePattern, попробуем привязать к запросу RouteValues, и, если параметр не найден, вернемся к строке запроса вместо него.
  5. Если привязка к телу отключена и параметр является массивом типов, реализующих TryParse (или string/`StringValues`), то привязка осуществляется к строке запроса.
  6. Является ли параметр сервисом известным DI? Если да, привязываем к службе DI.
  7. В противном случае привязка к телу запроса путем десериализации в формате JSON.

Хорошо... возможно, это все еще трудно понять. В качестве альтернативы вы можете представить, что "if" реализует следующую блок-схему:

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

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

Привязка параметров [From*]

Наивысший приоритет для связывания — это, когда вы применили атрибут [From*] к параметру, например [FromRoute] или [FromQuery]:

app.MapGet("/{id}", ([FromRoute] int id, [FromQuery]string search) => "Hello world");

CreateArgument проверяет по очереди каждый из поддерживаемых атрибутов [From]; первый найденный атрибут определяет источник привязки параметра. В большинстве случаев CreateArgument "вслепую" использует указанный источник, хотя он проверяет пару недопустимых условий и при их обнаружении немедленно выбрасывает исключение:

  • Если у вас есть атрибут [FromRoute], параметры маршрута были разобраны из RoutePattern, но имя параметра (или имя, указанное в атрибуте) не существует в коллекции маршрутов, вы получите InvalidOperationException();
  • Если вы примените атрибут [FromFile] к параметру, который не является IFormFile или IFormFileCollection, вы получите NotSupportedException.
  • Если указать имя в атрибуте [FromFile], применяемом к коллекции IFormFileCollection, вы получите NotSupportedException (указание имени поддерживается только для IFormFile).
  • Если вы попытаетесь использовать "вложенные" атрибуты [AsParameters] (применяя [AsParameters] к свойству), вы получите NotSupportedException.

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

CreateArgument() ищет каждый из атрибутов [From*] в порядке, показанном в приведенном ниже фрагменте. Это не правильный C#, это просто псевдокод (так что не кричите на меня), но я думаю, что так легче увидеть порядок атрибутов и некоторые из принятых "под" решений.

switch =>
{
    // If the parameter has a [FromRoute] attribute, bind to route parameter if it exists
    // if the parameter doesn't exist, throw an InvalidOperationException
    [FromRoute] when "param" exists => HttpContext.Request.RouteValues["param"],
    [FromRoute] => throw new InvalidOperationException(),

    // If the parameter has a [FromQuery] attribute, bind to query parameter
    [FromQuery] => HttpContext.Request.Query["param"],

    // If the parameter has a [FromHeader] attribute, bind to header parameter
    [FromHeader] => HttpContext.Request.Headers["param"],

    // If the parameter has a [FromBody] attribute, bind to the request stream or request body
    // depending on the parameter type
    [FromBody] => switch parmeterType
    {
        Stream stream => HttpContext.Request.Body,
        PipeReader reader => HttpContext.Request.BodyReader,
        // If a generic type, add metadata indicating the API accepts 'application/json' with type T 
        T _ => JsonSerializer.Deserialize<T>(HttpContext.Request.Body),
    },

    // If the parameter has a [FromForm] attribute, bind to the request body as a form
    // This also adds metadata indicating the API accepts the 'multipart/form-data' content-type
    [FromForm] => switch parameterType
    {
        IFormFileCollection collection => HttpRequest.Form.Files,
        IFormFile file => HttpRequest.Form.Files["param"],
        _ => throw new NotSupportedException(),
    },

    // If the parameter has a [FromServices] attribute, bind to a service in DI
    [FromServices] => HttpContext.RequestServices.GetRequiredService(parameterType),

    // If the parameter has an [AsParameters] attribute, recursively bind the properties of the parameter
    [AsParameters] => goto start!
}

Обратите внимание, что при привязке к телу запроса CreateArgument также добавляет соответствующие метаданные [Accepts], указывающие на ожидаемую форму тела запроса, выведенную из типа параметра.

Привязка известных типов

Если параметр не имеет атрибута [From*], CreateArgument проверяет, является ли он типом, который может напрямую связываться с частью HttpContext. В частности, проверяются следующие типы в следующем порядке:

  • HttpContext (привязывается к httpContext)
  • HttpRequest (привязывается к httpContext.Request)
  • HttpResponse (связывается с httpContext.Response)
  • ClaimsPrincipal (привязывается к httpContext.User)
  • CancellationToken (связывается с httpContext.RequestAborted)
  • IFormFileCollection (связывается с httpContext.Request.Form.Files и добавляет метаданные [Accepts])
  • IFormFile (привязывается к httpContext.Request.Form.Files["param"], и добавляет метаданные [Accepts])
  • Stream (привязывается к httpContext.Request.Body)
  • PipeReader (привязывается к httpContext.Request.BodyReader)

Большинство из них являются очень простыми привязками, поскольку они буквально представляют собой типы, доступные в HttpContext. Только IFormFile и IFormFileCollection требуют более сложных выражений привязки.

Привязки использующие методы BindAsync

После проверки того, является ли Type известным типом, CreateArgument() проверяет, есть ли у метода метод BindAsync. Если да, то этот метод полностью отвечает за привязку аргумента к запросу.

Вы можете реализовать один из двух методов BindAsync:

public static ValueTask<T?> BindAsync(HttpContext context, ParameterInfo parameter);
public static ValueTask<T?> BindAsync(HttpContext context);

Они не являются частью интерфейса как такового, ASP.NET Core использует отражение для проверки того, реализует ли тип параметра метод, и кэширует результат.

Привязка строки и параметры TryParse

Если вы не укажете атрибут [From*], то большинство встроенных типов (например, int и string) будут связываться с параметрами маршрута или строкой запроса. Это также относится к любым типам, которые реализуют новый интерфейс IParseable (представленный в C#11, с использованием статических абстрактных членов) или имеют один из соответствующих методов TryParse:

public static bool TryParse(string value, T out result);
public static bool TryParse(string value, IFormatProvider provider, T out result);

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

Если существует параметр маршрута с ожидаемым именем, параметр привязывается к значению маршрута. Если нет, он привязывается к значению запроса.

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

Привязка массивов к строке запроса

Следующая привязка является интересной. Она применяется только в том случае, если:

  • параметр является string[], StringValues, StringValues?, или T[], где T имеет метод TryParse()
  • DisableInferredFromBody равно true (что имеет место, например, для запросов GET и DELETE).

Если оба эти условия истинны, то параметр привязывается к строке запроса.

Помните, что строка запроса URL может содержать несколько экземпляров одного и того же ключа, например  ?q=1&q=2&q=3. Для обработчика типа (int[] q) => {} параметр q будет представлять собой массив с тремя значениями: new [] {1, 2, 3}.

Эта привязка интересна тем, что привязка массива, например int[], действительна для запросов GET и POST, но способ привязки совершенно разный. Это отличается от всех других подходов к привязке, где привязка параметров либо идентична для всех глаголов HTTP (например, атрибуты [From*] или BindAsync), либо вообще действительна только для некоторых глаголов (например, привязка сложных типов к телу запроса, как мы скоро увидим).

Но int[] — это другое. Он привязывается к разной части запроса в зависимости от глагола HTTP: для GET (и подобных) запросов он привязывается к строке запроса, а для POST (и подобных) запросов - к телу запроса, как и любой другой сложный тип.

Привязка сервисов

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

ASP.NET Core определяет, является ли данный запрос службой, которую можно внедрить, с помощью интерфейса (с интересным названием) IServiceProviderIsService. Этот интерфейс был представлен в .NET 6 специально для этой цели. Вы вызываете serviceProviderIsService.IsService(type), и если контейнер DI способен предоставить экземпляр типа, то возвращается true.

Если вы используете DI-контейнер стороннего производителя вместо встроенного, то контейнер должен реализовать IServiceProviderIsService. Это поддерживается в современных контейнерах, таких как Autofac и Lamar, но, если ваш контейнер не реализует этот интерфейс, вы не получите автоматической привязки к сервисам, и вместо этого вы должны использовать [FromService].

Последний вариант: Привязка к телу

Если ни один из других вариантов привязки не подходит, последний вариант - привязка к телу запроса. Обратите внимание, что этот вариант происходит независимо от того, находится ли запрос в GET или DELETE, в котором DisableInferredFromBody является истинным. Это "неправильное" поведение исправляется позже в методе CreateArguments() после привязки всех параметров обработчика, как я описал в предыдущей статье:

// Пытались ли мы сделать вывод о привязке к телу, когда этого делать не следовало?
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);
}

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

Помимо генерации дерева выражений для привязки к телу запроса, финальный этап привязки добавляет метаданные [Accepts] в коллекцию эндпоинта, указывая, что запрос ожидает JSON-запрос, соответствующий типу связанного параметра.

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

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

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

Я показал, что минимальная логика привязки модели API работает через семь различных категорий при определении того, какой источник использовать: [From*] атрибуты, известные типы, методы BindAsync(), методы TryParse(), привязка массива к строке запроса (для GET-запросов), DI-сервисы и, наконец, тело запроса. Используется первый источник, с которым совпадает параметр. Это определяет генерируемое выражение. В следующей статье мы рассмотрим, как на самом деле выглядят сгенерированные деревья Expression!

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