Генерация ответов, создание выражений для RequestDelegate

Генерация ответов, создание выражений для RequestDelegate
Photo by Priscilla Du Preez / Unsplash

В предыдущих двух статьях я подробно рассмотрел, как метод 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().

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