Настройка RequestDelegate с помощью фильтров

Настройка RequestDelegate с помощью фильтров
Photo by Vincent Ghilione / Unsplash

В этой серии статей мы рассмотрели генерацию кода для простых эндпоинтов минимального API. В рамках этого процесса я игнорировал функцию "filters", добавленную в .NET 7, поскольку она добавляет определенную сложность. Что ж, в этой статье мы столкнемся с этой сложностью лицом к лицу и посмотрим, как добавление фильтра изменяет конечный сгенерированный RequestDelegate.

RequestDelegate для минимального API без фильтров.

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

app.MapGet("/{name}", (string name) => $"Hello {name}!");

Если вы следили за этой серией статей, вы сможете понять, что сгенерированный делегат выглядит примерно так:

Task (HttpContext httpContext) => TargetableRequestDelegate(Program.<>c, httpContext);

где TargetableRequestDelegate выглядит примерно так:

Task TargetableRequestDelegate(object? target, HttpContext httpContext)
{
    string name_local;
    bool wasParamCheckFailure;

    name_local = httpContext.Request.RouteValues["name"]
    if (name_local == null)
    {
        wasParamCheckFailure = true;
        Log.RequiredParameterNotProvided(httpContext, "string", "name", "route");
    }

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

    return ExecuteWriteStringResponseAsync(
        httpContext,
        handler(name_local));
}

Я не показал здесь метод ExecuteWriteStringResponseAsync(), но, по существу, он выглядит следующим образом, как было показано в предыдущей статье:

Task ExecuteWriteStringResponseAsync(HttpContext httpContext, string text)
{
    httpContext.Response.ContentType ??= "text/plain; charset=utf-8";
    return httpContext.Response.WriteAsync(text);
}

Итак, это наша начальная точка. Теперь добавим простой фильтр к эндпоинту и посмотрим, как все изменится.

Минимальный API с фильтрами и фабриками фильтров.

В данной статье мы создадим очень простой фильтр, который выполняет некоторую "проверку" аргумента и возвращает 400 Bad Request в случае неудачи, а в противном случае вызывает обработчик:

app.MapGet("/{name}", (string name) => $"Hello {name}!")
    .AddEndpointFilter(async (context, next) =>
    {
        var name = context.GetArgument<string>(0);
        if (name is not "Sock")
        {
            return Results.ValidationProblem(
                new Dictionary<string, string[]>
                {
                    {"name", new[]{"Invalid name"}}
                });
        }
        
        return await next(context); 
    });

Обратите внимание, что это очень "endpoint-specific" фильтр, поскольку он делает значительные предположения о методе обработчика. Этот фильтр также может быть написан с использованием шаблона "фабрика фильтров":

Тип EndpointFilterDelegate определяется следующим образом:

public delegate ValueTask<object?> EndpointFilterDelegate(EndpointFilterInvocationContext context);

так что это фактически Func<EndpointFilterInvocationContext, ValueTask<object?>> .

Как следует из названий "filter" и "filter factory":

  • Когда вы добавляете фильтр, вы добавляете код, который выполняется как часть RequestDelegate. Значение, которое вы возвращаете из фильтра, сериализуется в ответ.
  • Когда вы добавляете фабрику фильтров, вы добавляете код, который выполняется во время создания RequestDelegate. Значение, возвращаемое из фабрики фильтров, является кодом, который выполняется в конвейере. Вы также можете вернуть "next" параметр, чтобы не добавлять дополнительный фильтр в конвейер.


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

На самом деле, метод AddEndpointFilter() делегирует непосредственно AddEndpointFilterFactory():

public static TBuilder AddEndpointFilter<TBuilder>(
    this TBuilder builder,
    Func<EndpointFilterInvocationContext, EndpointFilterDelegate, ValueTask<object?>> routeHandlerFilter)
    where TBuilder : IEndpointConventionBuilder
{
    return builder.AddEndpointFilterFactory(
        (routeHandlerContext, next) => 
            (context) => routeHandlerFilter(context, next));
}

Вложенные лямбды здесь довольно запутаны, но в конечном итоге вы получаете фабрику фильтров, как показано в примере выше. Итак, давайте посмотрим, как это изменит RequestDelegate.

RequestDelegate для минимального API с фильтром.

После добавления фильтра в RequestDelegate все становится намного сложнее. Приведенный ниже код показывает, как сгенерированный TargetableRequestDelegate() изменяется с фильтром. Есть три основных отличия:

  • RequestDelegate создает общий экземпляр EndpointFilterInvocationContext, который содержит ссылку на HttpContext и исходные аргументы, привязанные к модели.
  • Вместо прямого вызова лямбды обработчика вызывается EndpointFilterDelegate filterPipeline. Он содержит вложенный "конвейер фильтрации", который завершается обработчиком эндпоинта.
  • Код записи ответа" должен обрабатывать тип ValueTask<object?> вместо ответа обработчика эндпоинта (который в нашем примере является строкой).

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

Task TargetableRequestDelegate(object? target, HttpContext httpContext)
{
    string name_local;
    bool wasParamCheckFailure;

    name_local = httpContext.Request.RouteValues["name"]
    if (name_local == null)
    {
        wasParamCheckFailure = true;
        Log.RequiredParameterNotProvided(httpContext, "string", "name", "route");
    }

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

    // 👇 Создаем общий EndpointFilterInvocationContext<> и используем его
    // для вызова конвейера фильтрации
    var filterContext = new EndpointFilterInvocationContext<string>(httpContext, name_local)
    ValueTask<object?> result = filterPipeline.Invoke(filterContext); 

   // 👇 Обрабатываем результат конвейера фильтрации, который всегда является
    // ValueTask<object?>, но который может быть обернут во множество различных 
// типов в зависимости от конкретного пути ошибки
    return ExecuteValueTaskOfObject(result, httpContext);

    // 👇 "Полный" конвейер фильтрации. Поскольку у нас только один
    // фильтр в нашем примере, здесь только один уровень "вложенности".
    ValueTask<object?> filterPipeline(EndpointFilterInvocationContext context)
    {
        // 👇 Наш фильтр
        var name = ctx.GetArgument<string>(0);
        if (name is not "Sock")
        {
            return Results.ValidationProblem(
                new Dictionary<string, string[]>
                {
                    { "name", new[] { "Invalid name" } }
                });
        }

        // 👇Вызов "внутреннего" обработчика
        return await filteredInvocation(ctx);
    }

    // 👇 Внутренний фильтр в конвейере, который вызывает метод обработчика
    ValueTask<object?> filteredInvocation(EndpointFilterInvocationContext context)
    {
        if(context.HttpContext.Response.StatusCode >= 400)
        {
            // Already errored, don't run the handler method
            return ValueTask.CompletedTask;  
        }
        else
        {
            // Create the "instance" of the target (if any)
            var target = targetFactory(context.HttpContext); 
            // Execute the handler using the arguments from the filter context
            // and wrap the result in in a ValueTask
            return ValueTask.FromResult<object?>( 
                target.handler.Invoke(context.GetArgument<string>(0)) // the actual handler execution
            );
        }
    }
}

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

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

Изменения в CreateArguments().

Первые изменения в генерации RequestDelegate происходят в функции CreateArguments(). После генерации выражения для привязки аргументов к модели, как обсуждалось в предыдущих постах, генерируется дополнительное выражение для каждого аргумента обработчика. Это выражение служит для получения каждого аргумента из EndpointFilterInvocationContext, как показано ниже:

context.GetArgument<string>(0);
Когда динамический код не поддерживается, сгенерированное выражение извлекает объект из IList<object?> (что является операцией boxing для типов значений), используя context. Arguments[0].

Этот дополнительный Expression хранится в factoryContext.ContextArgAccess и используется для вызова метода обработчика на последнем этапе конвейера фильтрации.

Построение конвейера фильтрации в CreateFilterPipeline().

Следующее большое изменение происходит в CreateTargetableRequestDelegate(), где мы вызываем CreateFilterPipeline() для создания EndpointFilterDelegate:

EndpointFilterDelegate? filterPipeline = null

if (factoryContext.EndpointBuilder.FilterFactories.Count > 0)
{
    filterPipeline = CreateFilterPipeline(methodInfo, targetExpression, factoryContext, targetFactory);

    // ... больше деталей, показано ниже
}

CreateFilterPipeline() отвечает за создание конвейера и имеет следующую сигнатуру:

private static EndpointFilterDelegate? CreateFilterPipeline(
    MethodInfo methodInfo, // оригинальный метод обработчика endpoint
    Expression? targetExpression, // приведение параметра 'target' к правильному типу, например, (Program)target
    RequestDelegateFactoryContext factoryContext, // контекст фабрики для создания RequestDelegate
    Expression<Func<HttpContext, object?>>? targetFactory) // _ => handler.Target; 

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

Создание последнего вызова обработчика.

Подобно построению конвейера middleware и других конструкций "матрешки", CreateFilterPipeline() начинает с "самого внутреннего" обработчика и последовательно оборачивает вокруг него дополнительные обработчики. Сначала создается вызов внутреннего обработчика с помощью:

targetExpression is null
    ? Expression.Call(methodInfo, factoryContext.ContextArgAccess)
    : Expression.Call(targetExpression, methodInfo, factoryContext.ContextArgAccess)

Это выражение вызывает исходный метод обработчика эндпоинта, используя выражения context.GetArgument(0):

target.handler.Invoke(context.GetArgument<string>(0));

Это выражение передается в качестве аргумента в метод MapHandlerReturnTypeToValueTask() вместе с типом возврата исходного обработчика (в нашем случае - строка):

Expression handlerReturnMapping = MapHandlerReturnTypeToValueTask(
    targetExpression is null        // target.handler.Invoke(context.GetArgument<string>(0));
        ? Expression.Call(methodInfo, factoryContext.ContextArgAccess)
        : Expression.Call(targetExpression, methodInfo, factoryContext.ContextArgAccess),
    methodInfo.ReturnType);         // string

MapHandlerReturnTypeToValueTask() отвечает за изменение типа возврата метода обработчика endpoint в ValueTask. Обратите внимание, что речь идет не о записи ответа, а исключительно о превращении его в ValueTask<object?>, чтобы он мог вписаться в конвейер фильтрации как EndpointFilterDelegate.

Для нашего примера минимального API (возвращение строки) MapHandlerReturnTypeToValueTask() вызывает метод WrapObjectAsValueTask, который просто заворачивает результат вызова обработчика в ValueTask<object?>, выдавая код, похожий на:

return ValueTask.FromResult<object?>(
    target.handler.Invoke(context.GetArgument<string>(0))
);

Создание целевого экземпляра.

Далее CreateFilterPipeline() создает целевой экземпляр с помощью targetFactory. В предыдущей статье я вкратце рассказал о них: target — это экземпляр, на котором вызывается метод обработчика. Для лямбда-метода это содержащийся класс, но он может быть и null, если вы используете, например, статический метод.

BlockExpression handlerInvocation = Expression.Block(
    new[] { TargetExpr },
    targetFactory == null
        ? Expression.Empty()
        : Expression.Assign(TargetExpr, Expression.Invoke(targetFactory, FilterContextHttpContextExpr)),
    handlerReturnMapping
);

Это создает выражение, которое выглядит примерно так, где targetFactory определяется как _ => handler.Target для нашего примера минимального API:

target = targetFactory(context.HttpContext);
return ValueTask.FromResult<object?>(                     // from handlerReturnMapping
    target.handler.Invoke(context.GetArgument<string>(0)) // 
);

Для статических методов targetFactory будет равно null, поэтому весь handlerInvocation будет выглядеть примерно так:

return ValueTask.FromResult<object?>(              // from handlerReturnMapping
    handler.Invoke(context.GetArgument<string>(0)) // 
);

Теперь нам нужно превратить вызов этого обработчика в EndpointFilterDelegate.

Создание финального внутреннего обработчика.

Следующий вызов CreateFilterPipeline() создает внутренний обработчик, определяя Expression следующим образом:

EndpointFilterDelegate filteredInvocation = Expression.Lambda<EndpointFilterDelegate>(
    Expression.Condition(
        Expression.GreaterThanOrEqual(FilterContextHttpContextStatusCodeExpr, Expression.Constant(400)),
        CompletedValueTaskExpr,
        handlerInvocation),
    FilterContextExpr).Compile();

Этот код Expression для нашего минимального API выглядит примерно так:

if(context.HttpContext.Response.StatusCode >= 400)
{
    return ValueTask.CompletedTask;
}
else
{
    target = targetFactory(context.HttpContext);              // 
    return ValueTask.FromResult<object?>(                     // from handlerInvocation
        target.handler.Invoke(context.GetArgument<string>(0)) // 
    );
}

Затем это компилируется в EndpointFilterDelegate, так что фактически это становится Func<EndpointFilterInvocationContext, ValueTask<object?>>, который выглядит следующим образом:

ValueTask<object?> filteredInvocation(EndpointFilterInvocationContext context)
{
    if(context.HttpContext.Response.StatusCode >= 400)
    {
        return ValueTask.CompletedTask;
    }
    else
    {
        var target = targetFactory(context.HttpContext);          // 
        return ValueTask.FromResult<object?>(                     // from handlerInvocation
            target.handler.Invoke(context.GetArgument<string>(0)) // 
        );
    }
}

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

Создание линии фильтрации.

Теперь у нас готово большинство частей, и мы можем вызывать фабрики фильтров. Помните, что фабрики фильтров содержат код, который выполняется сейчас и возвращает EndpointFilterDelegate. CreateFilterPipeline() проходит по каждой фабрике (от последней к первой), передавая в качестве следующего параметра EndpointFilterFactoryContext и "остаток" конвейера фильтров:

var routeHandlerContext = new EndpointFilterFactoryContext
{
    MethodInfo = methodInfo,           // это оригинальный обработчик
    ApplicationServices = factoryContext.EndpointBuilder.ApplicationServices,
};

var initialFilteredInvocation = filteredInvocation;

// 👇Перебор всех зарегистрированных фабрик, начиная с последнего добавленного фильтра.
// "Последний" добавленный фильтр будет "самым внутренним" фильтром, который выполняется _после_
// "внешних" фильтров, отсюда и обратная зависимость
for (var i = factoryContext.EndpointBuilder.FilterFactories.Count - 1; i >= 0; i--)
{
    var currentFilterFactory = factoryContext.EndpointBuilder.FilterFactories[i];
    
    // вызываем фабрику, передавая контекст и уже отфильтрованный pipeline
    filteredInvocation = currentFilterFactory(routeHandlerContext, filteredInvocation);
}

Для каждого фильтра в качестве следующего параметра передается filteredInvocation. Таким образом, если мы вспомним наш пример с одним фильтром, то currentFilterFactory будет "фабричной" версией нашего исходного фильтра:

(EndpointFilterFactoryContext context, EndpointFilterDelegate next) =>
{
    return async ctx =>
    {
        var name = ctx.GetArgument<string>(0);
        if (name is not "Sock")
        {
            return Results.ValidationProblem(
                new Dictionary<string, string[]>
                {
                    { "name", new[] { "Invalid name" } }
                });
        }

        return await next(ctx);
    };
}

Параметром контекста является routeHandlerContext, определенный в предыдущем блоке кода, а filteredInvocation передается как следующий. Это повторяется для каждой фабрики фильтров, таким образом, вы последовательно вкладываете фильтры.

После выполнения всех фабрик фильтров функция CreateFilterPipeline() возвращает окончательно отфильтрованный pipeline или null, если фабрики фильтров не изменили pipeline вообще.

Помните, что, если вы используете добавление фильтров с помощью AddEndpointFilter(), фильтр будет выполняться всегда. Если вы добавляете фильтры с помощью AddEndpointFilterFactory(), вы можете не добавлять фильтр к endpoint, и это не повлияет на время выполнения.
// Фабрики фильтров были запущены без изменения поведения каждого запроса, 
// поэтому мы можем пропустить запуск конвейера.
if (ReferenceEquals(initialFilteredInvocation, filteredInvocation))
{
    return null;
}

return filteredInvocation;

Здесь мы рассмотрели метод CreateFilterPipeline(), теперь давайте вернемся к рассмотрению того, где он был вызван в CreateTargetableRequestDelegate().

Вызов фильтрующего конвейера в CreateTargetableRequestDelegate.

Мы вызвали CreateFilterPipeline() из CreateTargetableRequestDelegate при создании окончательного RequestDelegate. Теперь мы можем посмотреть на остальные изменения:

EndpointFilterDelegate? filterPipeline = null;
var returnType = methodInfo.ReturnType;

if (factoryContext.EndpointBuilder.FilterFactories.Count > 0)
{
    // Построение конвейера фильтрации в CreateFilterPipeline()
    filterPipeline = CreateFilterPipeline(methodInfo, targetExpression, factoryContext, targetFactory);

    // Если бы мы действительно добавили какие-либо фильтры
    if (filterPipeline is not null)
    {
        // Создание выражения, вызывающего конвейер фильтрации
        Expression<Func<EndpointFilterInvocationContext, ValueTask<object?>>> invokePipeline = 
            (context) => filterPipeline(context);

        // Тип возврата конвейера всегда ValueTask<object?>,
        // независимо от того, каким был исходный тип возврата обработчика.
        returnType = typeof(ValueTask<object?>);
        
        // Изменение метода "handler" на вызов конвейера вместо этого
        factoryContext.MethodCall = Expression.Block(
            new[] { InvokedFilterContextExpr },
            Expression.Assign(
                InvokedFilterContextExpr,
                CreateEndpointFilterInvocationContextBase(factoryContext, factoryContext.ArgumentExpressions)),
            Expression.Invoke(invokePipeline, InvokedFilterContextExpr)
        );
    }
}

Этот код делает две вещи:

  • Изменяет конечный returnType на ValueTask<object?>, независимо от того, что возвращает метод обработчика.
  • Преобразует factoryContext.MethodCall в вызов, который создает новый экземпляр filterContext и запускает конвейер фильтрации. Он использует общий тип EndpointFilterInvocationContext<T> для эффективного " приведения" в вызовах GetArgument<T>.
Последний пункт интересен: EndpointFilterInvocationContext является негенеративным базовым типом, но существуют генеративные производные классы EndpointFilterInvocationContext, EndpointFilterInvocationContext<T1, T2> и т. д. (до 10 генеративных аргументов)! Эти общие типы гарантируют, что аргументы struct не будут уупакованы при вызове GetArgument<T>.

Все это дает код, который для нашего примера выглядит примерно так:

EndpointFilterInvocationContext filterContext = 
    new EndpointFilterInvocationContext<string>(httpContext, name_local)
invokePipeline.Invoke(filterContext);

Это вызывает конвейер фильтрации, но нам все еще нужно обработать результат фильтрации.

Обработка результата конвейера фильтрации.

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

  • Конвейер фильтрации всегда возвращает ValueTask<object?>, вместо результата обработчика, поэтому нам нужно изменить способ сериализации результата в ответ.
  • Без фильтров Task.CompletedTask возвращается, когда wasParamCheckFailure == true, до вызова обработчика. Мы не можем сделать это с конвейером фильтров, потому что нам всегда нужно вызывать фильтры, независимо от того, была ли привязка модели успешной.

Последнее достигается просто: в CreateParamCheckingResponseWritingMethodCall() мы изменяем условие проверки, чтобы установить код статуса 400, но не возвращать Task.CompletedTask. Поэтому вместо этого:

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

мы создаем это:

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

Как вы видели ранее, самый внутренний фильтр (который вызывает обработчик endpointа) обходит обработчик, если после выполнения фильтров код состояния является кодом ошибки.

Наконец, AddResponseWritingToMethodCall() добавляет код для обработки ValueTask<object?>, ожидая его и генерируя соответствующий метод написания ответа, как описано в предыдущей статье. Здесь нет ничего специфичного для filter-pipeline, кроме того, что нам всегда нужна одна и та же обработка ответа, поскольку filter pipeline всегда возвращает ValueTask<object?>.

И на этом мы закончили!

Обзор конвейера фильтрации RequestDelegate.

В завершение мы еще раз рассмотрим окончательный "эффективный" RequestDelegate для нашего ""фильтрованного" минимального API":

Task TargetableRequestDelegate(object? target, HttpContext httpContext)
{
    string name_local;
    bool wasParamCheckFailure;

    name_local = httpContext.Request.RouteValues["name"]
    if (name_local == null)
    {
        wasParamCheckFailure = true;
        Log.RequiredParameterNotProvided(httpContext, "string", "name", "route");
    }

    if(wasParamCheckFailure)
    {
        httpContext.Response.StatusCode = 400;
        // Обратите внимание, что здесь мы _не_ возвращаем Task.CompletedTask
    }

    var filterContext = new EndpointFilterInvocationContext<string>(httpContext, name_local)
    // Запуск конвейера фильтрации
    ValueTask<object?> result = filterPipeline.Invoke(filterContext); 

    // Обработка результата конвейера фильтрации и сериализация
    return ExecuteValueTaskOfObject(result, httpContext);

    // Конвейер фильтров для нашего обработчика (один уровень вложенности)
    ValueTask<object?> filterPipeline(EndpointFilterInvocationContext context)
    {
        var name = ctx.GetArgument<string>(0);
        if (name is not "Sock")
        {
            return Results.ValidationProblem(
                new Dictionary<string, string[]>
                {
                    { "name", new[] { "Invalid name" } }
                });
        }

        // Вызов "внутреннего" обработчика
        return await filteredInvocation(ctx);
    }

    // Самый "внутренний" фильтр в конвейере, который вызывает метод обработчика
    ValueTask<object?> filteredInvocation(EndpointFilterInvocationContext context)
    {
        if(context.HttpContext.Response.StatusCode >= 400)
        {
            // Уже произошла ошибка, не запускайте метод обработчика
            return ValueTask.CompletedTask;  
        }
        else
        {
            // Создайте "экземпляр" цели (если таковой имеется)
            var target = targetFactory(context.HttpContext); 
            // Выполняем обработчик, используя аргументы из контекста фильтра
// и оборачиваем результат в ValueTask
            return ValueTask.FromResult<object?>( 
                target.handler.Invoke(context.GetArgument<string>(0)) // фактическое выполнение обработчика
            );
        }
    }
}

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

Подведение итогов.


В этой статье мы рассмотрели, как фильтры минимального API и фабрики фильтров изменяют конечный RequestDelegate, созданный для endpoint. Мы начали с рассмотрения конечного RequestDelegate, созданного для обработчика отфильтрованной endpoint. Есть несколько важных отличий по сравнению с нефильтрованным RequestDelegate:

  • Когда связывание аргументов не удается, мы не выполняем немедленное замыкание, как в нефильтрованном случае. Вместо этого фильтры продолжают выполняться, а ошибка связывания обрабатывается в самом "внутреннем" обработчике.
  • Результат обработчика конечной точки всегда обернут в ValueTask<object?>.
  • Объект контекста EndpointFilterInvocationContext<> создается для каждого обработчика endpoint в соответствии с количеством и типом аргументов обработчика endpoint.
  • Если фабрика фильтров не настраивает EndpointFilterDelegate, она вообще не добавляется в конвейер.

Главный вывод заключается в том, что фильтры в минимальных API реализованы самым эффективным способом. Они добавляют накладные расходы только для конкретных endpoints, к которым они применяются, и они компилируются в конечный RequestDelegate очень эффективным образом.

Это подводит нас к концу этого подробного обзора минимальных API. Лично я впечатлен тем, как RequestDelegateFactory работает над тем, чтобы сделать минимальные API настолько эффективными, насколько это вообще возможно!

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