Генерация выражений аргументов для minimal API. Часть 2
В предыдущей статье серии я показал, как 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 и запись ответа.
Оригинал данной статьи вы можете прочитать по ссылке.