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

Генерация выражений аргументов для minimal API. Часть 2
Photo by Tyler Casey / Unsplash

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

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

Привязка необязательных массивов строк или StringValues? к строке запроса.

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

То же самое происходит и при привязке параметров массива: привязка string[] или StringValues значительно проще, чем привязка массивов TryParse типов, таких как int[].

StringValues — это структура, доступная только для чтения, которая эффективно представляет 0/null, одну или много строк. Она может быть приведена к строке и из строки, и из массива строк.

Например, рассмотрим следующий эндпоинт, который связывает массив q со значениями строк запроса. Например, для запроса типа /?q=Mark&q=John массив будет иметь два значения, "Mark" и "John":

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

По сути, как и в предыдущем сообщении, выражение связывания может просто взять значения из строки запроса/значений маршрута/заголовков и передать их обработчику напрямую. Свойства Query, Headers и т. д. напрямую преобразуются в string[], так что много работы делать не нужно:

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

    return Task.CompletedTask;
}

Этот сгенерированный код по сути идентичен тому, когда вы связываете строковый параметр; его можно использовать в обоих случаях, поскольку значение StringValues, возвращаемое из Query["q"], напрямую преобразуется как в string, так и в string[]!

Привязка требуемых массивов строк или StringValues.

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

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

В этом случае, теоретически, привязка Expression остается простой, но теперь RequestDelegate должен проверить, что в строке запроса точно есть значение, которое совпадает. Если нет, эндпоинт должен вернуть ответ 400 BadRequest.

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

    q_local = httpContext.Request.Query["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;
}
Кроме того, в поведении параметров string[] и string[]? есть несоответствие по сравнению с другими типами требуемых и обнуляемых параметров. Теоретически, если вы не предоставите значение для строки запроса, вы можете ожидать, что параметр string[]? будет иметь значение null (как было предложено в предыдущем разделе), а параметр string[]? вернет ошибку 400 Bad Request, как показано в приведенном выше коде.
Однако это не так. Если вы вызовете эти API и не предоставите ожидаемую строку запроса, параметр никогда не будет равен null и не вернет ошибку 400 Bad Request - вместо этого вы получите пустой массив. В настоящее время это противоречит документации (и поведению StringValues, которые возвращают 400), поэтому я открыл issue по этому поводу, где описал причины различий. Консенсус по этому вопросу, похоже, таков: "По замыслу, исправлять не будем".

Все это выглядит довольно похоже на код, который вы видели в предыдущем посте для привязки к строке, это не удивительно, учитывая, что StringValues могут быть автоматически приведены как к строке, так и к string[]. Сложнее обстоит дело, если у нас есть массив типов, где необходимо вызвать TryParse.

Привязка int[] к строке запроса.

В следующем примере мы рассмотрим привязку параметра int?[] к строке запроса:

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

В этом случае нам нужно сначала извлечь string[] из строки запроса во временную переменную ( tempStringArray ), проверить, является ли каждое значение null, и, если нет, каждое из значений попытаться спарсить (TryParse) в int. Это значительно усложняет код!

Task Invoke(HttpContext httpContext)
{
    string tempSourceString; // Добавлено из RequestDelegateFactory.Create()
    bool wasParamCheckFailure = false; // Добавлено из RequestDelegateFactory.Create()
    int?[] q_local;
    
    // Прасим запрос во временный string[]
    string[] tempStringArray = httpContext.Request.Query["q"];

    if (tempStringArray != null)
    {
        // Инициализируем переменную, которая зафиксирует финальное значение
        q_local = new int[tempStringArray.Length];

        int index = 0;
        while(true)
        {
            if(index < tempStringArray.Length)
            {
                // Проходимся по каждой строке массива
                tempSourceString = tempStringArray[index];

                // если строка пустая, например в URL /q=&q=
                // значит оставляем в дефолтном значении
                if(!string.IsNullOrEmpty(tempSourceString))
                {
                    // Пробуем распарсить значение
                    if (int.TryParse(tempSourceString, out var parsedValue))
                    {
                        // Успешно распарсили, кастуем к int? и
                        q_local[i] = (int?)parsedValue;
                    }
                    else
                    {
                        // неудачный парсинг
                        wasParamCheckFailure = true;
                        Log.ParameterBindingFailed(httpContext, "Nullable<int>[]", "query", tempSourceString, true);
                    }
                }
            }
            else
            {
                break;
            }
    
            index++
        }
    }
    else
    {
        // 👇 На сколько я могу судить, этот код на самом деле не может быть выполнен из-за 
        // https://github.com/dotnet/aspnetcore/issues/45956
        wasParamCheckFailure = true;
        Log.ParameterBindingFailed(httpContext, "Int32[]", "q", "query", tempSourceString, true);
    }

    if(wasParamCheckFailure) // Получено из RequestDelegateFactory.Create()
    {
        httpContext.Response.StatusCode = 400;
        return Task.CompletedTask;
    }

    handler.Invoke(q_local); 

    return Task.CompletedTask;
}

Возможно, вы подумаете, что этот код выглядит немного странно: цикл while со вложенным if - почему бы просто не использовать цикл for или foreach. Мое самое простое предположение: этот код легче писать, когда вы все делаете в виде выражений! В нем также много дублирующихся блоков Expression между различными типами параметров, поэтому я не склонен критиковать его. Если вы хотите действительно понять, посмотрите на 250 строк приседаний с Expression в BindParameterFromValue()!

Привязка обязательных сервисов из DI.

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

var builder = WebApplication.CreateBuilder();
builder.Services.AddSingleton<MyService>();

var app = builder.Build();
app.MapGet("/", (MyService service) => {});
app.Run()

class MyService {}

Эндпоинт minimal API автоматически определяет, что тип MyService доступен в DI, и привязывает параметр обработчика к сервису с помощью GetRequiredService().

async Task Invoke(HttpContext httpContext)
{
    handler.Invoke(
        httpContext.RequestServices.GetRequiredService<MyService>()); 

    return Task.CompletedTask;
}

Привязка опциональных сервисов из DI.

"Необязательные" сервисы, когда сервис может быть зарегистрирован в DI, а может и не быть, вообще кажутся мне плохим шаблоном, но они поддерживаются непосредственно minimal API. Обычно вам нужно использовать атрибут [FromServices], чтобы minimal API поняли, что это DI-сервис, и пометили параметр как необязательный с помощью ?

var builder = WebApplication.CreateBuilder();
var app = builder.Build();
app.MapGet("/", ([FromServices] MyService? service) => {});
app.Run()

Результирующее выражение очень похоже на случай с "обязательным" сервисом. Единственное отличие - необязательные сервисы используют GetService вместо GetRequiredService.

async Task Invoke(HttpContext httpContext)
{
    handler.Invoke(
        httpContext.RequestServices.GetService<IService>()); 

    return Task.CompletedTask;
}

Привязка к необязательным файлам форм с помощью IFormFileCollection и IFormFile.

Далее мы рассмотрим привязку IFormFile и IFormFileCollection. Возможно, вы помните из предыдущей статьи, что это два "известных" типа, к которым minimal API могут привязываться напрямую. Они примерно одинаковы по способу привязки: IFormFileCollection привязывается ко всем файлам формы, а IFormFile привязывается к одному файлу, названному "file" в следующем примере:

app.MapGet("/", (IFormFile? file) => {});

Файлы формы открываются непосредственно в ASP.NET Core через свойство HttpRequest.Form.Files, поэтому Expression очень похож на тот, когда мы привязывали строку или свойство StringValues к строке запроса:

Task Invoke(HttpContext httpContext)
{
    handler.Invoke(
        httpContext.Request.Form.Files["file"] != null 
            ? httpContext.Request.Form.Files["file"]
            : (IFormFile) null); 

    return Task.CompletedTask;
}

Что не показано в приведенном выше делегате, так это код, который фактически считывает форму из тела, как первый шаг конечного RequestDelegate. Процесс выполнения этого действия немного запутан, но, по сути, он вызывает следующий метод TryReadFormAsync, и если он может выполниться, то сразу же возвращается.

static async Task<(object? FormValue, bool Successful)> TryReadFormAsync(
    HttpContext httpContext,
    string parameterTypeName,
    string parameterName,
    bool throwOnBadRequest)
{
    object? formValue = null;
    var feature = httpContext.Features.Get<IHttpRequestBodyDetectionFeature>();

    if (feature?.CanHaveBody == true)
    {
        if (!httpContext.Request.HasFormContentType)
        {
            Log.UnexpectedNonFormContentType(httpContext, httpContext.Request.ContentType, throwOnBadRequest);
            httpContext.Response.StatusCode = StatusCodes.Status415UnsupportedMediaType;
            return (null, false);
        }

        try
        {
            formValue = await httpContext.Request.ReadFormAsync();
        }
        catch (IOException ex)
        {
            Log.RequestBodyIOException(httpContext, ex);
            return (null, false);
        }
        catch (InvalidDataException ex)
        {
            Log.InvalidFormRequestBody(httpContext, parameterTypeName, parameterName, ex, throwOnBadRequest);
            httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
            return (null, false);
        }
    }

    return (formValue, true);
}

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

Привязка к обязательному файлу формы с помощью IFormFileCollection и IFormFile.

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

app.MapGet("/", (IFormFileCollection files) => {});

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

Task Invoke(HttpContext httpContext)
{
    bool wasParamCheckFailure = false;
    IFormFileCollection files;

    files = httpContext.Request.Form.Files
    if (files == null)
    {
        wasParamCheckFailure = true;
        Log.RequiredParameterNotProvided(httpContext, "IFormFileCollection", "files", "body");
    }

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

    handler.Invoke(files); 

    return Task.CompletedTask;
}
TryReadFormAsync() вызывается в начале RequestDelegate, как показано в предыдущем примере.

Ну вот и все, теперь мы закончили с "легкими" выражениями связывания. Вернемся к более сложным вещам...

Привязка BindAsync типов.

До этого момента я показывал примеры "окончательного" RequestDelegate, созданного RequestDelegateFactory.Create(), используя определения параметра Expression, созданные в методе CreateArgument(). Но я несколько лукавил: по техническим причинам RequestDelegate часто не так "чист", как показанные мной примеры.

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

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

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

Представим, что у нас есть тип MyType, у которого есть метод BindAsync, который мы используем в minimal API.

app.MapGet("/", (MyType t) => {});

Чтобы сгенерировать выражение параметра для параметра t, CreateArgument() вызывает метод BindParameterFromBindAsync(). Это создает Func<HttpContext, ValueTask<object?>>, который вызывает метод BindAsync нашего типа, примерно так:

// Тут всё очень примерно, за большими разъяснениями идите в 
// https://github.com/dotnet/aspnetcore/blob/v7.0.1/src/Shared/ParameterBindingMethodCache.cs#L195
MethodInfo bindAsyncMethod = typeof(MyType).GetMethod("BindAsync");

var bindAsyncDelegate = Expression.Lambda<Func<HttpContext, ValueTask<object?>>>(
    bindAsyncMethod, HttpContextExpr).Compile();

Конечным результатом является Func<>, который при вызове создает экземпляр MyType с параметром HttpContext (или может вызвать исключение).

Метод CreateArgument() добавляет bindAsyncDelegate в коллекцию RequestDelegateFactoryContext.ParameterBinders. RequestDelegateFactory.Create() затем использует этот список для связывания всех BindAsync-овых параметров перед вызовом "тела" RequestDelegate.

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

  • здесь представлена немного запутанная смесь кода, созданного с помощью Expression и компиляции, и простых закрытий Func<>. Код, который я показываю ниже, является лучшей попыткой показать результат после компиляции Expression и комбинирования с различными Func<>, но он не является четким тет-а-тет с реальным кодом.
  • Внутренний Func<> (который я назвал generateResult) использует захваченные значения закрытия. В предыдущих случаях я не обращал на это внимания, но в данном случае очевидно, что коллекция ParameterBindings захватывается напрямую.
  • целевой параметр во внутреннем продолжении — это экземпляр, на котором определен метод обработчика. Для обработчиков лямбда-эндпоинтов это сгенерированный класс закрытия для делегата, но для статических обработчиков это может быть и null.

Поскольку большая часть этого кода создается RequestDelegateFactory.Create(), я выделил те части, которые создаются CreateArgument специально.

// Они создаются вне метода RequestDelegate и перехватываются 
// закрытием RequeustDelegate Func<>
var binders = factoryContext.ParameterBinders.ToArray();
// Длина равна количеству параметров, реализующих BindAsync
var count = binders.Length;

async Task Invoke(HttpContext httpContext)
{
    // целевой объект, передаваемый здесь, является объектом "обработчика", если таковой имеется
    var Task Invoke(object? target, HttpContext httpContext)
    {
        // В этом массиве хранится массив значений параметров
        // которые привязаны с помощью BindAsync к HttpContext
        var boundValues = new object?[count];
        for (var i = 0; i < count; i++)
        {
            // Вызов Func<> для каждого параметра и запись результата
            // Для этого примера создается один объект MyType
            boundValues[i] = await binders[i](httpContext);
        }

        // определяем продолжение, которое вызывается после связывания
        var generateResult = (object? target, HttpContext httpContext, object?[] boundValues) =>
        {
            bool wasParamCheckFailure = false;

            // Эта проверка выдается только в том случае, если параметр является обязательным
            // и является одной из немногих частей, выдаваемых CreateArgument()
            if(boundValues[0] == null)
            {
                wasParamCheckFailure = true;
                Log.RequiredParameterNotProvided(httpContext, "MyType", "t", "MyType.BindAsync(httpContext)", true);
            }

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

            // CreateArgument() выдает выражение, которое приводит 
            // связанное значение к типу параметра, MyType
            target.handler.Invoke((MyType)boundValues[0]); 

            return Task.CompletedTask;
        };

        // вызываем Func<>, передавая значения из внешней функции
        generateResult(target, httpContext, boundValues);
    }

    // Вызовите внутренний метод, передав экземпляр лямбда-закрытия в качестве цели
    return Invoke(target: Program.<>c, httpContext);
}

Если вы запутались, я вас не виню. В приведенном примере много ненужных "оберток" во внутренних переменных Func, что немного усложняет процесс. В предыдущих примерах я "сгладил" эти вызовы Func<>, но оставил их здесь для полноты картины. Я все еще не решил, жалею ли я об этом выборе 😅.

Обратите внимание, что реальный RequestDelegate, сгенерированный RequestDelegateFactory, не имеет переменных Func<>, таких как generateResult и т.д.. Выражения компилируются в Func<> и вызываются непосредственно при построении секций. Приведенное выше — это моя попытка представить , не зацикливаясь на деталях слишком сильно!

Привязка тела запроса к типу.

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

Представим, что у нас есть тот же API, но на этот раз тип MyType не реализует BindAsync:

app.MapGet("/", (MyType t) => {});

По умолчанию этот API связывает параметр t с телом запроса. Это первое, что происходит в создаваемом RequestDelegate, который вызывает вспомогательную функцию TryReadBodyAsync. Как и раньше, почти все это создается RequestDelegateFactory.Create(), а не CreateArgument(), но я включил это сюда для полноты.

async Task Invoke(HttpContext httpContext)
{
    // пытаемся прочитать тело запроса
    var (bodyValue, successful) = await TryReadBodyAsync(
        httpContext,
        typeof(MyType),
        "MyType",
        "t",
        false, // factoryContext.AllowEmptyRequestBody
        true); // factoryContext.ThrowOnBadRequest

    // если что то пошло не так, выходим
    if (!successful)
    {
        return;
    }

    // определяем Func<> которая вызывает обработчик
    var generateResponse = (object? target, HttpContext httpContext, object? bodyValue) =>
    {
        bool wasParamCheckFailure = false;
        
        if (bodyValue == null) // это состояние создано в CreateArgument()
        {
            wasParamCheckFailure = true;
            Log.ImplicitBodyNotProvided(httpContext, "MyType", true);
        }

        handler.Invoke((MyType)bodyValue);  // кастуем созданное в CreateArgument()

        return Task.CompletedTask;
    };

    // Вызвать продолжение, передав null в качестве цели
    await generateResponse(target: null, httpContext, bodyValue);
}

// Эта вспомогательная функция считывает тело запроса и создает 
// экземпляр объекта с использованием System.Text.Json
static async Task<(object? FormValue, bool Successful)> TryReadBodyAsync(
    HttpContext httpContext,
    Type bodyType,
    string parameterTypeName,
    string parameterName,
    bool allowEmptyRequestBody,
    bool throwOnBadRequest)
{
    object? defaultBodyValue = null;

    if (allowEmptyRequestBody && bodyType.IsValueType)
    {
        defaultBodyValue = CreateValueType(bodyType);
    }

    var bodyValue = defaultBodyValue;
    var feature = httpContext.Features.Get<IHttpRequestBodyDetectionFeature>();

    if (feature?.CanHaveBody == true)
    {
        if (!httpContext.Request.HasJsonContentType())
        {
            Log.UnexpectedJsonContentType(httpContext, httpContext.Request.ContentType, throwOnBadRequest);
            httpContext.Response.StatusCode = StatusCodes.Status415UnsupportedMediaType;
            return (null, false);
        }
        try
        {
            bodyValue = await httpContext.Request.ReadFromJsonAsync(bodyType);
        }
        catch (IOException ex)
        {
            Log.RequestBodyIOException(httpContext, ex);
            return (null, false);
        }
        catch (JsonException ex)
        {
            Log.InvalidJsonRequestBody(httpContext, parameterTypeName, parameterName, ex, throwOnBadRequest);
            httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
            return (null, false);
        }
    }

    return (bodyValue, true);

Одна вещь, не показанная в приведенном выше коде, заключается в том, что CreateArgument() добавляет дополнительные метаданные [Accepts] в коллекцию метаданных как часть вызова InferMetadata, указывая, что конечная точка ожидает тело запроса JSON MyType.

Этот шаблон вложенных вызовов Func<> является тем, как вызывается метод TryReadFormAsync() при связывании IFormFile. Схема чтения тела идентична, отличается только обработка тела.

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

Основные выводы из этой статьи заключаются в том, что CreateArgument() иногда выполняет много работы. Выражение для чтения параметра int[], например, является значительным. В отличие от этого, привязка к DI-сервису — это тривиальное выражение!

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

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

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

В предыдущей и этой статьях я показал примеры некоторых деревьев выражений, создаваемых для определенных типов параметров. В этом посте я начал с демонстрации привязки массивов к строке запроса, а затем показал привязку DI-сервисов и файлов IFormFile.

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

Код примера показывает, как генерируется и вызывается несколько вложенных Func<>. Это ближе к "реальному" поведению скомпилированного RequestDelegate, но это делает чтение более запутанным!

В следующем посте мы рассмотрим другой раздел сгенерированного RequestDelegate: обработку типа возврата minimal API и запись ответа.

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