Генерация ответов, создание выражений для RequestDelegate
В предыдущих двух статьях я подробно рассмотрел, как метод RequestDelegateFactory.CreateArgument()
создает выражение, используемое для привязки различных типов параметров в обработчике эндпоинта. Точное выражение, необходимое для этого, различается в зависимости от типа параметра, который вы связываете, и части HttpRequest
, к которой вы его привязываете.
Точно так же, в зависимости от типа возврата вашего обработчика, вам нужно генерировать разные выражения для обработки типа возврата обработчика и записи HTTP-ответа. В этой статье мы начнем рассматривать, как RequestDelegateFactory.Create()
создает конечный RequestDelegate
, сосредоточившись на различных выражениях, которые генерируются в зависимости от сигнатуры вашего обработчика.
Генерация ответа с помощью CreateTargetableRequestDelegate.
В этой статье мы не будем рассматривать весь метод RequestDelegateFactory.Create()
. Вместо этого мы рассмотрим один из вызываемых им частных методов: CreateTargetableRequestDelegate()
. Этот метод принимает эндпоинт обработчика, использует выражения аргументов, созданные CreateArguments()
, и генерирует выражение, которое записывает ответ.
В этой статье мы рассмотрим только часть метода, связанную с написанием ответа, поэтому я остановился на некоторых других аспектах метода. Ниже показаны важные моменты:
- Постройте выражения связывания аргументов, если это еще не сделано.
- Создайте выражение
MethodCall
— это выражение, которое фактически вызывает метод обработчика, используя выражения аргументов. - Если выражения привязки аргументов включают проверку, вызывается
CreateParamCheckingResponseWritingMethodCall
, который в случае неудачи генерирует запрос400 Bad Request
. - Если валидация не требуется, вызывается
AddResponseWritingToMethodCall
. - Если в выражении аргумента используется переменная tempSourceString, добавляет ее в начало выражения.
- Добавляет выражение для чтения из тела запроса, как показано в предыдущем сообщении, и компилирует это выражение в
Func<>
, который используется для создания окончательногоRequestDelegate
.
private static Func<object?, HttpContext, Task>? CreateTargetableRequestDelegate(
MethodInfo methodInfo, // лямбда функция хэндлера
Expression? targetExpression,
RequestDelegateFactoryContext factoryContext,
Expression<Func<HttpContext, object?>>? targetFactory = null)
{
// Строит выражения аргументов, если они по какой-то причине не были построены ранее
factoryContext.ArgumentExpressions ??= CreateArgumentsAndInferMetadata(methodInfo, factoryContext);
// Создает выражение, которое фактически вызывает обработчик, передавая аргументы
factoryContext.MethodCall = CreateMethodCall(
methodInfo, targetExpression, factoryContext.ArgumentExpressions);
var returnType = methodInfo.ReturnType;
// Если какой-либо из параметров имеет валидацию (например, проверка на null), то.
// генерируем валидацию Expression. Генерирует окончательный "write response"
// Выражение в обоих случаях и присваивает его переменной.
var responseWritingMethodCall = factoryContext.ParamCheckExpressions.Count > 0 ?
CreateParamCheckingResponseWritingMethodCall(returnType, factoryContext) :
AddResponseWritingToMethodCall(factoryContext.MethodCall, returnType);
// Если какой-либо из параметров использует для разбора переменную tempSourceString.
// то добавляет определение переменной в начало
if (factoryContext.UsingTempSourceString)
{
responseWritingMethodCall = Expression.Block(
new[] { TempSourceStringExpr },
responseWritingMethodCall);
}
// Добавляет чтение тела запроса и компилирует выражение в Func<>
return HandleRequestBodyAndCompileRequestDelegate(responseWritingMethodCall, factoryContext);
}
Большая часть метода заключается в создании выражения для записи ответа, но он также добавляет определение tempSourceString
, если это необходимо. По сути, он добавляет следующее в начало Expression
:
string tempSourceString;
используя следующее определение:
internal static readonly ParameterExpression TempSourceStringExpr =
Expression.Variable(typeof(string), "tempSourceString");
Функция CreateMethodCall()
принимает ссылку на метод-обработчик, "target" вызова метода и выражения аргументов. "Target" — это объект (если таковой имеется), на который вызывается метод обработчика. Для minimal API, использующих статические методы, цель равна null
, но если вы используете метод экземпляра или лямбду, она содержит ссылку на объект:
private static Expression CreateMethodCall(
MethodInfo methodInfo, Expression? target, Expression[] arguments) =>
target is null ?
Expression.Call(methodInfo, arguments) :
Expression.Call(target, methodInfo, arguments);
После компиляции это означает либо вызов handler(arg1, arg2)
, либо target.handler(arg1, arg2)
в зависимости от того, является ли target null.
В следующем разделе мы рассмотрим метод CreateParamCheckingResponseWritingMethodCall()
, который вызывается выше, когда привязка параметров к запросам может оказаться неудачной.
Добавление проверок валидности параметров в выражение.
Легче всего понять поведение CreateParamCheckingResponseWritingMethodCall()
, думая о выражениях, которые добавляет CreateArgument()
. Например, если мы возьмем следующий простой эндпоинт minimal API:
app.MapGet("/". (string q) => {});
Из предыдущих статей мы увидели, что начальный RequestDelegate
выглядит следующим образом:
Task Invoke(HttpContext httpContext)
{
bool wasParamCheckFailure = false;
string q_local;
q_local = httpContext.Request.Query["q"];
if (q_local == null)
{
wasParamCheckFailure = true;
Log.RequiredParameterNotProvided(httpContext, "string", "q", "query");
}
if(wasParamCheckFailure)
{
httpContext.Response.StatusCode = 400;
return Task.CompletedTask;
}
handler.Invoke(q_local);
}
Метод CreateArgument()
создает не все из перечисленного выше. CreateArgument()
создает три части RequestDelegate
как выражения, и хранит каждое выражение в отдельной коллекции в RequestDelegateFactoryContext factoryContext
:
- Определение любых локальных переменных
(q_local)
хранится вfactoryContext.ExtraLocals
. - Ссылка на аргумент, переданный в функцию-обработчик
(q_local)
, в итоге хранится вfactoryContext.ArgumentTypes
. - Выражение проверки параметров хранится в
factoryContext.ParamCheckExpressions
.
Выражение проверки параметров в данном случае состоит из присвоения q_local
проверки на null
и записи того, что данная проверка не пройдена:
q_local = httpContext.Request.Query["q"];
if (q_local == null)
{
wasParamCheckFailure = true;
Log.RequiredParameterNotProvided(httpContext, "string", "q", "query");
}
Учитывая это, давайте посмотрим, как CreateParamCheckingResponseWritingMethodCall()
использует коллекции Expression
для создания RequestDelegate
. Я добавил комментарии к методу ниже, чтобы сделать его более понятным.
private static Expression CreateParamCheckingResponseWritingMethodCall(
Type returnType, // тип возврата нашего обработчика эндпоинта, который в данном примере равен void
RequestDelegateFactoryContext factoryContext)
{
// Создает массив для хранения всех необходимых нам локальных переменных (например, q_local)
// и копирует переменную Expressions. +1 переменная добавляется для
// дополнительной переменной wasParamCheckFailure, которая нам нужна
var localVariables = new ParameterExpression[factoryContext.ExtraLocals.Count + 1];
for (var i = 0; i < factoryContext.ExtraLocals.Count; i++)
{
localVariables[i] = factoryContext.ExtraLocals[i];
}
// Добавляет переменную `bool wasParamCheckFailure`.
localVariables[factoryContext.ExtraLocals.Count] = WasParamCheckFailureExpr;
// Создает массив для хранения выражений проверки параметров
// и копирует выражения проверки. +1 добавляется к длине
// массива для хранения блока, который проверяет, было ли выражение успешным
var checkParamAndCallMethod = new Expression[factoryContext.ParamCheckExpressions.Count + 1];
for (var i = 0; i < factoryContext.ParamCheckExpressions.Count; i++)
{
checkParamAndCallMethod[i] = factoryContext.ParamCheckExpressions[i];
}
// Если фильтры зарегистрированы, мы должны позволить им работать.
// даже если произошел сбой привязки. Подробнее об этом в следующей статье
if (factoryContext.EndpointBuilder.FilterFactories.Count > 0)
{
// ... пока игнорируем фабрики фильтров
}
else
{
// Строим выражение, которое выглядит примерно следующим образом
// if(wasParamCheckFailure)
// {
// httpContext.Response.StatusCode = 400;
// return Task.CompletedTask;
// }
// else
// return handler(q_local); // Added by AddResponseWritingToMethodCall()
var checkWasParamCheckFailure = Expression.Condition(
WasParamCheckFailureExpr,
Expression.Block(
Expression.Assign(StatusCodeExpr, Expression.Constant(400)),
CompletedTaskExpr),
AddResponseWritingToMethodCall(factoryContext.MethodCall!, returnType));
// Добавляем выражение в коллекцию
checkParamAndCallMethod[factoryContext.ParamCheckExpressions.Count] = checkWasParamCheckFailure;
}
// Объединяем выражения переменных с проверкой параметров и ответами
// записываем выражения, вызывая каждое по очереди, чтобы построить единое выражение
return Expression.Block(localVariables, checkParamAndCallMethod);
}
В финальном выражении, сгенерированном выше, мы вызываем AddResponseWritingToMethodCall()
. Он также вызывается непосредственно в CreateTargetableRequestDelegate()
, когда у нас нет никаких выражений проверки параметров.
Генерация ответа с помощью функции AddResponseWritingToMethodCall().
Последний метод, который мы рассмотрим в этой статье - AddResponseWritingToMethodCall()
. Этот метод отвечает за две вещи:
- Вызов метода обработчика в виде выражения
Expression
, созданного ранее с помощьюCreateMethodCall
. - Обработка возвращаемого значения метода обработчика путем записи ответа в
HttpResponse
, если требуется.
Тело AddResponseWritingToMethodCall()
— это, по сути, гигантский оператор if...else
, переключающий тип возврата метода-обработчика и генерирующий соответствующее выражение. Вместо того, чтобы показывать 15 с лишним пунктов if
, я кратко описал, как обрабатывается каждый тип возврата, показав примерный эффективный код C#, сгенерированный для каждого случая, с примером метода обработчика с типом возврата.
Возвращаемый тип: void
Для простейших методов обработчика, которые ничего не возвращают, тип возврата - void:
app.MapGet("/", (string q) => {})
Здесь ничего не нужно делать, кроме как вызывать обработчик, поэтому код вызова выглядит примерно так:
handler.Invoke(q_local); // вызвать обработчик
return Task.CompletedTask;
Возвращаемый тип: string
Следующим простейшим обработчиком является строковый ответ:
app.MapGet("/", () => "Hello world!");
В этом случае делегат устанавливает Content-Type
ответа на text/plain
и сериализует возвращаемое обработчиком значение в выходной поток:
string text = handler.Invoke(); // эта переменная фактически вставлена, но включена для ясности
httpContext.Response.ContentType ??= "text/plain; charset=utf-8";
return httpContext.Response.WriteAsync(text);
Возвращаемый тип: IResult
string
— это один из специальных возвращаемых типов в minimal API, а IResult
- другой. Объект IResult
отвечает за запись ответа в поток ответа и установку любых необходимых Content-Type
или кодов состояния.
Статические помощники Results
и TypedResults
являются обычным способом возврата IResult
:
app.Map("/", () => Results.NoContent());
Для делегата это означает, что код выглядит примерно так:
var result = handler.Invoke(); // эта переменная фактически вставлена, но включена для ясности
return ExecuteResultWriteResponse(result, httpContext); // вызов хелпера для запуска ExecuteAsync
private static async Task ExecuteResultWriteResponse(
IResult? result, HttpContext httpContext)
{
if (result is null)
{
throw new InvalidOperationException("IResult возвращаемый делегатом не может быть null.");
}
await result.ExecuteAsync(httpContext);
}
Самое интересное здесь то, что "главный" RequestDelegate
не вызывает await
напрямую. Вместо этого он возвращает результат асинхронного метода ExecuteResultWriteResponse
. В построении выражений много таких "непрямых" действий, поскольку они облегчают некоторую композитивность выражений.
Еще один момент, о котором следует помнить, если вы пишете пользовательские объекты IResult
: если ваш обработчик возвращает значение типа (struct) IResult
, произойдет боксинг перед передачей в вызов метода. Это, вероятно, устраняет большинство потенциальных преимуществ создания реализаций struct IResult
.
Возвращаемый тип: JSON
Если вы возвращаете любой другой тип (не Task), он сериализуется в тело ответа как JSON. Например:
app.Map("/", () => new { Message = "Hello world!"});
RequestDelegate
использует метод HttpResponseJsonExtensions.WriteAsJsonAsync()
для сериализации типа в HttpResponse
. Вы увидите, что он использует негенеративную версию метода WriteAsJsonAsync()
вместо генеративной, что позволяет избежать проблем с полиморфизмом генератора исходного кода.
var result = handler.Invoke();
return WriteJsonResponse(httpContext.Response, result); // вызов хелпера для запуска ExecuteAsync
private static Task WriteJsonResponse(HttpResponse response, object? value)
{
return HttpResponseJsonExtensions.WriteAsJsonAsync(
response,
value,
value is null ? typeof(object) : value.GetType(),
default);
}
Это охватывает все "встроенные" типы: string
, IResult
и "другие DTO".
Возвращаемый тип: object
При возврате объекта делегат должен проверить, является ли возвращаемый объект одним из поддерживаемых типов сериализации, таких как IResult
или string
, и обработать его соответствующим образом. Если это не один из этих типов, он сериализует результат в JSON. В результате происходит то, что вы ожидаете: вызывается один из трех уже показанных методов.
Таким образом, для API, который выглядит следующим образом:
app.Map("/", () => (object) "Hello world!");
вы получите следующую обработку ответа:
// вызываем обработчик и передаем результат в ExecuteAwaitedReturn
return ExecuteAwaitedReturn(handler.Invoke(), httpContext);
private static Task ExecuteAwaitedReturn(object obj, HttpContext httpContext)
{
// Встроенные терминалы
if (obj is IResult result)
{
return ExecuteResultWriteResponse(result, httpContext);
}
else if (obj is string stringValue)
{
httpContext.Response.ContentType ??= "text/plain; charset=utf-8";
return httpContext.Response.WriteAsync(stringValue);
}
else
{
// В противном случае, мы сериализуем JSON, когда достигаем состояния терминала
return WriteJsonResponse(httpContext.Response, obj);
}
}
Это охватывает все основные типы, которые вы можете вернуть для обработчика, и, наконец, мы рассмотрим различные типы Task
.
Возвращаемый тип: Task или ValueTask
Самые простые случаи — это когда вы возвращаете Task
или ValueTask
. Например:
app.Map("/", () =>
{
return Task.CompletedTask;
});
Здесь все просто, мы буквально просто возвращаем обработчик:
return handler.Invoke();
Версия ValueTask
, использующая, например, ValueTask.CompletedTask
или new ValueTask()
, немного сложнее, поскольку ей необходимо преобразовать ValueTask
в Task
:
return ExecuteValueTask(handler.Invoke());
static Task ExecuteValueTask(ValueTask task)
{
// Если задача ValueTask уже завершена (обычный случай синхронизации)
// Мы можем извлечь значение и вернуть
if (task.IsCompletedSuccessfully)
{
task.GetAwaiter().GetResult();
return Task.CompletedTask;
}
// в противном случае нам нужно ожидать его (асинхронный случай)
// Выполнение await в отдельном методе означает, что мы не платим за стоимость
// машины состояния async для обычного случая синхронизации
return ExecuteAwaited(task);
static async Task ExecuteAwaited(ValueTask task)
{
await task;
}
}
Это касается негенеративных случаев Task
, теперь мы рассмотрим генеративные случаи.
Возвращаемый тип: Task<T> или ValueTask<T>
Возврат Task<T>
или ValueTask<T>
— это, вероятно, одни из самых распространенных типов возврата для minimal API: вы получаете запрос, направляете его на выполнение чего-то асинхронного, например, чтения/записи из базы данных, а затем возвращаете DTO с результатами:
app.MapGet("/", async (IWidgetService service) =>
{
var widgets = await service.GetAll();
return widgets.Select(widget => new { widget.Id, widget.Name });
});
Как и следовало ожидать, код обработки для Task<T>
и ValueTask<T>
вызывает await
на результате обработчика, затем вызывает тот же код сериализации, который вы уже видели в этой статье, в зависимости от того, является ли возвращаемый объект string
, IResult
или чем-то еще.
Я не собираюсь перечислять все эти комбинации, поэтому давайте рассмотрим пример для Task<T>
выше:
var result = handler.Invoke(
httpContext.RequestServices.GetRequiredService<IWidgetService>());
// RequestDelegateFactory считывает параметры общего типа
// из Task<T> в результате, и вызывает общий ExecuteTaskOfT
return ExecuteTaskOfT<IEnumerable<WidgetDto>>(result, httpContext);
private static Task ExecuteTaskOfT<T>(Task<T> task, HttpContext httpContext)
{
// Не возвращаем null как Task, никогда!
if (task is null)
{
throw new InvalidOperationException("The Task returned by the Delegate must not be null.");
}
// Если Task уже выполнено, получите результат
// и немедленно вызовите WriteJsonResponse
if (task.IsCompletedSuccessfully)
{
return WriteJsonResponse(httpContext.Response, task.GetAwaiter().GetResult());
}
// Если он еще не завершился, ожидайте вызова
return ExecuteAwaited(task, httpContext);
static async Task ExecuteAwaited(Task<T> task, HttpContext httpContext)
{
// ожидать выполнения задания, а затем ожидать вызова WriteJsonResponse
await WriteJsonResponse(httpContext.Response, await task);
}
}
// Этот метод WriteJsonResponse - тот же самый, который используется, когда вы
// возвращаете объект T напрямую
private static Task WriteJsonResponse(HttpResponse response, object? value)
{
return HttpResponseJsonExtensions.WriteAsJsonAsync(
response, value,value is null ? typeof(object) : value.GetType(), default);
}
Здесь больше кода, но нет ничего слишком сложного:
Проверьте результат обработчика, завершилась ли уже задача Task<T>
?
- Если да, записываем результат с помощью
WriteJsonResponse()
- Если нет, ожидаем
Task
, а затем записываем результат с помощьюWriteJsonResponse()
.
Метод WriteJsonResponse
-— это тот же метод, который вызывается, когда вы возвращаете T (без Task<>
). Вызов await
обернут в локальную функцию, как и негенерический обработчик Task
, чтобы избежать выделения машины состояния async
, когда Task
завершилась синхронно (я предполагаю).
Для полноты я покажу еще один - возвращающий ValueTask<string>
, что-то вроде:
app.MapGet("/", () => new ValueTask("Hello world!"));
Тут всё без сюрпризов:
var result = handler.Invoke();
// RequestDelegateFactory читает параметры обобщенного типа
// из Task<T> в result, и вызывает ExecuteValueTaskOfString
return ExecuteValueTaskOfString(result, httpContext);
private static Task ExecuteValueTaskOfString(ValueTask<string?> task, HttpContext httpContext)
{
httpContext.Response.ContentType ??= "text/plain; charset=utf-8";
// Если ValueTask уже выполнено (общий синхронный случай)
// получаем result и пишем его прямо в response
if (task.IsCompletedSuccessfully)
{
return httpContext.Response.WriteAsync(task.GetAwaiter().GetResult()!);
}
// Если еще не завершилось, то ожидаем
return ExecuteAwaited(task, httpContext);
static async Task ExecuteAwaited(ValueTask<string> task, HttpContext httpContext)
{
// ждем ValueTask, и затем ждем запись response
await httpContext.Response.WriteAsync(await task);
}
}
На этом мы закончили рассмотрение ответов. Фактический код, созданный здесь, довольно прост и интуитивно понятен, он учитывает четыре поддерживаемых типа возврата ( void
, string
, IResult
и T
), а также их эквиваленты Task
/ Task<T>
/ ValueTask<T>
. Кроме того, это в основном защитное программирование и оптимизация производительности.
В этой серии мы многое рассмотрели, поэтому в следующей статье мы соберем все это вместе и рассмотрим, как метод RequestDelegateFactory.Create()
создает окончательный RequestDelegate
для эндпоинта.
Подводя итог.
В этой статье мы рассмотрели делегат CreateTargetableRequestDelegate
и сосредоточились на коде, который он производит и который обертывает вызов метода обработчика. В частности, мы рассмотрели два вызова метода:
CreateParamCheckingResponseWritingMethodCall()
обрабатывает случай, когда возникла проблема с привязкой параметров запроса, и генерирует ответ400 Bad Request
.AddResponseWritingToMethodCall()
обрабатывает генерацию окончательного ответа путем сериализации возвращаемого значения обработчика в ответ (и вызоваawait
на ответеTask
, например).
Это были две недостающие части в генерации всего RequestDelegate
для эндпоинта, поэтому в следующей статье мы соберем все вместе, рассмотрев RequestDelegateFactory.Create()
.
С оригиналом статьи вы можете ознакомиться по ссылке.