Это - четвертая и последняя (пока) статья цикла про ограничение скорости обработки запросов в ASP.NET Core. Она содержит концептуальное (т.е. раскрывающее состав и взаимодействие частей друг с другом) описание функции ограничения скорости обработки запросов в ASP.NET Core. В этой статье рассмотрено, как на базе универсального компонента ограничения скорости реализована функция ограничения скорости обработки запросов в ASP.NET Core.
Предупреждение: если вам не требуется или не интересно просто для себя (как это интересно мне) разбираться, как устроена и работает функция ограничения скорости обработки запросов в ASP.NET Core, то эта статья, скорее всего, покажется вам длинной и занудной. Потому что в ней рассказывается о весьма специфических подробностях, знание которых совершенно не требуются для того чтобы просто взять и начать использовать в своей программе функцию ограничения скорости обработки запросов ASP.NET Core. Для использования этой функции, скорее всего достаточно будет изучить примеры - или из первой статьи цикла - руководства по использованию, или вообще из документации на сайте Microsoft. В таком случае вам, наверное, читать эту статью не стоит. Но, возможно, и в этом случае вам стоит хотя бы заглянуть в приложения к ней. Там я, в качестве иллюстрации к основному материалу статьи, описал сделанные мной компоненты, позволяющие использовать функцию ограничения скорости нестандартным способом: возможно, вы найдёте применение одному из таких компонентов в своей программе. Компоненты эти оформлены в виде библиотек классов .NET, так что для их использования уже сейчас можно взять их в исходном виде и добавить в свое решение (solution). Причем, при описании каждого компонента я постарался вынести в начало их описания пример его использования - так, чтобы для использования компонента не требовалось читать остальной текст приложения, где написано как он устроен и работает.
Ну, а если вам пришлось разбираться (потому что эта функция не работает так, как вы ожидали) или, как мне, просто захотелось разобраться для себя, как работает функция ограничения скорости обработки запросов в ASP.NET Core - читайте дальше.
О структуре статьи.
Итак, данная статья - это часть цикла, в состав которого входит первая статья - руководство по использованию функции ограничения скорости обработки запросов, в котором даны примеры использования без погружения в детали реализации этой функции, и набор статей, более детально описывающих, как реализованы различные части этой функции.
Состав цикла
Руководство по использованию функции ограничения скорости обработки входящих запросов в ASP.NET Core.
Устройство и работа классов базовых ограничителей универсального компонента ограничения.
Компонент-обработчик ограничения скорости обработки запросов в ASP.NET Core - эта статья.
В основном тексте в тематических разделах дано на уровне концепций описание состава и работы соответствующей части, которой посвящен раздел. Базовые понятия, используемые в этом описании, выделены курсивом. В скрытом тексте с заголовком “Подробности” в этих разделах описаны детали реализации: приведен исходный код классов, названия и назначения полей и методов, обнаруженные интересные приемы и т.п. Этот текст можно пропустить без ущерба для общего понимания. Читать его стоит, только если вам интересны эти детали. Для общего понимания, как работает ограничение скорости обработки запросов, этот скрытый текст читать не обязательно.
В скрытом тексте с заголовком “Сводка” ниже содержится краткое изложение материала из тематических разделов. Эту сводку можно использовать для первичного знакомства со статьей, а при необходимости можно ознакомиться с дополнительными подробностями в соответствующем разделе.
Сводка
Главная часть ограничителя запросов ASP.NET Core - это компонент-обработчик ограничителя (rate limiting middleware), который выполняет конкретную работу по ограничению. Компонент-обработчик ограничителя является пользователем универсального компонента ограничения. Поведение компонента-обработчика ограничителя настраивается передаваемыми в его конструктор параметрами настройки ограничителя.
Для добавления компонента-обработчика ограничителя запросов в конвейер обработчиков веб-приложения ASP.NET Core используется одна из форм метода расширения UseRateLimiter для интерфейса IApplicationBuilder, вызываемая на этапе настройки конвейера веб-приложения. Метод UseRateLimiter следует вызывать в промежутке между методами настройки маршрутизации UseRouting и обработки точек назначения маршрутизации UseEnpoints. При использовании в качестве основы приложения WebApplication/WebApplicationBuilder (то есть - шаблонов веб-приложений, используемых в версиях, начиная с 6.0, по умолчанию) это условие соблюдается автоматически.
Есть две формы метода UseRateLimiter: одна из них передает в компонент-обработчик параметры настройки ограничения(экземпляр класса RateLimiterOptions), заданные в ее дополнительном параметре, вторая, обычно используемая, не имеющая дополнительных параметров, заставляет компонент-обработчик ограничения получить свои параметры настройки из контейнера сервисов приложения, используя Options pattern.
Для работы компонента-обработчика ограничения в контейнер сервисов приложения необходимо (начиная с ASP.NET Core версии 8) добавить требуемые сервисы (они используются для сбора метрик). Это делается вызовом на этапе настройки контейнера сервисов метода расширения AddRateLimiter для IServiceCollection. Обычно этот же метод задает и параметры настройки компонента-обработчика ограничения - это делает делегат, передаваемый в метод AddRateLimiter как параметр. Начиная с ASP.NET Core 9 в функцию ограничения запросов была добавлена вторая форма метода AddRateLimiter, в которую делегат для настройки параметров обработки не передается.
Содержимое класса параметров настройки ограничения можно отнести к одной из трех групп: глобальному ограничению, ограничению на основе политик или действию при получении отказа в разрешении на выполнение запроса. Чтобы запрос можно было выполнить, компонент-обработчик запрашивает разрешения одновременно через два независимых механизма - глобальное ограничение и ограничение на основе политик - и разрешает выполнение запроса, только когда получено разрешение от обоих механизмов. Если глобальное ограничение не настроено, или запрос не попадает под действие ни одной политики, то соответствующий механизм не используется, и разрешение от него считается полученным.
Глобальное ограничение применяется ко всем запросам. Оно настраивается сохранением в свойстве GlobalLimiter селективного ограничителя - объекта класса PartitionedRateLimiter из состава компонента универсального ограничителя скорости .NET, со значением его параметра-типа ресурса HttpContext (другие типы ресурсов в ограничителях для ASP.NET Core не используются).
Три обобщенных метода с одинаковым именем AddPolicy, но разным набором параметров, составляют группу настройки политик ограничения запросов. Они добавляют в параметры настройки именованные политики ограничения запросов.
Группа, состоящая из свойств OnRejected и RejectionStatusCode, описывает действие, которое компонент-обработчик должен выполнить в случае получения отказа при запросе разрешения на выполнение запроса, если иное действие не указано в связанной с этим запросом политике, если таковая есть. При получении отказа в разрешении на выполнение компонент-обработчик устанавливает код статуса результата запроса в значение, содержащееся в свойстве RejectionStatusCode параметров настройки, и вызывает обработчик отказа в выделении разрешения, если он задан (не равен null). Обработчик отказа может быть задан или глобально в параметрах настройки в свойстве OnRejected, или в политике, под которую попадает запрос. Если установлены оба обработчика, приоритет имеет обработчик, установленный в политике. Обработчик отказа в выделении разрешения - это асинхронный (он возвращает ValueTask) делегат, которому передается контекст запроса и объект ответа с отказом на заявку на выдачу разрешения, а также - маркер отмены для отмены вызова этого делегата. Этот делегат предназначен для модификации переданного контекста запроса (обычно - модификации ответа на этот запрос), чтобы отобразить в нем информацию о нарушении ограничения.
Политика ограничения запросов - это объект, который позволяет выполнять ограничение не для всех запросов скопом, а свое собственное для разных путей, указанных в URL запроса, и групп таких путей, то есть - точек назначения подсистемы маршрутизации. Кроме параметров ограничения политика может содержать свой специфический обработчик отказа в выделении разрешения. Политики ограничения запросов могут быть именованными и безымянными. Настройка ограничения на основе именованных политик состоит из двух стадий: добавления именованных политик в параметры настройки компонента-обработчика ограничения и привязки политик ограничения к маршрутам (точкам назначения маршрутизации). Настройка безымянных политик ограничения состоит только из стадии привязки их к конкретным маршрутам, в параметры настройки компонента-обработчика ограничения они не добавляются, и для других маршрутов они быть использованы не могут.
Политика, чтобы она применялась, должна быть привязана к маршруту подсистемы маршрутизации. В этом случае указанные в ней ограничения будут применяться ко всем запросам, адресованным к точкам назначения этого маршрута. Одна и та же именованная (но не безымянная) политика может быть связана с несколькими независимыми маршрутами, тогда ко всем запросам, адресованным к точкам назначения любого из этих маршрутов будет применяться одно и то же ограничение. Ограничение, задаваемое политикой, может быть сделано селективным, подобным тому, которое выполняет секционированный ограничитель - то есть к запросам, попадающими под действие одной и той же политики, могут применяться разные базовые ограничители, выбираемые по значению параметра запроса, назначенного ключом секционирования.
Исходными объектами для создания политик ограничения запросов могут служить классы, реализующие интерфейс политики, а также ( в текущей версии функции ограничения это доступно только для именованных политик) секционирующие делегаты с типом ресурса (входного параметра делегата) HttpContext.
Класс, из которого создается политика ограничения, должен реализовывать интерфейс политики ограничения - обобщенный интерфейс IRateLimiterPolicy<TPartitionKey>с одним параметром-типом TPartitionKey - типом ключа секционирования. В интерфейсе политики определены метод GetPartition и свойство OnRejected. Метод GetPartition (секционирующий метод) возвращает на основе значения контекста запроса (значения типа HttpContext) данные для секции с указанным в параметре-типе интерфейса типом ключа секционирования. По параметрам и возвращаемому значению метод GetPartition идентичен секционирующему делегату с типом ресурса HttpContext и типом ключа секционирования TPartitionKey интерфейса. Свойство OnRejected интерфейса может содержать специфичный для политики обработчик отказа в получении разрешения на выполнение запроса. Этот обработчик, если он установлен (не равен null), вызывается вместо обработчика по умолчанию для всех запросов, попадающих под действие политики, для которых получен ответ с отказом на заявку на выполнение запроса.
Компонент-обработчик ограничителя запросов в ASP.NET Core при своей работе непосредственно использует объекты, реализующие интерфейс политики с конкретным типом ключа секционирования - составным ключом приготовленной политики (о нем см. далее). Такие объекты, непосредственно используемые компонентом-обработчиком ограничения, называются в статье приготовленные политики, а класс этих объектов - класс хранения политики. Для реализации метода GetPartition интерфейса приготовленной политики в классе хранения политики используется специальный делегат, знающий и использующий особым образом структуру составного ключа приготовленной политики. Этот делегат в статье называется делегат-адаптер (почему используется именно такое название, см. далее).
Создание приготовленных политик в большинстве случаев происходит в момент добавления исходных политик в настройки: безымянных - при связывании их с точкой назначения маршрутизации в методе расширения RequireRateLimiting, именованных - в одном из методов RateLimiterOptions.AddPolicy, добавляющих именованные политики в параметры настройки ограничителя. Однако в одном из вариантов метода RateLimiterOptions.AddPolicy создание приготовленной политики откладывается до момента инициализации компонента-обработчика ограничения, чтобы для создания приготовленной политики можно было использовать исходный класс, в котором используется внедрение зависимостей из контейнера сервисов через конструктор.
Составной ключ приготовленной политики объединяет в себе имя политики (для безымянных политик оно равно null), и необязательное значение исходного ключа секционирования - ключа секционирования для исходной политики ограничения запросов или исходного секционирующего делегата, из которых эта приготовленная политика была создана. Класс приготовленной политики ограничения реализует обобщенный интерфейс политики ограничения IRateLimiterPolicy, специфицированный этим типом составного ключа политики. Сравнение двух составных ключей на равенство и вычисление их хэш-кодов при обработке политик в компоненте-обработчике ограничения производится исключительно на основе этих двух свойств - имени политики и исходного ключа секционирования. Составной ключ политики содержит внутри себя также дополнительные данные, необходимые для работы секционирующего метода приготовленной политики - то есть, делегата-адаптера, но при сравнении составных ключей эти данные игнорируются.
Именованные политики ограничения запросов, добавленные в объект класса параметров ограничителя RateLimiterOptions, идентифицируются по строке - имени политики, т.е каждая именованная политика имеет уникальное имя.
Для добавления именованных политик в параметры компонента-обработчика ограничения запросов используется один из трех вариантов метода AddPolicy. Все эти варианты являются обобщенными, с первым (и, возможно, единственным) параметром-типом TPartitionKey - типом исходного ключа секционирования, используемого политикой. Кроме того, все эти методы принимают в качестве первого параметра строку - имя политики.
Первый вариант метода AddPolicy - RateLimiterOptions AddPolicy<TPartitionKey>(string policyName, Func<HttpContext, RateLimitPartition<TPartitionKey>> partitioner) - добавляет в параметры настройки ограничения приготовленную политику, которая создается из исходного секционирующего делегата с типом ресурса HttpContext и типом исходного ключа секционирования, совпадающим с параметром-типом метода. А потому в этом варианте можно использовать те же самые вспомогательные методы из класса RateLimiterPartition, что и для создания секционированного ограничителя (такого как, например, глобальный ограничитель). Этот вариант, однако, не позволяет задать обработчик отказа в выдаче разрешения.
Второй вариант AddPolicy - public RateLimiterOptions AddPolicy<TPartitionKey>(string policyName, IRateLimiterPolicy<TPartitionKey> policy) - добавляет в параметры настройки ограничения приготовленную политику, которая создается из существующего исходного объекта, реализующего интерфейс политики ограничения с типом исходного ключа секционирования интерфейса политики, совпадающим с параметром-типом метода. Этот вариант позволяет задать обработчик отказа в выдаче разрешения через свойство OnRejected исходного объекта.
Третий вариант метода AddPolicy -public RateLimiterOptions AddPolicy<TPartitionKey, TPolicy>(string policyName) where TPolicy : IRateLimiterPolicy<TPartitionKey> - в качестве исходного объекта для создания приготовленной политики использует экземпляр класса, реализующий интерфейс политики ограничения с типом исходного ключа секционирования интерфейса политики, совпадающим с первым параметром-типом метода. Тип этого исходного класса передается в качестве второго параметра-типа этого метода - TPolicy. Создание исходного экземпляра и, как следствие - объекта приготовленной политики при использовании этого варианта откладывается до момента инициализации компонента-обработчика ограничения при добавлении его в конвейер обработчиков веб-приложения. Поэтому в этом варианте возможно внедрение зависимостей из контейнера сервисов в конструктор исходного класса. Этот вариант также позволяет задать обработчик отказа в выдаче разрешения через свойство OnRejected исходного объекта.
В конструктор экземпляра приготовленной политики передается секционирующий делегат, создающий из значения контекста запроса данные для секции с ключом секционирования - составным ключом политики. Для создания этого делегата из исходной политики или секционирующего делегата используется внутренний статический метод RateLimiterOptions.ConvertPartition. Этот метод создает из исходного секционирующего делегата или секционирующего метода исходной политики делегат-адаптер, который вызывает исходный секционирующий делегат или секционирующий метод исходной политики и преобразует полученные от него данные для секции с исходным ключом в возвращаемые делегатом-адаптером данные данные для секции с составным ключом политики. В реализации делегата-адаптера используется дополнительное, третье поле составного ключа политики, не участвующее в сравнении этих ключей. Этот делегат-адаптер передается в качестве секционирующего в конструктор объекта приготовленной политики.
Если исходный секционирующий делегат обладает селективностью - то есть, создает отдельные секции с отдельными ограничителям для разных контекстов запросов, отличающихся по каким-либо параметрам, то такой же селективностью будет обладать и созданный для него делегат-адаптер приготовленной политики: запросы с разными значениями исходного ключа секционирования, получаемого при вызове исходного секционирующего делегата, будут попадать в разные секции, с разными ограничителями для этих секций - теми же, что и для секций, создаваемых по исходному ключу.
Для упрощения создания политик ограничения существуют вспомогательные методы (по одному методу для каждого алгоритма ограничения) добавления простейших именованных политик в параметры настройки ограничителя. Эти методы создают и добавляют в параметры настройки ограничителя политики, производящие не-селективное ограничение запросов по алгоритму, определяемому вспомогательным методом. Созданные этими методами политики не позволяют задать специфический для политики обработчик отказа в выдаче разрешения.
Область действия ограничений на основе политик настраивается путем привязки политик ограничения к метаданным точек назначения в подсистеме маршрутизации. Политики ограничения, используемые в ограничителе запросов, могут быть как именованными, так и безымянными. Именованные политики добавляются в параметрах настройки ограничителя и могут быть привязаны более чем к одной точке назначения, а безымянные политики хранятся непосредственно в метаданных конкретной точки назначения и каждая такая политика привязана только к этой точке.
Для того, чтобы именованную политику можно было использовать, она должна быть добавлена в объект параметров настройки ограничения, используемый при создании компонента-обработчика ограничителя.
Привязку политик к точкам назначения можно сделать двумя способами, которые оба приводят к помещению в метаданные маршрутизации точки назначения экземпляра класса EnableRateLimitingAttribute. Первый способ - указать для точки назначения соответствующий атрибут маршрутизации - [EnableRateLimiting] с именем привязываемой политики. Этот способ применяется, если для точки назначения используется маршрутизация по атрибутам. Этим способом к точке назначения можно привязать только именованную политику. Второй способ - вызвать для этой точки назначения метод расширения RequireRateLimiting для класса настройки конечной точки, реализующего интерфейс IEndpointConventionBuilder. Этот способ позволяет привязать к точке назначения как именованную политику (для этого в метод RequireRateLimiting передается имя этой политики), так и безымянную (для этого в метод RequireRateLimiting передается исходный объект, реализующий интерфейс политики).
Есть возможность полностью отключать ограничение запросов (включая и глобальное ограничение) для отдельных точек назначения. Отключение выполняется путем помещения в метаданные маршрутизации этой точки назначения ссылки на экземпляр класса DisableRateLimitingAttribute. Есть два способа поместить ссылку на экземпляр указанного класса в метаданные. Первый способ - указать для точки назначения соответствующий атрибут маршрутизации - [DisableRateLimiting]. Этот способ применяется, если для точки назначения используется маршрутизация по атрибутам. Второй способ - вызвать для этой точки назначения метод расширения DisableRateLimiting для класса настройки конечной точки, реализующего интерфейс IEndpointConventionBuilder.
Экземпляр компонента-обработчика (middleware) ограничения создается при создании конвейера приложения. Конструктор компонента-обработчика копирует в свои внутренние поля информацию, переданную ему через параметры (одним из которых является экземпляр параметров настройки ограничения, передаваемый через Options pattern - как ссылка на интерфейс IOptions<RateLimiterOptions>), копирует в свой список именованных политик из параметров настройки ограничения заранее созданные именованные приготовленные политики, создает и добавляет в этот список те именованные приготовленные политики, создание которых было отложено до этого момента, и создает ограничитель по политикам.
Ограничитель по политикам - это секционированный ограничитель с ключом секционирования - составным ключом политики. Его секционирующий делегат использует метаданные маршрутизации из контекста запроса и список приготовленных именованных политик для получения данных для секции: составного ключа и ограничителя секции. Если в метаданных маршрутизации в контексте запроса ссылка на политику ограничения отсутствует, ограничитель по политикам выдает разрешение на выполнение этого запроса без каких-либо дальнейших проверок.
При обработке запроса вызывается метод Invoke компонента-обработчика ограничения. Этот метод прежде всего проверяет через метаданные маршрутизации запроса, не отключено ли для него ограничение. Если ограничение отключено, метод Invoke вызывает следующий компонент-обработчик в конвейере. В противном случае метод Invoke вызывает последовательно глобальный ограничитель (если он есть - если же его нет, то глобальное разрешение автоматически считается полученным) и ограничитель по политикам, который обрабатывает запрос в соответствии с назначенной ему через метаданные маршрутизации политикой.
При получении разрешения от обоих ограничителей компонент-обработчик ограничения вызывает следующий компонент-обработчик в конвейере. В случае получения отказа в разрешении метод Invoke компонента-обработчика ограничения устанавливает в ответе настроенный в нем код статуса HTTP при отказе, вызывает соответствующий обработчик отказа в выдаче разрешения, если он вообще установлен, и завершает обработку запроса.
В разделе с заголовком “Про код” я изложил (в одноименном скрытом тексте, чтобы уменьшить объём этой и без того объёмной статьи) свои мысли о коде, реализующем описанные в статье классы. Кому вдруг интересно это мое мнение, могут с ним ознакомиться, а остальные вполне могут безболезненно пропустить этот текст.
В приложениях к статьям этого цикла, включая и эту, которую вы читаете, приведены примеры, как можно использовать сведения из этих статей для реализации нестандартного использования тех частей функции ограничения скорости, которые описаны в этих статьях, и рассмотрены сделанные на этой базе компоненты, которые можно использовать в своих программах. Код примеров опубликован в репозитории на github под лицензией, допускающей его свободное использование.
Содержание
Установка компонента-обработчика ограничителя запросов в конвейер.
Настройка контейнера сервисов для использования компонента-обработчика ограничителя запросов.
-
Механизмы компонента-обработчика ограничения и класс параметров его настройки.
-
Связывание политик ограничения запросов с метаданными маршрутизации.
-
Инициализация и работа компонента-обработчика ограничителя запросов.
Приложение 1. Пример класса, реализующего интерфейс политики ограничения.
-
Приложение 2. Политика, использующая произвольный селективный ограничитель.
Введение.
Ограничитель запросов ASP.NET Core использует в своей работе универсальный компонент ограничения в .NET. И теперь, когда мы знаем по предыдущим статьям цикла, как устроен и работает этот компонент, можно перейти к описанию ограничителя запросов ASP.NET Core - его составных частей и как эти части взаимодействуют при его работе.
Главная часть ограничителя запросов ASP.NET Core - это компонент-обработчик ограничителя (rate limiting middleware), который выполняет конкретную работу по ограничению. По терминологии из второй статьи цикла компонент-обработчик ограничителя является пользователем универсального компонента ограничения. На этапе инициализации конвейера он в соответствии со своими настройками настраивает объекты, предоставляемые универсальным компонентом ограничения. На этапе выполнения запросов компонент-обработчик ограничителя подает заявки на разрешение в эти объекты и обрабатывает ответы на эти заявки. И начать описание стоит с того, как этот ограничитель запросов ASP.NET Core добавляется в конвейер компонентов-обработчиков. И хотя до этого этапа требуется ещё выполнить настройку контейнера сервисов для компонента-обработчика, её стоит рассмотреть позднее, чтобы иметь понимание, что должно быть настроено при настройке контейнера сервисов.
Установка компонента-обработчика ограничителя запросов в конвейер.
Компонент-ограничитель запросов подключается к конвейеру обработчиков запросов в ASP.NET Core методом расширения UseRateLimiter для интерфейса IApplication. Этот метод существует в двух вариантах, различающихся передаваемыми в него аргументами: первый вариант - без дополнительных аргументов, второму передается дополнительно экземпляр класса RateLimiterOptions, содержащий параметры настройки ограничителя запросов ASP.NET Core(далее в тексте - просто параметры настройки ограничителя). Оба варианта устанавливают в конвейер компонент-обработчик ограничителя запросов типа RateLimitingMiddleware. В первом варианте конструктор компонента-обработчика получает через свой параметр типа IOptions<RateLimiterOptions> неименованный экземпляр класса параметров настройки ограничителя из контейнера сервисов, используя Options pattern. Содержимое этого экземпляра, получаемого из контейнера сервисов, при этом настраивается соответствующими методами конфигурирования. Большинство из них описано в теме про Options Pattern, но есть один метод, специфический именно для ограничителя запросов ASP.NET Core - метод настройки контейнера сервисов для ограничителя AddRateLimiter (см. следующий раздел). Во втором варианте в конструктор компонента-обработчика через этот параметр явно передаются параметры настройки ограничителя, обернутые в интерфейс IOptions<RateLimiterOptions>, которые были получены как дополнительный аргумент метода UseRateLimiter.
Подробности.
Методы расширения UseRateLimiter определенны в классе RateLimiterApplicationBuilderExtensions:
public static class RateLimiterApplicationBuilderExtensions { public static IApplicationBuilder UseRateLimiter(this IApplicationBuilder app) { ArgumentNullException.ThrowIfNull(app); VerifyServicesAreRegistered(app); return app.UseMiddleware<RateLimitingMiddleware>(); } public static IApplicationBuilder UseRateLimiter(this IApplicationBuilder app, RateLimiterOptions options) { ArgumentNullException.ThrowIfNull(app); ArgumentNullException.ThrowIfNull(options); VerifyServicesAreRegistered(app); return app.UseMiddleware<RateLimitingMiddleware>(Options.Create(options)); } private static void VerifyServicesAreRegistered(IApplicationBuilder app) { var serviceProviderIsService = app.ApplicationServices.GetService<IServiceProviderIsService>(); if (serviceProviderIsService != null && !serviceProviderIsService.IsService(typeof(RateLimitingMetrics))) { throw new InvalidOperationException(Resources.FormatUnableToFindServices( nameof(IServiceCollection), nameof(RateLimiterServiceCollectionExtensions.AddRateLimiter))); } } }
В обоих варианта метода сначала проверяется, что переданные в метод параметры не равны null и что необходимые для работы ограничителя сервисы были зарегистрированы ранее методом AddRateLimiter (проверяется регистрация сервиса RateLimitingMetrics, реализующего сбор метрик). В случае, если это не так, вызывается исключение. Затем для добавление компонента-обработчика ограничителя в конвейер вызывается метод UseMiddleware. Как работает метод UseMiddleware, и вообще, как производится добавление компонентов-обработчиков в конвейер, я описывал в одной из своих предыдущих статей.
Настройка контейнера сервисов для использования компонента-обработчика ограничителя запросов.
Эта настройка обычно выполняется методом расширения AddRateLimiter для списка сервисов, помещаемых в контейнер - IServiceSollection. Этот метод выполняет две функции: первая - добавление в контейнер необходимых для работы компонента-обработчика ограничителя сервисов, вторая - установка параметров настройки компонента-обработчика. Для реализации этой второй функции в метод AddRateLimiter передается аргумент-делегат, устанавливающий значения свойств и вызывающий методы переданного ему экземпляра класса параметров настройки. Выполнение второй функции может и не требоваться вовсе - например, при настройке параметров стандартными для Options pattern методами (Configure и пр.), или - при передаче параметров компоненту-обработчику в явном виде, через вторую форму метода UseRateLimiter (см. выше). Для таких случаев в ASP.NET Core 9 была добавлена вторая форма метода AddRateLimiter, в которую делегат для настройки параметров обработки не передается.
Подробности.
Методы AddRateLimiter определены в классе RateLimiterServiceCollectionExtensions:
public static class RateLimiterServiceCollectionExtensions { public static IServiceCollection AddRateLimiter(this IServiceCollection services, Action<RateLimiterOptions> configureOptions) { ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(configureOptions); services.AddRateLimiter(); services.Configure(configureOptions); return services; } public static IServiceCollection AddRateLimiter(this IServiceCollection services) { ArgumentNullException.ThrowIfNull(services); services.AddMetrics(); services.AddSingleton<RateLimitingMetrics>(); return services; } }
Необходимые для работы сервисы, которые добавляют в контейнер сервисов методы AddRateLimiter - это сервис доступа к инфраструктуре работы с метриками через System.Diagnostics.Metrics API (его добавляет вызов AddMetrics) и сервис, добавляющий специфические метрики ограничителя запросов ASP.NET Core и организующий их сбор - класс RateLimitingMetrics.
Вариант метода AddRateLimiter, принимающий делегат, устанавливающий параметры настройки ограничителя, дополнительно добавляет этот делегат в цепочку инициализации неименованного экземпляра класса параметров настройки в соответствии со стандартным для .NET шаблоном Options pattern. Добавление производится стандартным способом - вызовом метода расширения Configure для списка конфигурирования сервисов, представленного интерфейсом IServiceCollection.
Конструктор компонента-обработчика имеет зависимость от IOptions<RateLimiterOptions> - то есть, принимает параметр этого типа. Если значение параметра этого типа не было задано ранее в вызове UseMiddleware(а задать его можно методом UseRateLimiter - его вторая форма как раз принимает в качестве параметра экземпляр класса RateLimiterOptions, который она оборачивает в интерфейс IOptions<RateLimiterOptions> и передает этот интерфейс в метод UseMiddleware; подробно о том, как это работает, я написал в своей статье по ссылке выше), то при при инициализации компонента-обработчика значение этого параметра для передачи в его конструктор извлекается из контейнера сервисов, реализуя тем самым Options pattern. Полное описание механизма работы Options pattern выходит далеко за рамки этой статьи (механизм там сам по себе довольно интересный, может быть, я когда-нибудь напишу про него статью, но - не здесь и не сейчас). А для текущей статьи нам достаточно знать, что в процессе этой реализации и вызывается тот самый делегат, устанавливающий значения параметров настройки ограничителя, который был передан как аргумент в метод AddRateLimiter, и что вызов этого делегата произойдет позже, при создании конвейера обработчиков, когда компонент-ограничитель запросит в своем конструкторе значение параметров настройки обратившись к свойству IOptions<RateLimiterOptions>.Value.
Механизмы компонента-обработчика ограничения и класс параметров его настройки.
Публично доступные свойства и методы класса параметров настройки ограничителя RateLimiterOptions приведены в следующем фрагменте кода:
public sealed class RateLimiterOptions { public PartitionedRateLimiter<HttpContext>? GlobalLimiter { get; set; } public Func<OnRejectedContext, CancellationToken, ValueTask>? OnRejected { get; set; } public int RejectionStatusCode { get; set; } = StatusCodes.Status503ServiceUnavailable; public RateLimiterOptions AddPolicy<TPartitionKey>(string policyName, Func<HttpContext, RateLimitPartition<TPartitionKey>> partitioner); public RateLimiterOptions AddPolicy<TPartitionKey, TPolicy>(string policyName) where TPolicy : IRateLimiterPolicy<TPartitionKey>; public RateLimiterOptions AddPolicy<TPartitionKey>(string policyName, IRateLimiterPolicy<TPartitionKey> policy); }
Эти свойства и методы можно отнести к одной из трех групп. Каждая из групп относится к одной из трех функций компонента-обработчика ограничения запросов в ASP.NET Core: глобальному ограничению, ограничению на основе политик или действию при получении отказа в разрешении на выполнении запроса. Чтобы запрос можно было выполнить, компонент-обработчик запрашивает разрешения одновременно через два независимых механизма - глобальное ограничение и ограничение на основе политик - и разрешает выполнение запроса, только когда получено разрешение от обоих механизмов. Если глобальное ограничение не настроено, или запрос не попадает под действие ни одной политики, то соответствующий механизм не используется, и разрешение от него считается полученным.
Настройка глобального ограничения.
Группу настройки глобального ограничения составляет единственное свойство GlobalLimiter. Оно содержит ссылку на глобальный ограничитель, который является основой механизма глобального ограничения. Если этот механизм настроен (то есть, значение свойства GlobalLimiter не равно null, его значению по умолчанию), то он применяется ко всем запросам. Глобальный ограничитель представляет собой селективный ограничитель (см. вторую статью цикла) с типом ресурса - контекстом запроса (HttpContext). Создается он обычно одним из статических методов класса PartitionedRateLimiter (Create или CreateChained).
Добавление именованных политик ограничения запросов.
Три обобщенных метода с одинаковым именем AddPolicy, но разным набором параметров составляют группу настройки политик ограничения запросов. Они добавляют в параметры настройки именованные политики ограничения запросов. Эти методы будут рассмотрены далее в посвященной политикам ограничения главе в соответствующем разделе.
Указание действия по умолчанию при получении отказа.
Группа, состоящая из свойств OnRejected и RejectionStatusCode, описывает действие, которое компонент-обработчик должен выполнить в случае получения отказа при запросе разрешения на выполнение запроса, если иное действие не указано в связанной с этим запросом политике, если таковая есть.
Свойство OnRejected может содержать используемый по умолчанию обработчик отказа в выделении разрешения. Обработчик отказа в выделении разрешения - это асинхронный (он возвращает ValueTask) делегат, которому передается контекст запроса (HttpContext) и объект ответа с отказом на заявку на выдачу разрешения, а также - маркер отмены для отмены вызова этого делегата. Этот делегат предназначен для модификации переданного контекста запроса (обычно - модификации ответа на этот запрос), чтобы отобразить в нем информацию о нарушении ограничения. Если значение свойства OnRejected равно null, то по умолчанию никакого вызова делегата не происходит и код HTTP-статуса в ответе на запрос, чей контекст был передан, устанавливается равным RejectionStatusCode. Обработчик отказа в выделении разрешения для запросов, попадающих под действие какой-либо политики ограничения, может быть задан в этой политике, и тогда именно он будет вызываться вместо обработчика по умолчанию.
Подробности
В компоненте-обработчике ограничения сначала в код HTTP-статуса ответа контекста запроса копируется значение свойства RejectionStatusCode, и только после этого вызывается обработчик отказа - делегат из свойства OnRejected политики, под которую попадает запрос, а если его нет - из одноименного свойства параметров настройки (опять-таки, если он есть). Если значение этого свойства равно null или же обработчик не устанавливает свой код статуса в HttpContext, то код статуса в ответе в контексте запроса сохраняет значение из свойства RejectionStatusCode.
Контекст запроса и объект ответа на заявку с отказом передаются в делегат OnRejected одним параметром типа OnRejectedContext в свойствах параметра HttpContext и Lease. В качестве маркера отмены в обработчик отказа передается маркер отмены запроса - значение свойства RequestAborted контекста запроса. Исходный код OnRejectedContext:
namespace Microsoft.AspNetCore.RateLimiting; public sealed class OnRejectedContext { public required HttpContext HttpContext { get; init; } //Контекст запроса public required RateLimitLease Lease { get; init; } //Объект ответа на заявку (с отказом) }
То есть, при вызове делегата OnRejected для объекта контекста выделяется память в куче. Мне такое решение, причем - в месте, которое легко может оказать на критическом пути при перегрузке приложения запросами, кажется неправильным: эти два значения можно было бы передать каждое через свой параметр, без необходимости выделять в куче лишнюю память, которую потом должен будет собрать сборщик мусора.
По умолчанию при получении отказа компонент-обработчик ограничителя запросов возвращает код статуса HTTP 503 (Service Unavailable). Такой выбор умолчания кажется мне сомнительным - проблема-то возникает не на самом сервере, проблему вызывает-таки клиент, подающий слишком много запросов, и обычно клиенту стоит указать на этот факт, возвращая код статуса HTTP 429 (Too Many Requests).
Настройка ограничения запросов на основе политик.
Совет: применение политик в компоненте-обработчике ограничения в ASP.NET Core основано на создании и использовании экземпляра класса секционированного ограничителя из универсального компонента ограничения, описанного во второй статье цикла. Поэтому перед просмотром этого раздела имеет смысл освежить в памяти понятия, связанные с устройством и работой секционированного ограничителя, а именно - ключ секционирования, секционирующий делегат, данные для секции - поскольку описание устройства политик и работы выполняющего их обработчика опирается на эти понятия.
Общая информация о политиках ограничения запросов.
Политика ограничения запросов - это объект, который позволяет выполнять ограничение не для всех запросов скопом, а свое собственное для разных путей, указанных в URL запроса, и групп таких путей, то есть - привязанное к маршрутам и точкам назначения подсистемы маршрутизации. Кроме кода, выполняющего ограничение, политика может содержать свой специфический обработчик отказа в выделении разрешения.
Как уже рассказано в руководстве, политики ограничения запросов могут быть именованными и безымянными. Настройка ограничения на основе именованных политик состоит из двух стадий: добавления именованных политик в параметры настройки компонента-обработчика ограничения и привязки политик ограничения к маршрутам (точкам назначения маршрутизации). Настройка безымянных политик ограничения состоит только из стадии привязки их к конкретным маршрутам, в параметры настройки компонента-обработчика ограничения они не добавляются, и для других маршрутов они быть использованы не могут.
Политика, чтобы она применялась, должна быть, как упомянуто выше, связана с маршрутом подсистемы маршрутизации. В этом случае указанные в ней ограничения будут применяться ко всем запросам, адресованным к точкам назначения этого маршрута. Одна и та же именованная (но не безымянная) политика может быть связана с несколькими независимыми маршрутами, тогда ко всем запросам, адресованным к точкам назначения любого из этих маршрутов будет применяться одно и то же ограничение. Ограничение, задаваемое политикой, может быть сделано селективным, подобным тому, которое выполняет секционированный ограничитель - то есть к запросам, попадающими под действие одной и той же политики, могут применяться разные базовые ограничители, выбираемые по значению параметра запроса, назначенного ключом секционирования.
Политики ограничения запросов можно создавать либо из классов, реализующие интерфейс политики ограничения, либо (в текущей версии функции ограничения это доступно только для именованных политик) из секционирующих делегатов с типом ресурса (входного параметра делегата) HttpContext.
Интерфейс политики ограничения.
Класс, из которого создается политика ограничения, должен реализовывать интерфейс политики ограничения (IRateLimiterPolicy) - обобщенный интерфейс с одним параметром-типом TPartitionKey - типом ключа секционирования. В интерфейсе политики ограничения определены метод GetPartition и свойство OnRejected. Метод GetPartition возвращает на основе значения контекста запроса (значения типа HttpContext) структуру данных для секции со значением параметра-типа этой структуры, равным указанному в параметре-типе интерфейса типу ключа секционирования. Эта структура содержит значение ключа секционирования для запроса, определенное из переданного контекста, и делегат создания ограничителя секции. Следует отметить, что по параметрам и возвращаемому значению метод GetPartition идентичен секционирующему делегату с типом ресурса HttpContext и тем же типом ключа секционирования TPartitionKey, что и для интерфейса политики. Поэтому этот метод логично называть секционирующий метод. С одной стороны, он может быть присвоен переменной имеющей тип секционирующего делегата(с типом ресурса HttpContext и тем же типом ключа секционирования), а с другой - может быть реализован с помощью секционирующего делегата с теми же типами ресурса и ключа секционирования.
Свойство OnRejected интерфейса политики содержит специфичный для политики обработчик отказа в разрешении на выполнение запроса, попадающего под действие этой политики. Этот обработчик, если он не null, вызывается вместо обработчика по умолчанию для всех запросов, попадающих под действие политики, даже если отказ был получен от глобального ограничителя, а не от ограничителя, созданного на основе политики. Если это свойство содержит null, используется заданный в параметрах настройки ограничителя обработчик отказа по умолчанию (а при его отсутствии для ответа на запрос просто устанавливается указанный в параметрах настройки код статуса HTTP).
Интерфейс политики ограничения (точнее, класс, его реализующий) используется как основа (исходная политика) для создания политики - как именованной, так и безымянной.
Подробности
Исходный код интерфейса IRateLimiterPolicy с моими комментариями:
namespace Microsoft.AspNetCore.RateLimiting; public interface IRateLimiterPolicy<TPartitionKey> // параметр-тип TPartitionKey - тип значения ключа секционирования { //Метод, возвращающий для указанного контекста запроса данные для секции - // значение ключа секционирования (типа TPartitionKey) и делегат, создающий объект базового ограничителя на основе значения этого ключа //Обратите внимание, что параметры и возвращаемое значение этого метода совпадают с таковыми для секционирующего делегата с типом ресурса HttpContext. RateLimitPartition<TPartitionKey> GetPartition(HttpContext httpContext); //Свойство, возвращающее специфичный для политики делегат, который выполняется (асинхронно), если он определен (т.е. - не null), если разрешение не получено, // вместо аналогичного делегата, указанного в свойстве параметров настройки ограничителя RateLimiterOptions.OnRejected Func<OnRejectedContext, CancellationToken, ValueTask>? OnRejected { get; } }
Приготовленные политики
В общем случае используемый при создании политики тип ключа секционирования для интерфейса исходной политики или исходного секционирующего делегата, на базе которого политика создается, может быть произвольным. Но, с другой стороны, компонент-обработчик ограничителя запросов в ASP.NET Core использует для реализации политик секционированный ограничитель с конкретным типом ключа секционирования - составным ключом политики. Поэтому он может напрямую использовать только такие политики, которые реализуют обобщенный интерфейс политики (IRateLimiterPolicy) именно с этим типом ключа секционирования в качестве параметра-типа. Такие политики - объекты, непосредственно используемые ограничителем, реализующим механизм ограничения по политикам - далее в статье называются приготовленные политики, а класс этих объектов, реализующий интерфейс приготовленной политики - класс хранения политики. Для реализации метода GetPartition интерфейса приготовленной политики в классе хранения политики используется специальный делегат, знающий и использующий особым образом структуру составного ключа приготовленной политики. Этот делегат в статье называется делегат-адаптер (почему используется именно такое название, будет объяснено в соответствующем разделе).
Создание приготовленных политик в большинстве случаев происходит в момент добавления исходных политик в настройки: безымянных - при связывании их с точкой назначения маршрутизации в методе расширения RequireRateLimiting, именованных - в одном из методов RateLimiterOptions.AddPolicy, добавляющих именованные политики в параметры настройки ограничителя. Однако в одном из вариантов метода добавления именованных политик RateLimiterOptions.AddPolicy создание приготовленной политики откладывается до момента инициализации компонента-обработчика ограничения. Так сделано, чтобы можно было использовать внедрение зависимостей из контейнера сервисов через конструктор реализующего политику класса. В момент добавления политик в параметры настройки ограничителя контейнер сервисов ещё не создан, а потому получить из него значение внедряемого параметра для конструктора экземпляра класса, реализующего политику, невозможно. В момент же инициализации компонента-обработчика ограничения контейнер сервисов уже доступен.
При добавлении такой политики в параметры настройки ограничения добавляется не сама приготовленная политика(в виде экземпляра класса хранения политики), а делегат, принимающий в качестве параметра контейнер сервисов. Вызов этого делегата на этапе инициализации компонента-обработчика ограничения создаст экземпляр класса хранения для этой приготовленной политики.
Подробности
Класс хранения политики называется DefaultRateLimiterPolicy. Исходный код класса DefaultRateLimiterPolicy:
namespace Microsoft.AspNetCore.RateLimiting; internal sealed class DefaultRateLimiterPolicy : IRateLimiterPolicy<DefaultKeyType> { private readonly Func<HttpContext, RateLimitPartition<DefaultKeyType>> _partitioner; private readonly Func<OnRejectedContext, CancellationToken, ValueTask>? _onRejected; public DefaultRateLimiterPolicy(Func<HttpContext, RateLimitPartition<DefaultKeyType>> partitioner, Func<OnRejectedContext, CancellationToken, ValueTask>? onRejected) //Реализация интерфейса IRateLimiterPolicy<DefaultkeyType> public Func<OnRejectedContext, CancellationToken, ValueTask>? OnRejected => _onRejected; public RateLimitPartition<DefaultKeyType> GetPartition(HttpContext httpContext) { return _partitioner(httpContext); } }
Этот класс реализует интерфейс приготовленной политики ограничения - специализацию обобщенного интерфейса политики ограничения типом составного ключа политики IRateLimiterPolicy<DefaultKeyType> . Особенность класса DefaultRateLimiterPolicy - реализация секционирующего метода этого интерфейса (GetPartition) через вызов секционирующего делегата (с типом ресурса HttpContext), хранящегося в поле _partitioner: этот делегат передается в конструктор экземпляра этого класса и сохраняется в этом поле. В коде компонента ограничения запросов для ASP.NET Core в качестве такого секционирующего делегата всегда используется делегат-адаптер, создаваемый статическим методом RateLimiterOptions.ConvertPartitioner, о нем будет рассказано далее.
Делегат, который содержится в свойстве OnRejected (или null вместо него), также передается в конструктор и сохраняется в поле _onRejected экземпляра класса хранения политики, свойство OnRejected возвращает значение этого поля.
Составной ключ приготовленной политики.
Составной ключ приготовленной политики (или просто Составной ключ политики) объединяет в себе имя политики (для безымянных политик оно равно null), и необязательное значение исходного ключа секционирования - ключа секционирования для исходной политики ограничения запросов или исходного секционирующего делегата, из которых эта приготовленная политика была создана. Как уже упоминалось, класс хранения политики ограничения реализует обобщенный интерфейс политики ограничения IRateLimiterPolicy, специфицированный типом составного ключа политики. Сравнение двух составных ключей на равенство и вычисление их хэш-кодов при обработке политик в компоненте-обработчике ограничения производится исключительно на основе этих двух свойств - имени политики и исходного ключа секционирования. Составной ключ политики содержит внутри себя также дополнительные данные, необходимые для работы секционирующего метода приготовленной политики - то есть, делегата-адаптера, но при сравнении составных ключей эти данные игнорируются.
Подробности
Тип ключа секционирования приготовленной политики называется DefaultKeyType. Исходный код класса DefaultKeyType, с моими комментариями:
namespace Microsoft.AspNetCore.RateLimiting; internal struct DefaultKeyType { public DefaultKeyType(string? policyName, object? key, object? factory = null); public string? PolicyName { get; } public object? Key { get; } // Фактический тип этого поля - TPartitionKey (тип исходного ключа секционирования ) public object? Factory { get; } //Фактический тип этого поля Func<TPartitionKey, RateLimiter>, // оно используется при работе делегата-адаптера, реализующего секционирующий метод хранимой политики DefaultRateLimiterPolicy.GetPartition }
Свойство PolicyName содержит имя приготовленной политики. Для безымянной приготовленной политики оно равно null. Свойство Key содержит значение исходного ключа секционирования полученное из контекста запроса вызовом секционирующего метода исходной политики (или исходного секционирующего делегата). Это поле может быть равно null. Свойство Factory содержит делегат, возвращающий ограничитель (базовый) секции для исходного ключа секционирования, который передается в него как как параметр. Этот делегат копируется делегатом-адаптером из возвращаемых исходным секционирующим делегатом или методом данных для секции (поля Factory структуры RateLimitPartition) при создании составного ключа. Вызывается этот сохраненный делегат при вызове созданного делегатом-адаптером делегата, возвращающего данные для секции с ключом типа составного ключа политики. Как именно выглядит и работает этот созданный делегатом-адаптером делегат см. далее в описании метода RateLimiterOptions.ConvertPartitioner.
Свойства Key и Factory имеют тип Object чтобы обеспечить универсальность составных ключей приготовленных политик, созданных для разных типов исходных ключей: возможность добавлять их в одну и ту же хэш-таблицу (словарь), проверять их на равенство друг другу и т.д. Можно было бы обеспечить универсальность и без потери на этапе компиляции информации о реальном типе свойств составного ключа. Я, ради интереса, реализовал эту строгую типизацию для составного ключа политики, результат находится в скрытом тексте “Про код” в разделе “Сводка”, кому интересно, могут там найти этот кусок и посмотреть (ссылку не даю, т.к. ссылки внутрь скрытого текста работают плохо). Но команда, разработавшая ограничитель обработки запросов, этим заморачиваться не стала, так что имеем то, что имеем.
Но, в любом случае, этот уход от статической типизации ничему реально не мешает: все работающие с составным ключом делегаты создаются внутри обобщенного метода ConvertPartitioner (он будет рассмотрен дальше), реальные типы свойств составного ключа выводятся из параметра-типа этого метода, и значения этих свойств составного ключа вполне надежно приводятся к их реальным типам в создаваемых этим методом делегатах.
Как мы видим, ключ секционирования хранимой политики политики - довольно хитрая штука. Он содержит не только значения, используемые для проверки на равенство ключей секционирования - на практике для сравнения ключей секционирования хранимой политики используются только первые два поля, - но и делегат, используемый при получении данных для секции из контекста запроса. Подробнее об этом см. в описании работы делегата-адаптера.
Методы добавления именованных политик ограничения запросов в параметры настройки компонента-ограничителя.
Именованные политики ограничения запросов, добавленные в объект класса параметров ограничителя RateLimiterOptions идентифицируются по строке - имени политики, т.е каждая именованная политика имеет уникальное имя.
Для добавления именованных политик в параметры компонента-обработчика ограничения запросов (объект класса RateLimiterOptions) используется один из трех вариантов метода AddPolicy. Все эти варианты являются обобщенными, с первым (и, возможно, единственным) параметром-типом TPartitionKey - типом исходного ключа секционирования, используемого политикой. Кроме того, все эти методы принимают в качестве первого параметра строку - имя политики. Возвращают все методы AddPolicy ссылку на экземпляр параметров ограничителя, для которого они вызываются - это можно использовать для организации вызовов этих методов по цепочке.
В первом варианте метода AddPolicy - RateLimiterOptions AddPolicy<TPartitionKey>(string policyName, Func<HttpContext, RateLimitPartition<TPartitionKey>> partitioner) - через второй параметр partitioner передается исходный секционирующий делегат (что это - см. здесь), который возвращает данные для секции на основе указанного значения HttpContext. А потому с этим вариантом можно использовать те же самые ранее описанные вспомогательные методы из класса RateLimiterPartition для создания секционированных ограничителей на основе базовых алгоритмических, что и при создании глобального секционированного ограничителя, см. пример из руководства. Однако этот, первый, вариант метода AddPolicy не позволяет задать в политике специфичный для нее обработчик отказа в выделении разрешения. Поэтому при обработке отказа запросов, попадающих под такую политику, всегда будет использоваться логика обработки по умолчанию (то есть, устанавливаться в ответе код статуса по умолчанию и вызываться обработчик отказа по умолчанию, если он вообще был указан в параметрах настройки ограничения).
Два остальных варианта метода AddPolicy добавляют именованную политику использующую заданный в их параметрах интерфейс исходной политики ограничения. Второй вариант AddPolicy - public RateLimiterOptions AddPolicy<TPartitionKey>(string policyName, IRateLimiterPolicy<TPartitionKey> policy) - получает через второй параметр ссылку на существующий объект, реализующий этот интерфейс. В третьем варианте метода AddPolicy - public RateLimiterOptions AddPolicy<TPartitionKey, TPolicy>(string policyName) where TPolicy : IRateLimiterPolicy<TPartitionKey> - класс, реализующий интерфейс политики ограничения, передается в качестве второго параметра-типа этого обобщенного метода. Эти два варианта, в отличие от первого, позволяют задать (через параметр политики ограничения) специфический для добавляемой именованной политики обработчик отказа в выдаче разрешения.
Методы AddPolicy класса настройки параметров обработчика преобразуют переданные им параметры либо в приготовленные политики (экземпляры класса хранения политики)- первые две формы, либо в делегаты, создающие экземпляры класса хранения политики - третья форма.
Подробности
В классе настройки параметров обработчика добавленные в него именованные политики хранятся в одном из двух списков:
internal Dictionary<string, DefaultRateLimiterPolicy> PolicyMap { get; } internal Dictionary<string, Func<IServiceProvider, DefaultRateLimiterPolicy>> UnactivatedPolicyMap { get; }
Каждый список является словарем с ключом - именем политики.
Первый список содержит уже созданные экземпляры приготовленных политик. Второй список содержит делегаты с параметром - ссылкой на контейнер сервисов (при инициализации компонента-обработчика через этот параметр передается ссылка на контейнер сервисов приложения), создающие при инициализации компонента-обработчика ограничения соответствующие приготовленные политики, подробности см. в описании инициализации компонента-обработчика.
Все варианты метода AddPolicy прежде всего проверяют по обоим спискам, что политика с таким именем ещё не была добавлена, и что все обязательные параметры не содержат null.
Первый вариант метода AddPolicy создает экземпляр класса хранения политики DefaultRateLimiterPolicy, в конструктор которого в качестве секционирующего делегата передается созданный методом ConvertPartitioner делегат-адаптер для второго параметра этого метода (исходного секционирующего делегата), а делегат, обрабатывающий отказы, для свойства OnRejected устанавливается в null. Затем этот экземпляр добавляется в список созданных именованных политик PolicyMap.
Второй вариант метода AddPolicy создает экземпляр класса хранения политики DefaultRateLimiterPolicy, в конструктор которого в качестве секционирующего делегата передается созданный методом ConvertPartitioner делегат-адаптер для метода GetPartition интерфейса исходной политики ограничения (точнее, созданного для этого метода делегата), переданного во втором параметре метода AddPolicy, а делегат для свойства обработчика отказа в выделении разрешения OnRejected копируется из одноименного свойства этой политики. Затем этот экземпляр опять-таки добавляется в список созданных именованных политик PolicyMap.
Третий вариант метода AddPolicy, в который передается не экземпляр, а тип класса, реализующего исходную политику, создает не экземпляр класса хранения политики DefaultRateLimiterPolicy, но делегат, который создает такой экземпляр. Этот делегат затем добавляется в список делегатов, создающих экземпляры класса хранения политики UnactivatedPolicyMap. Это позволяет отложить создание класса хранения политики до момента инициализации компонента-обработчика ограничения, чтобы использовать внедрение зависимостей в конструктор этого класса. Так сделано, потому что создание класса хранения политики требует предварительного создания экземпляра соответствующего класса, реализующего интерфейс политики ограничения. Создание же этого экземпляра может требовать, в общем случае, доступа к контейнеру сервисов для внедрения зависимостей, на которые опирается этот класс. Но контейнер сервисов в момент добавления политики в параметры настройки компонента-обработчика ограничения ещё не создан, а потому создание экземпляра политики в этот момент может быть невозможно. Именно поэтому третий вариант метода AddPolicy создает делегат для создания экземпляра класса хранения политики, а не сам экземпляр, и сохраняет его в списке делегатов, создающих экземпляры класса хранения политики. А вот на этапе инициализации компонента-обработчика ограничения контейнер сервисов уже доступен, а потому конструктор компонента-обработчика вполне способен использовать его для создания экземпляра класса хранения политики с помощью сохраненного в параметрах настройки делегата.
Делегат, создающий экземпляр класса хранения политики для третьего варианта метода AddPolicy, после проверок параметров, во-первых, создает с помощью ActivatorUtilities.CreateInstance экземпляр класса TPolicy, реализующего исходную политику - для этого как раз и требуется получаемая делегатом через входной параметр ссылка на контейнер сервисов приложения - а во-вторых, использует созданный экземпляр, реализующий интерфейс IRateLimiterPolicy<TPartitionKey> для создания на его основе экземпляра класса хранения политики точно так же, как это делается во втором варианте метода - оборачивая метод GetPartition исходной политики в делегат-адаптер с помощью метода ConvertPartitioner и копируя из исходной политики делегат обработчика отказа в выделении разрешения OnRejected.
Создание и работа делегата-адаптера
Для полного понимания работы как методов добавления именованных политик ограничения, так и метода GetPartition класса хранения политики осталось рассмотреть метод ConvertPartitioner класса параметров настройки компонента-ограничителя (RateLimiterOptions). Этот метод создает делегат-адаптер - секционирующий делегат для возвращения данных для секции на основе составного ключа приготовленной политики (тип DefaultKeyType). В метод ConvertPartitioner передаются в качестве параметров имя политики (для безымянной политики передается null) и исходный секционирующий делегат - это либо параметр метода AddPolicy, либо делегат для метода GetPartition исходной политики ограничения - той, которая либо была передана в метод AddPolicy для добавления именованной политики, либо была привязана через метаданные маршрутизации (см. далее) - для безымянной. Метод ConvertPartitioner - обобщенный, его единственный параметр-тип TPartitionKey является типом ключа секционирования исходного секционирующего делегата, далее он называется исходный ключ секционирования. Метод ConvertPartitioner - статический, а потому может быть вызван независимо от наличия экземпляра параметров настройки ограничителя (и это делается при создании экземпляров класса хранения для безымянных политик).
Созданный делегат-адаптер используется затем как основа для реализации метода секционирования GetPartition приготовленной политики (то есть, класса хранения политики DefaultRateLimiterPolicy): он передается в его конструктор, сохраняется во внутреннем поле экземпляра класса хранения политики и вызывается при вызове этого метода с теми параметрами, которые были переданы в метод GetPartition, а результат вызова делегата-адаптера возвращается в качестве результата этого метода. Делегат-адаптер называется адаптером, потому что он при своей работе вызывает исходный секционирующий делегат с переданным в адаптер значением HttpContext и использует возвращенные исходным секционирующим делегатом данные для секции на базе исходного ключа секционирования для формирования своих данных для секции на базе составного ключа политики. Подробности, как именно делегат-адаптер формирует данные для секции - составные ключи и делегаты создания ограничителей для секций, см. в скрытом тексте ниже. Здесь же стоит остановиться на том, в каких случаях созданные составные ключи оказываются одинаковыми или различаются - и как это влияет на независимость политик и на их селективность.
Как будет рассказано в описании работы компонента-обработчика ограничения в ASP.NET Core, составные ключи приготовленной политики сравниваются (внутри секционированного ограничителя, реализующего ограничения по политикам - он описан далее) и по имени политики, и по значению исходного ключа. Поэтому запросы, попадающие под разные именованные политики, гарантированно будут попадать в разные секции, с разными базовыми ограничителями. А потому именованные политики ограничения друг на друга не влияют. Но для безымянных политик отсутствие взаимного влияния, вообще говоря, не гарантируется: для них всех используется одно и то же (пустое) имя политики, а потому запросы, попадающие под разные безымянные политики, вполне могут попасть в одну и ту же секцию и обрабатываться одним и тем же ограничителем секции (базовым, то есть, не селективным) - если значения исходных ключей, извлеченных из контекста этих запросов, окажутся равны. То есть, безымянные политики влиять друг на друга могут. И именно поэтому я в руководстве (первой статье цикла) рекомендовал не использовать в приложении более одной безымянной политики, по крайней мере - с одним типом ключа: вероятность ошибочного равенства при сравнении для ключей разных типов сильно меньше, чем для ключей одного и того же типа.
Если исходный секционирующий делегат обладает селективностью - то есть, создает отдельные секции с отдельными ограничителям для разных контекстов запросов, отличающихся по каким-либо параметрам, то такой же селективностью обладает и созданный для него делегат-адаптер приготовленной политики: запросы с разными значениями исходного ключа секционирования, получаемого при вызове исходного секционирующего делегата, будут попадать в разные секции, с разными ограничителями для этих секций - теми же, что и для секций, создаваемых по исходному ключу.
Подробности
Создаваемый методом ConvertPartitioner делегат-адаптер возвращает данные для секции для составного ключа, создаваемого на основе переданного на его вход контекста запроса: то есть, он возвращает значение составного ключа типа DefaultKeyType для переданного ему контекста запроса и делегат создания ограничителя секции, создающий объект базового ограничителя для этого значения ключа. Возвращаемые значения объединяются в запись данных для секции ограничителя (типа RateLimitPartition<DefaultKeyType>), к которой относится этот запрос. Таким образом, по своей сигнатуре делегат-адаптер является секционирующим делегатом с типом ресурса HttpContext и типом ключа секционирования DefaultKeyType.
Сигнатура метода ConvertPartitioner::
internal static Func<HttpContext, RateLimitPartition<DefaultKeyType>> ConvertPartitioner<TPartitionKey>(string? policyName, Func<HttpContext, RateLimitPartition<TPartitionKey>> partitioner)
Создаваемый методом ConvertPartitioner делегат-адаптер называется так, потому что является адаптером для исходного секционирующего делегата или секционирующего метода исходной политики: для его создания в метод ConvertPartitioner передается (кроме имени политики) исходный делегат, создающий запись данных для секции с другим типом ключа (TPartitionKey), и работа созданного делегата основывается на вызове исходного делегата и преобразовании результата этого вызова.
Как и полагается секционирующему делегату, делегат-адаптер, созданный методом ConvertPartitioner, возвращает, исходя из переданного ему значения HttpContext, данные для секции, в данном случае - с ключом секционирования типа DefaultKeyType, типа ключа для приготовленной политики. Как было написано в одной из предыдущих статей этого цикла, эти данные - структура обобщенного типа RateLimiterPartition<DefaultKeyType> - состоят из значения составного ключа политики типа DefaultKeyType и делегата создания ограничителя секции для указанного значения составного ключа политикиЮ который возвращает (обычно - создавая его заново) ограничитель секции - базовый (неселективный) ограничитель, выполняющий ограничение всех запросов, попадающих в секцию с этим значением составного ключа.
Для получения значения составного ключа и создания делегата, возвращающего ограничитель секции, исходя из контекста запроса, делегат-адаптер прежде всего вызывает для переданного в него контекста запроса исходный секционирующий делегат, сохраненный в замыкании делегата-адаптера, сформированном в методе ConvertPartitioner. Затем делегат-адаптер создает составной ключ политики из имени политики (оно передается как параметр в метод ConvertPartitioner и сохраняется в замыкании делегата-адаптера) и значения исходного ключа секционирования, которое возвращает исходный секционирующий делегат в данных для секции с исходным типом ключа. Имя политики сохраняется в свойстве PolicyName объекта составного ключа, а значение исходного ключа - в свойстве Key. При этом его тип “стирается”, так как формально это свойство имеет тип Object, базовый для любого типа в .NET. “Стирается” здесь написано в кавычках - потому что, стирается тип исключительно с точки зрения статической типизации на этапе компиляции: так-то исполняющая система .NET в процессе исполнения всегда знает реальные типы объектов, и, в том числе, умеет различать обобщенные типы, специализированные разными типами-параметрами. В объекте составного ключа, в его свойстве Factory, также сохраняется и делегат создания ограничителя секции для исходного типа ключа секционирования. Этот делегат берется из тех же возвращенных данных для секции с исходным ключом - из свойства с тем же именем. Значение этого свойства будет использовано в делегате создания ограничителя секции, входящего в возвращаемые делегатом-адаптером данные для секции. Тип этого делегата в этом свойстве также “стирается”: свойство Factory тоже имеет тип Object. Можно было бы сделать работу с составным ключом политики, сохранив статическую типизацию (я описал, как это делается, в скрытом тексте “Про код” в разделе “Сводка”), но и в имеющемся варианте этот уход от статической типизации ничему реально не мешает: все работающие с составным ключом делегаты создаются внутри обобщенного метода ConvertPartitioner, реальные типы свойств составного ключа выводятся из параметра-типа этого метода, и значения этих свойств приводятся к нужным, известным внутри метода ConvertPartitioner, типам в создаваемых этим методом делегатах.
Делегат создания ограничителя секции для указанного составного ключа работает следующим образом: он извлекает из составного ключа значение исходного ключа и исходный делегат создания ограничителя секции для этого ключа, восстанавливает их типы (на этапе создания делегата в методе ConvertPartitioner эти типы известны - они выводятся из параметра-типа обобщенного метода ConvertPartitioner), вызывает исходный делегат для получения ограничителя секции с исходным ключом и возвращает этот ограничитель.
То есть, логика работы созданного методом ConvertPartitioner<TPartitionKey> делегата-адаптера следующая:
сначала делегат-адаптер вызывает исходный секционирующий делегат или метод исходной политики, возвращающий для переданного в делегат-адаптер значения HttpContext данные для секции с типом ключа TPartitionKey (эти данные содержат значение исходного ключа, извлеченное из этого HttpContext, и делегат создания ограничителя секции для этого значения ключа);
затем делегат-адаптер создает значение составного ключа приготовленной политики(типа DefaultKeyType): в свойство PolicyName помещается имя политики, в свойство Key - значение исходного ключа из данных для секции, возвращенных исходным делегатом, и в свойство Factory - делегат создания ограничителя секции, создающий базовый ограничитель, из тех же данных для секции;
и, наконец, делегат-адаптер создает и возвращает данные для секции с типом ключа DefaultKeyType: в качестве ключа в этих данных используется созданное на предыдущем шаге значение составного ключа, а в качестве делегата создания ограничителя секции - делегат, который вызывает исходный делегат из поля Factory переданного ему составного ключа с аргументом - значением исходного ключа из поля Key и возвращает полученный базовый ограничитель, перед вызовом значения полей Key и Factory внутри этого делегата создания ограничителя преобразуются к нужным типам (см. комментарии к этим полям в исходном коде класса DefaultKeyType выше). Безопасность типов это по факту не нарушает, так как эти типы известны либо напрямую выводятся внутри создавшего делегат-адаптер обобщенного метода ConvertPartitioner через его параметр-тип.
Вспомогательные методы добавления простейших политик в параметры настройки ограничителя.
В руководстве по использованию ограничителя запросов (первой статье цикла), уже было рассказано про методы добавления простейших именованных политик ограничения запросов в класс параметров настройки ограничителя (RateLimiterOptions): приведены примеры, как их использовать, и рассказано об их возможностях. Здесь я собираюсь рассмотреть их подробнее: как они устроены и почему их возможности ограничены.
Напомню, что эти методы - AddFixedWindowLimiter, AddSlidingWindowLimiter, AddTokenBucketLimiter, AddConcurrencyLimiter - добавляют в параметры настройки ограничителя именованные политики, основанные, соответственно, на алгоритмах ограничения с фиксированным окном, ограничения со скользящим окном, ограничение с пополняемой емкостью для жетонов и ограничения параллелизма. Первый дополнительный параметр этих методов - строка, задающая имя политики, второй - делегат, устанавливающий параметры для настройки создаваемого базового ограничителя, реализующего соответствующий алгоритм. Классы, содержащие параметры настройки - свои для каждого из классов базовых ограничителей. Они были рассмотрены статье этого цикла про устройство и работу классов базовых ограничителей.
Все эти методы устроены одинаково. Сначала они создают экземпляр класса настроек для реализующего нужный алгоритм класса базового ограничителя. Потом они вызывают делегат, устанавливающий настройки, полученный через второй параметр. В него передается созданный экземпляр класса настроек. В первых трех из рассматриваемых методов - которые добавляют политику, использующую базовые ограничителями скорости) - после вызова этого делегата в параметрах для создания базового ограничителя принудительно отключается автоматический режим пополнения: поле AutoReplenishment устанавливается в false, чуть дальше я объясню, зачем это делается. И последнее, что делают эти методы - добавляют в параметры настройки ограничителя политику с указанным в первом параметре именем, используя первый вариант метода AddPolicy - с секционирующим исходным делегатом в качестве параметра. Этот секционирующий делегат возвращает одни и те же данные для секции для любого значения контекста запроса, для этого он вызывает соответствующий используемому алгоритму вспомогательный статический метод для создания секционирующих делегатов, описанный в одной из предыдущих статей цикла с одним и тем же значением ключа секционирования для всех контекстов запроса. Использование этих вспомогательных методов объясняет, зачем в параметрах настройки создаваемых базовых ограничителей скорости, принудительно отключается автоматический режим пополнения: эти вспомогательные методы тоже в любом случае отключают автоматический режим пополнения, но в них для этого приходится размещать в куче копию объекта настроек. Поэтому, чтобы не отводить эту лишнюю память, автоматический режим пополнения сразу отключается и в рассматриваемых методах добавления простейших политик.
Такое устройство методов добавления простейших политик объясняет ограничения их возможностей, про которые сказано в руководстве (в скрытом тексте). Во-первых, все запросы, попадающие под простейшую политику, ограничиваются совместно, потому что секционирующий делегат, передаваемый в метод AddPolicy, возвращает для любого контекста запроса одно и то же значение ключа, а потому секционирующий делегат-адаптер приготовленной политики возвращает в своих данных для секции для любого контекста запроса одно и то же значение составного ключа политики. Во-вторых, специфическую для политики функцию обратного вызова при отклонении запроса для простейшей политики указать нельзя, потому что ее не позволяет задать та форма (первая) метода AddPolicy, которой эта политика добавляется в параметры настройки ограничителя.
Подробности
Вспомогательные методы добавления простейших политик в параметры настройки ограничителя определены ка методы расширения класса расширения для класса RateLimiterOptions в классе RateLimiterOptionsExtensions. Сигнатура класса RateLimiterOptionsExtensions:
namespace Microsoft.AspNetCore.RateLimiting; public static class RateLimiterOptionsExtensions { public static RateLimiterOptions AddTokenBucketLimiter(this RateLimiterOptions options, string policyName, Action<TokenBucketRateLimiterOptions> configureOptions) // ... реализация метода public static RateLimiterOptions AddFixedWindowLimiter(this RateLimiterOptions options, string policyName, Action<FixedWindowRateLimiterOptions> configureOptions) // ... реализация метода public static RateLimiterOptions AddSlidingWindowLimiter(this RateLimiterOptions options, string policyName, Action<SlidingWindowRateLimiterOptions> configureOptions) // ... реализация метода public static RateLimiterOptions AddConcurrencyLimiter(this RateLimiterOptions options, string policyName, Action<ConcurrencyLimiterOptions> configureOptions) // ... реализация метода }
Более подробно о реализации методов класса RateLimiterOptionsExtensions. Все они реализованы одинаково, отличие есть только в зависящих от алгоритма элементах: в используемом классе параметров настройки базового ограничителя и во вспомогательном методе, используемом при создании политики. Первым делом, сразу после проверки параметров, вспомогательные методы создают фиксированный ключ типа PolicyNameKey на основе имени политики из первого параметра. Экземпляр этого типа фактически является оберткой для строки, из которой он создан - имени политики. После этого методы создают и инициализируют экземпляр параметров настройки для создания базового алгоритмического ограничителя, реализующего алгоритм, применяемый в политике. Как это происходит, описано в основном тексте. И, наконец, каждый из методов добавляет простейшую политику с помощью того варианта метода AddPolicy, который принимает в качестве параметра исходный секционирующий делегат. Этот делегат вызывает тот вспомогательный статический метод для создания секционирующих делегатов, который использует соответствующий алгоритм. В качестве параметра-типа (типа исходного ключа) для этого метода используется тип фиксированного ключа (PolicyNameKey), а в качестве аргументов в него передаются значение фиксированного ключа (первый параметр) и делегат, игнорирующий значение ключа секционирования и всегда возвращающий созданный ранее экземпляр параметров настройки (второй параметр). Возвращает исходный секционирующий делегат значение данных для секции, полученное от вспомогательного метода. В результате возвращаемые исходным секционирующим делегатом данные для секции всегда содержат один и тот же ключ секционирования - фиксированный ключ, созданный в начале метода добавления простейшей политики.
Совершенно непонятно, зачем нужно было создавать фиксированный ключ другого типа вместо того, чтобы просто использовать строку. Похоже (судя по некоторым комментариям в коде), что авторы ограничителя запросов к веб-приложению в ASP.NET Core пытались избежать каких-то конфликтов, но в текущем коде возможностей для конфликта не просматривается, так как конфликты между разными именованными политиками невозможны. В составной ключ политики входит имя секции, а потому разные политики используют разные секции ограничителя по политикам (см. описание работы компонента-обработчика). Но, с другой стороны, фиксированный ключ создается однократно, а при добавлении простейшей политики однократно же сохраняется в замыкании создаваемого для нее секционирующего делегата, и к сколь-нибудь заметным потерям в производительности это излишнее действие не приводит.
Связывание политик ограничения запросов с метаданными маршрутизации.
Как уже говорилось в первой статье цикла, для привязки политики ограничения запросов к маршрутам и точкам назначения подсистемы маршрутизации используется либо добавление атрибута [EnableRateLimiter] к атрибутам маршрутизации точки назначения, либо вызов метода расширения RequireRateLimiting для интерфейса IEndpointConventionBuilder, создаваемого методами, создающими маршруты - как стандартными для конкретных функций фреймворков, так и специфичными для конкретного приложения. Внутренняя реализация привязки политик к метаданным маршрутизации для обоих вариантов одинакова: она основывается на добавлении в эти метаданные экземпляра класса EnableRateLimitingAttribute. К сожалению, описание, как устроена и работает настройка метаданных для маршрутизации, и как эти метаданные связываются с запросом в процессе маршрутизации, выходит далеко за рамки этого цикла статей, а потому на этом при описании подробностей, что и как при этом происходит, я вынужден остановиться. Для этой статьи важно, что при обработке запроса компонент-обработчик ограничения анализирует метаданные маршрутизации этого запроса и, если в метаданных присутствует добавленный туда в процессе привязки экземпляр класса EnableRateLimitingAttribute, применяет указанную в нем политику.
Класс EnableRateLimitingAttribute содержит два свойства только для чтения - одно публичное, PolicyName - имя политики, другое - внутреннее, Policy, содержащее ссылку на саму политику (на экземпляр уже рассмотренного выше класса хранения политики DefaultRateLimiterPolicy) - и два конструктора: тоже один публичный, а другой внутренний, каждый конструктор устанавливает значение только одного из этих свойств. То есть, хранящийся в метаданных маршрута экземпляр EnableRateLimitingAttribute может хранить либо имя политики ограничения запросов, либо саму политику в виде экземпляра класса хранения. Первый вариант связывает с маршрутом именованную политику, которая должна быть обязательно добавлена в параметры настройки ограничителя. Второй вариант связывает с маршрутом безымянную политику, которую не нужно добавлять в параметры настройки - она указывается непосредственно в аргументе, с которым вызывается этот вариант метода RequireRateLimiting.
Используя атрибут [EnableRateLimiter], можно указать в нем только имя политики, то есть, через атрибут можно привязать только именованную политику. А вот у метода RequireRateLimiting есть два варианта. Первый связывает с маршрутом именованную политику с именем, указанным в ее параметре policyName. Второй связывает с маршрутом приготовленную безымянную политику, создаваемую из переданной в метод исходной политики ограничения.
Подробности
Исходный код класса EnableRateLimitingAttribute:
public sealed class EnableRateLimitingAttribute : Attribute { public string? PolicyName { get; } internal DefaultRateLimiterPolicy? Policy { get; } public EnableRateLimitingAttribute(string policyName) { ArgumentNullException.ThrowIfNull(policyName); PolicyName = policyName; } internal EnableRateLimitingAttribute(DefaultRateLimiterPolicy policy) { Policy = policy; } }
Указание атрибута [EnableRateLimiter] добавляет экземпляр класса EnableRateLimitingAttribute с именем именованной политики при обработке атрибутов (напомню, что в атрибуте суффикс Attribute имени класса можно не указывать). А у метода RequireRateLimiting есть два варианта. Первый связывает с маршрутом именованную политику с именем, указанным в ее параметре policyName. Второй связывает с маршрутом приготовленную безымянную политику, создаваемую из переданной в метод исходной политики ограничения. Этот метод добавляет в свойство Metadata интерфейса IEndpointConventionBuilder, которое используется при настройке метаданных для маршрута/точки назначения, экземпляр класса EnableRateLimitingAttribute. При этом в метаданные маршрутизации можно добавить любой из вариантов экземпляра этого класса - или с именем именованной политики, или с приготовленной безымянной политикой. Какой именно вариант будет добавлен, зависит от вызванного варианта метода RequireRateLimiting.
Экземпляр класса хранения (приготовленная политика) для добавляемой вторым формой вариантом метода RequireRateLimiting безымянной политики создается из переданного в метод через параметр policy интерфейса исходной политики ограничения. Приготовленная политика создается аналогично тому, как это делается во втором варианте метода AddPolicy класса параметров настройки ограничителя: путем использования внутреннего статического метода RateLimiterOptions.ConvertPartitioner этого класса, с единственным отличием - имя этой приготовленной политики устанавливается в null.
Как было сказано ранее (и как это станет ясно из приведенного далее описания обработки политик ограничения запросов компонентом-обработчиком ограничения запросов), одинаковое, равное null, значение имен разных безымянных политик во внутренних структурах данных ограничителя запросов ASP.NET Core может потенциально вызвать конфликты между несколькими разными безымянными политиками, по крайней мере - если они используют один и тот же тип исходного ключа секционирования (параметр-тип TPartitionKey интерфейса исходной политики ограничения): запросы, обрабатываемые одной безымянной политикой могут попадать на обработку ограничителю секции, созданной на базе другой политики. Поэтому, как сказано в руководстве (первой статье цикла) использования в приложении нескольких разных безымянных политик, по крайней мере - с одинаковым типом ключей секционирования, а лучше - вообще любых, следует избегать.
Отключение ограничения запросов к точке назначения через метаданные маршрутизации.
Для отключения ограничения запросов для конкретных точек назначения маршрутизации можно добавить в их метаданные ссылку на экземпляр класса DisableRateLimitingAttribute. Тогда, компонент-обработчик, обнаружив наличие экземпляра этого класса в метаданных, пропускает весь процесс получения разрешений на выполнение этого запроса и передает его дальше по конвейеру. Для такой точки назначения пропускается именно любое ограничение запросов, налагаемое не только политикой, но и глобальным ограничителем. Подробнее об этом написано в описании работы компонента-обработчика далее в этой статье.
Добавить в метаданные точки назначения ссылку на экземпляр класса DisableRateLimitingAttribute, аналогично добавлению класса привязки политики EnableRateLimitingAttribute, можно либо путем добавления атрибута [DisableRateLimiter] к атрибутам маршрутизации конечной точки, либо вызовом метода метода расширения DisableRateLimiting для интерфейса IEndpointConventionBuilder, возвращаемого методом, устанавливающим этот маршрут или группу маршрутов.
Подробности
Исходный код класса DisableRateLimitingAttribute:
namespace Microsoft.AspNetCore.RateLimiting; /AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)] public sealed class DisableRateLimitingAttribute : Attribute { internal static DisableRateLimitingAttribute Instance { get; } = new DisableRateLimitingAttribute(); }
Как мы видим, внутри экземпляра класса нет никаких специфичных данных. Поэтому метод расширения DisableRateLimiting не создает новый экземпляр этого класса а добавляет ссылку на существующий статический экземпляр Instance:
public static TBuilder DisableRateLimiting<TBuilder>(this TBuilder builder) where TBuilder : IEndpointConventionBuilder { ArgumentNullException.ThrowIfNull(builder); builder.Add(endpointBuilder => { endpointBuilder.Metadata.Add(DisableRateLimitingAttribute.Instance); }); return builder; }
Инициализация и работа компонента-обработчика ограничителя запросов.
Экземпляр класса компонента-обработчика ограничения запросов (он называется RateLimitingMiddleware), как и полагается компоненту конвейера обработки запросов, добавляемому через UseMiddleware, создается и инициализируется (вызовом ActivatorUtilities.CretateInstance) и добавляется в конвейер при создании конвейера. А при обработке запроса вызывается метод Invoke этого экземпляра(я описывал эти процессы в своей более ранней статье ). В этом разделе я опишу, что происходит при инициализации экземпляра компонента-обработчика ограничения (вызове его конструктора) и при обработке запроса - вызове метода Invoke.
Ограничение скорости выполнения запросов в компоненте-обработчике производится одним из двух механизмов: глобальным ограничителем и политиками ограничения. Глобальный ограничитель, если он установлен, действует на все запросы, кроме тех, для точек назначения которых ограничение явным образом отключено - то есть, в их в метаданных маршрутизации содержится ссылка на экземпляр класса DisableRateLimitingAttribute. Политики ограничения действуют только на те запросы, которые попадают в их область действия - то есть, для запросов, в метаданных маршрутизации которых содержится экземпляр класса EnableRateLimitingAttribute, содержащий либо имя именованной политики, либо безымянную политику, которая применяется к этому запросу. Если в метаданных маршрутизации при этом указано также, что ограничение для этого запроса отключено, то отключение ограничения имеет приоритет - ограничение такого запроса не производится.
Инициализация компонента-обработчика ограничителя.
Конструктор класса компонента-обработчика ограничителя RateLimitingMiddleware получает через свои параметры ссылку на следующий компонент конвейера (типа RequestDelegate, передается из UseMiddleware), параметры настройки компонента-обработчика в формате Options Pattern (типа IOptions<RateLimiterOptions> - они могут, в зависимости от использованной формы метода UseRateLimiting, либо передаваться из этого метода, либо внедряться из контейнера сервисов в соответствии с Options Pattern - и ещё ряд дополнительных зависимостей-сервисов (сервисы ведения журнала, сбора метрик, сам контейнер сервисов), которые внедряются из контейнера сервисов, и на которых я здесь останавливаться не буду.
Конструктор сохраняет в соответствующие поля экземпляра компонента-обработчика все переданные ему параметры кроме того, который содержит ссылку на объект параметров его настройки. Информация из объекта параметров настройки сохраняется отдельно. Свойства первой и второй групп объекта параметров настройки (ссылка на глобальный ограничитель и действия по умолчанию при получении отказа в разрешении, см. описание параметров настройки ограничителя выше) также копируются в соответствующие им поля в экземпляре компонента-обработчика ограничителя. Далее создается и сохраняется в соответствующем поле экземпляра компонента-обработчика список-словарь именованных политик. Ключами в этом словаре являются имена политик, а значениями - приготовленные политики с этими именам. Первоначальное содержимое словаря копируется из списка созданных именованных политик в параметрах настройки компонента-ограничителя. Затем конструктор создает и добавляет в этот словарь политики, делегаты для создания которых хранятся в списке этих делегатов в параметрах настройки.
И, наконец, конструктор создает и запоминает во внутреннем поле экземпляра компонента-обработчика секционированный ограничитель, реализующий механизм ограничения по политикам. Для краткости далее я буду называть его ограничитель по политикам.
Подробности
Поля объекта компонента-обработчика ограничения запросов в ASP.NET Core, устанавливаемые в конструкторе:
RequestDelegate _next - ссылка на следующий компонент-обработчик в конвейере;
Func<OnRejectedContext, CancellationToken, ValueTask>? _defaultOnRejected - действие по умолчанию при отказе в разрешении;
ILogger _logger - интерфейс журналирования;
RateLimitingMetrics _metrics - объект, реализующий сбор метрик;
PartitionedRateLimiter? _globalLimiter - глобальный ограничитель запросов (селективный по параметрам запроса);
PartitionedRateLimiter _endpointLimiter - ограничитель по политикам (секционированный, ключ секции - составной ключ политики);
int _rejectionStatusCode - код статуса HTTP, устанавливаемый перед выполнением (или вместо выполнения) действия при отказе;
Dictionary<string, DefaultRateLimiterPolicy> _policyMap - список (словарь) именованных приготовленных политик, в него копируется содержимое словаря из свойства PolicyMap параметров настройки, и добавляются приготовленные политики, созданные вызовами делегатов, хранящихся в свойстве UnactivatedPolicyMap параметров настройки;
DefaultKeyType _defaultPolicyKey - фиктивный ключ секционирования, используемый ограничителем по политикам для запроса, не попадающего ни под какую политику ;
Ограничитель по политикам.
Ограничитель по политикам - это секционированный ограничитель, который обрабатывает политики. При создании этого ограничителя в его конструктор, кроме секционирующего делегата, дополнительно передается нестандартный компаратор для сравнения экземпляров составного ключа политики. Этот компаратор рассчитывает хэш-код экземпляра и сравнивает экземпляры исключительно по полям имени политики (PolicyName) и исходного ключа секционирования(Key), без учета содержимого поля Factory.
Секционирующий делегат, используемый в ограничителе по политикам, работает следующим образом. Прежде всего он получает из метаданных маршрутизации запроса приготовленную политику, под действие которой попадает этот запрос. В зависимости от содержимого метаданных, приготовленная политика берется из списка именованных приготовленных политик, созданного при инициализации компонента-обработчика (если в метаданных указано имя политики) или непосредственно из самих метаданных (если в них была указана безымянная политика). Если запрос не попадает под действие какой-либо политики, то секционирующий делегат формирует данные для секции из фиктивного ключа (из поля _defaultPolicyKey) и делегата, который возвращает ссылку на фиктивный базовый ограничитель, всегда возвращающий ответ с выданным разрешением для любой заявки (этот делегат создается вспомогательным методом RateLimiterPartition.GetNoLimiter). Иначе говоря, не попадающие под политики запросы не ограничиваются - разрешение для них выдается всегда.
Если же запрос попадает под действие политики, то секционирующий делегат ограничителя по политикам вызывает секционирующий метод (GetPartition) этой приготовленной политики, который целиком состоит из вызова секционирующего делегата-адаптера, созданного методом ConvertPartition при создании этой приготовленной политики. Как работает этот делегат-адаптер - описано выше, в посвященном ему разделе. Результат вызова секционирующего метода приготовленной политики - данные для секции, в которую попадает запрос - возвращается в качестве результата вызова секционирующего делегата ограничителя по политикам.
Возвращенные секционирующим методом приготовленной политики данные для секции используются обычным образом для определения базового ограничителя, который будет выдавать ответы на заявки на разрешения к ограничителю по политикам.
Подробности
Параметры-типы этого секционированного ограничителя: тип ресурса - HttpContext, тип ключа секционирования - составной ключ политики (DefaultKeyType). Компаратором, используемым для создания секционированного ограничителя по политикам, является экземпляр класса DefaultKeyTypeEqualityComparer, который реализует интерфейс IEqualityComparer для составного ключа политики DefaultKeyType.
Политика в метаданных маршрутизации указывается через помещение в эти метаданные экземпляра класса EnableRateLimitingAttribute. Этот класс может содержать или имя именованной политики в свойстве PolicyName, или политику (безымянную) в свойстве Policy. Технически, этот класс может содержать и имя политики, и саму политику, но эта комбинация бессмысленна и на практике не применяется: конструкторы класса EnableRateLimitingAttribute могут инициализировать либо одно, либо другое свойство, а секционирующий делегат ограничителя по политикам, встретив непустую политику в поле Policy, содержимое поля PolicyName игнорирует. Если в экземпляре класса EnableRateLimitingAttribute в метаданных ни одно из этих свойств не указано, то секционирующий делегат ограничителя по политикам вызывает исключение InvalidOperationException. Если политика указана в метаданных по ее имени, то секционирующий делегат ограничителя по политикам находит эту политику в списке (словаре) именованных политик в поле _policyMap компонента-обработчика ограничителя и использует для ограничения по политикам ее, если политики с таким именем нет - опять-таки, вызывается исключение InvalidOperationException. Если в метаданных маршрутизации указана политика (безымянная) в поле Policy, то для ограничения по политикам используется эта политика.
Обработка запроса компонентом-обработчиком ограничителя - метод Invoke.
При обработке запроса компонент-обработчик ограничителя прежде всего проверяет, должен ли обрабатываемый запрос попадать под ограничение. И, если запрос под ограничение не попадает - либо ограничение для него явным образом отключено, либо ни один из механизмов ограничения на него действовать не может, потому что он не попадает под действие ни одной политики, а глобальный ограничитель при этом не установлен (соответствующее поле равно null) - то компонент-обработчик ограничителя просто передает такой запрос запрос дальше по конвейеру.
Если же запрос потенциально попадает под ограничение, то, если глобальный ограничитель установлен, то компонент-обработчик ограничения сначала подает заявку глобальному ограничителю. При этом, любая заявка в компоненте-обработчике ограничителя ASP.NET всегда запрашивает ровно одно разрешение. При получении от глобального ограничителя ответа на заявку с отказом компонент-обработчик переходит к обработке отказа в разрешении. Иначе (если глобальный ограничитель не установлен, либо выдал ответ на заявку с разрешением) компонент обработчик подает заявку ограничителю на основе политик - который, как написано выше, для запросов, не попадающих ни под одну политику, всегда выдает ответ с разрешением. При получении и от ограничителя на основе политик ответа на заявку с разрешением компонент-обработчик передает обрабатываемый запрос дальше по конвейеру. Таким образом, для получения разрешения на дальнейшую обработку запрос должен получить разрешение от обоих механизмов, но если один из механизмов к запросу не применим, то считается, что разрешение от него получается автоматически.
При получении от любого механизма ответа с отказом в разрешении компонент-обработчик выполняет обработку отказа в разрешении. Для этого он прежде всего записывает код статуса HTTP, указанный в его настройках, в ответ на запрос. Затем компонент-обработчик ищет делегат действия, которое он должен выполнить при отказе. Для этого, он прежде всего проверяет, что запрос не был просто отменен в то время, пока он стоял в очереди на получение разрешения, и, если запрос был отменен - не делает больше ничего.
Далее компонент-обработчик проверяет, не был ли получен отказ от ограничителя на основе политик, и, если это так - не указано ли в политике специфичное для нее действие при отказе (свойство OnRejected политики не равно null). В таком случае компонент-обработчик использует для обработки отказа это действие. Если же предыдущее условие по той или иной причине не выполняется, то в качестве действия при обработке отказа выбирается действие по умолчанию, если оно установлено (соответствующее поле не содержит null). Если действие при отказе найдено, то его делегат вызывается, и в этот делегат передается информация об отказе - контекст запроса и ответ на заявку с отказом - а также - маркер отмены запроса (свойство RequestAborted контекста запроса). Поскольку делегат этого действия - асинхронный (он возвращает ValueTask), то компонент-обработчик асинхронно ожидает его завершение через await и только после этого завершает обработку запроса. При отсутствии действия при отказе никакой обработчик, естественно, не вызывается, и обработка запроса, которому было отказано в выполнении по причине ограничения, сразу завершается.
Подробности
Обработка запроса, переданного в метод Invoke компонента-обработчика ограничения, производится в коде, распределенном по нескольким методам. Начальная проверка того, может ли запрос в принципе попасть под действие ограничения (то есть проверяется наличие экземпляра класса DisableRateLimitingAttribute в метаданных маршрутизации запроса либо отсутствие одновременно и настроенного в компоненте-обработчике глобального ограничителя, и экземпляра класса EnableRateLimitingAttribute в метаданных маршрутизации) производится непосредственно в методе Invoke компонента-обработчика ограничения. Если запрос в принципе не может попасть под ограничение, то метод Invoke просто вызывает следующий обработчик в конвейере. А если может, то для дальнейшей обработки запроса вызывается метод InvokeInternal.
Метод InvokeInternal (он, так же, как и Invoke, является асинхронным, т.е. возвращает задачу, через которую можно отслеживать завершение его работы) прежде всего создает контекст сбора метрик. Сбор метрик - это отдельная и довольно большая тема, в которую я в этом цикле не углубляюсь: я ограничусь тем, что буду просто указывать, где какие метрики собираются в компоненте ограничения ASP.NET, но не буду останавливаться на том, как именно это делается. А конкретно контекст сбора метрик нужен для согласованности сбора метрик: он содержит имя политики, под которую попадает запрос и флаги (свойства типа Boolean), указывающие на состояние сбора (включен/выключен) тех метрик, которые связаны с работой компонента-обработчика ограничения. Обновление этих метрик производится через вызовы методов класса RateLimitingMetrics, в которые обязательно передается этот контекст.
Затем метод InvokeInternal подает заявку на обработку запроса, вызывая метод TryAcquireAsync (работа этого метода будет описана дальше), асинхронно ожидает его завершения и получает как результат этого вызова контекст ответа на заявку, содержащий сам ответ. Дальнейшая работа метода InvokeInternal зависит от того, было ли в ответе на заявку получено разрешение на обработку запроса.
Если разрешение было получено, то метод InvokeInternal фиксирует для процесса сбора метрик вызовом метода RateLimitingMetrics.LeaseStart начало использования полученного разрешения, затем передает управление дальше по конвейеру в следующий обработчик, асинхронно ожидает завершения этого обработчика, фиксирует для процесса сбора метрик вызовом RateLimitingMetrics.LeaseEnd завершение использования разрешения и завершает свою работу.
Если разрешение получено не было, то метод InvokeInternal фиксирует факт отказа в разрешении вызовом метода RateLimitingMetrics.LeaseFail и, в случае, если причиной отказа (эта причина содержится вместе с самим ответом на заявку в полученном от метода TryAcquireAsync контексте ответа) была не отмена запроса, выполняет обработку отказа в получении разрешения: сначала устанавливает значение кода статуса в то, которое было задано в параметрах настройки ограничителя (оно хранится в поле _rejectionStatusCode), а затем, если имеется в наличии делегат-обработчик отказа (т.е. значение содержащего его поля не равно null) - вызывает этот делегат. Делегат-обработчик отказа может быть установлен в двух местах: через свойство OnRejected интерфейса политики, под которую попадает запрос, либо как делегат-обработчик по умолчанию в параметрах настройки ограничения. Если установлены оба делегата-обработчика и запрос был отвергнут ограничителем (не важно, каким именно), то будет вызван тот обработчик, который указан в политике.
В любом случае при завершении работы метода InvokeInternal объект ответа на заявку очищается путем очистки контекста ответа на заявку, в котором этот объект ответа содержится.
Метод TryAcquireAsync также является асинхронным: он возвращает объект ValueTask с результатом - контекстом ответа на заявку. Его сигнатура:
async ValueTask<LeaseContext> TryAcquireAsync(HttpContext context, MetricsContext metricsContext)
Контекст ответа на заявку - структура LeaseContext - содержит полученный ответ на заявку (поле Lease, равно null для отмененного запроса) и поле RequestRejectionReason перечисляемого типа, в котором указывается источник отказа, если был получен отказ или запрос был отменен (варианты источника отказа: от ограничителя по политике, от глобального ограничителя или из-за отмены запроса). Исходный код контекста ответа на заявку LeaseContext и перечисления RequestRejectionReason :
internal struct LeaseContext : IDisposable { public RequestRejectionReason? RequestRejectionReason { get; init; } public RateLimitLease? Lease { get; init; } public void Dispose() {Lease?.Dispose();} } internal enum RequestRejectionReason {EndpointLimiter, GlobalLimiter, RequestCanceled}
Контекст ответа является очищаемым - он реализует интерфейс IDisposable, его метод Dispose выполняет очистку ответа на заявку, содержащегося в контексте, если таковой есть.
Метод TryAcquireAsync сначала пытается получить разрешение синхронно (получает контекст ответа от метода CombinedAcquire), и если контекст содержит ответ с выданным разрешением - синхронно возвращает его. А если синхронно получить разрешение не удается, то метод пытается получить контекст ответа уже асинхронно, через метод CombinedWaitAsync как результат задачи(ValueTask), при этом предыдущий полученный синхронно контекст ответа не очищается, а просто теряется: содержащей его переменной просто присваивается новое значение. Обоим этим методам метод TryAcquireAsync передает контекст запроса (типа HttpContext), полученный через свой входной параметр. Методу CombinedWaitAsync дополнительно передается маркер отмены из контекста запроса (HttpContext.RequestAborted). То, что полученный от CombinedAcquire контекст с отказом теряется - это, конечно, огрех, но небольшой: среди используемых функцией ограничения скорости обработки запросов ASP.NET Core ограничителей нет таких, ответ с отказом от которых удерживал бы какие-то ресурсы. Но лучше, конечно, было бы не забывать очистить то, что предназначено быть очищенным.
Если ответ с разрешением получен сразу - синхронно, методом CombinedAcquire, причем ответ на заявку положительный (разрешение выдано) или же формально асинхронно, через метод CombinedWaitAsync, но возвращенная им задача уже завершена, то метод TryAsync немедленно возвращает полученный контекст ответа на заявку и завершается. Если же задача, полученная в ответ на вызов метода CombinedWaitAsync сразу не завершается (потому что заявка была поставлена в очередь), то метод TryAcquireAsync фиксирует для процесса сбора метрик вызовом метода RateLimitingMetrics.QueueStart начало ожидания получения ответа, асинхронно ожидает завершение метода CombinedWaitAsync, фиксирует для процесса сбора метрик вызовом RateLimitingMetrics.QueueEnd завершение ожидания в очереди и завершает свою работу.
На мой взгляд, первоначальная попытка получить разрешение синхронно, равно как и весь метод CombinedAcquire, тут лишние. Потому что в тех случаях, когда метод CombinedAcquire возвращает ответ с успешно выданным разрешением (когда в запасе ещё были доступные разрешения), метод CombinedWaitAsync также фактически выполняется синхронно, то есть возвращает уже завершенную задачу (ValueTask). Возврат полученного контекста ответа после проверки возвращенной этим методом задачи на то, что она уже завершена приведет ровно к тому же результату вне зависимости, вызывался ли метод CombinedAcquire или нет. Заодно исчезла бы возможность допустить описанный выше огрех - потерять контекст ответа без его очистки. Но, как сделано - так сделано, поменять это - вне пределов наших возможностей.
Методы CombinedAcquire и CombinedWaitAsync в целом устроены и работают примерно одинаково. Сигнатуры этих методов таковы:
LeaseContext CombinedAcquire(HttpContext context); ValueTask<LeaseContext> CombinedWaitAsync(HttpContext context, CancellationToken cancellationToken)
В оба метода подачи заявки ограничителям передается контекст запроса, а в асинхронный - дополнительно ещё и маркер отмены. Оба они возвращают контекст ответа на заявку (LeaseContext). Отличаются они характером работы - возвращают ли они результат - контекст ответа на заявку - синхронно, в виде экземпляра структуры LeaseContext, или асинхронно, как объект класса ValueTask<LeaseContext>, на котором для получения непосредственно результата следует выполнить асинхронное ожидание (await). Кроме того, эти методы различаются тем, какие методы подачи заявки ограничителям - глобальному ограничителю и ограничителю по политикам - они используют: синхронный (AttemptAcquire) или асинхронный (AcquireAsync).
Прежде всего, оба эти метода проверяют, настроен ли глобальный ограничитель, и если да - подают ему заявку на получение разрешения через соответствующий метод и получают ответ на заявку - сразу (CombinedAcquire) или после асинхронного ожидания (CombinedWaitAsync). Если в ответ на эту заявку они получают отказ, то они создают контекст ответа с этим отказом и источником отказа - глобальным ограничителем и завершают работу с этим результатом. Если глобальный ограничитель не установлен или разрешение от него было успешно получено, эти методы запрашивают разрешение у ограничителя по политикам. При получении от него ответа с отказом методы очищают ранее полученное разрешение от глобального ограничителя (если оно было получено) и возвращают контекст ответа с этим отказом и источником отказа - ограничением по политике.
Если глобальный ограничитель установлен, то ответы с полученными разрешениями от него и от ограничителя по политикам (напомним, что если запрос не попадает под действие ни одной политики, то ограничитель на основе политик выдает разрешение на его выполнение) комбинируются в один составной ответ (типа DefaultCombinedLease) с выданным разрешением, который и возвращается в контексте ответа. Это нужно, чтобы при последующей очистке контекста ответа в методе TryAcquireAsync выполнить очистку обоих полученных ответов с разрешениями. Если же глобальный ограничитель не установлен, а потому заявка на разрешение от него не подавалась, то методы CombinedAcquire и CombinedWaitAsync возвращают контекст ответа с тем разрешением, которое выдал ограничитель по политикам. При возникновении в процессе работы методов исключения (любого) уже полученные ответы на заявку очищаются, при этом, если исключение было вызвано из-за отмены запроса (возможно только в асинхронном методе CombinedWaitAsync), то этот асинхронный метод возвращает контекст ответа с пустым полем ответа и источником отказа - отменой запроса, в противном случае исключение выбрасывается повторно.
Про код
Про код
В коде функции ограничения скорости обработки запросов ASP.NET Core есть несколько мест, про которые стоило бы сказать отдельно.
Первое - это обработка отказа в получении разрешения. Прежде всего, мне кажется, что выбор по умолчанию для кода статуса HTTP для ответа на запрос значения 503 (Service Unavailable) неудачен: проблема-то возникает не на самом сервере, проблему вызывает-таки клиент, подающий слишком много запросов, и клиенту стоило бы указать на этот факт, возвращая код статуса HTTP 429 (Too Many Requests), по умолчанию. Кроме того, я считаю неправильным, что при вызове делегата OnRejected выделяется память в куче для некоего объекта контекста OnRejectedContext, который всего лишь содержит два объекта, то есть, две ссылки - на контекст запроса и на ответ на заявку с отказом. Ничто не мешало передавать эти ссылки как отдельные параметры в делегат OnRejected, не отводя лишнюю память, и я считаю, что так и нужно было бы сделать. Особенно - учитывая, что именно этот путь обработки запроса выбирается в условиях перегрузки приложения запросами, и может стать в этом случае критическим. Загружать сборщик мусора на критическом пути ненужной работой - это, я считаю, делать бы не стоило.
Второе интересное место место - это составной ключ политики DefaultKeyType и объект приготовленной политики, который этот ключ использует. Сам по себе составной ключ - штука идейно довольно простая, внешне он состоит из двух свойств - имени политики PolicyName и ключа Key, и все сравнение ключей политик и подсчет их хэш-кода идет по этим двум свойствам.
internal struct DefaultKeyType { //... public string? PolicyName { get; } // This is really a TPartitionKey public object? Key { get; } // This is really a Func<TPartitionKey, RateLimiter> public object? Factory { get; } }
Но есть тут нюанс - третье свойство, Factory: оно служит не для сравнения экземпляров составного ключа, а для сохранения в нем части результатов вызова исходного секционирующего делегата или секционирующего метода исходной политики, на основе которого приготовлена политика. А именно, при вызове секционирующего делегата/метода для получения данных об исходной секции в этом свойстве запоминается делегат, создающий ограничитель секции, делает это делегат-адаптер, создаваемый методом RateLimiterOptions.ConvertPartition (всё это было изложено несколько ранее, здесь я лишь повторяю):
internal static Func<HttpContext, RateLimitPartition<DefaultKeyType>> ConvertPartitioner<TPartitionKey>(string? policyName, Func<HttpContext, RateLimitPartition<TPartitionKey>> partitioner) { return context => { RateLimitPartition<TPartitionKey> partition = partitioner(context); var partitionKey = new DefaultKeyType(policyName, partition.PartitionKey, partition.Factory); return new RateLimitPartition<DefaultKeyType>(partitionKey, static key => ((Func<TPartitionKey, RateLimiter>)key.Factory!)((TPartitionKey)key.Key!)); }; }
Механизм обработки ограничений по политикам объединят в один объект-секционированный ограничитель политики с разными типами исходных ключей, и составной ключ политики является для него средством такого объединения. Поэтому составной ключ не может зависеть от типа ключей любой исходной политики. В коде компонента-обработчика эта независимость достигается довольно грубым приемом: чтобы составной ключ не зависел от типов ключей любых исходных политик, ряд его элементов - исходный ключ секционирования и делегат, создающий ограничитель секции - сохраняются в нем с типом Object, информация о типах этих элементов при этом полностью теряется при сохранении. В принципе, этот вариант вполне работоспособен, так как мест, где используются элементы общего типа Object вместо точного типа, немного: сам тип составного ключа, метод создания делегата-адаптера RateLimiterOptions.ConvertPartition и создаваемые в нем делегаты (а ещё - компаратор, определяющий равенство экземпляров составного ключа). И при использовании метода создания делегата-адаптера несложно привести эти элементы общего типа к необходимым точным типам: эти точные типы в методе создания делегата-адаптера известны.
Хотя именно так и делается в коде реального компонента-обработчика ограничителя (см. код выше), этот способ выглядит несколько неизящным. И ниже, кому интересно (а если вам не интересно - смело пропускайте), я демонстрирую способ решения подобных задач без потери информации о точных типах элементов составного ключа.
Проблема в этой задаче, которая не решается в лоб - в необходимости для сохранения информации о типах объектов (для разных объектов - разной) в условиях, когда эти, разнородные, объекты должны находиться в однородной структуре данных, в данном случае (в конечном итоге) - в наборе ключей словаря секций в секционированном ограничителе, реализующем политики. Типовой способ решения такой проблемы: использовать в качестве типа ключа интерфейс, который будут реализовывать все помещаемые в структуру данных объекты. Имя интерфейса пусть для простоты изложения, несмотря на нарушение обычных соглашений о наименованиях, совпадает с именем класса составного ключа из оригинального кода:
public interface DefaultKeyType : IEquatable<DefaultKeyType> { String? PolicyName { get; } Object? Key { get; } RateLimiter CreateRateLimiter(); }
В отличие от оригинального кода я здесь использовал для секционированного ограничителя не внешний класс-компаратор, а реализовал сравнение в самом объекте составного ключа через интерфейс IEquitable: мне показалось, что так проще организовать доступ к не-публичным полям объектов, а использующий код это только упростит. Пара слов по членам интерфейса ключа: свойство PolicyName полностью аналогично одноименному свойству из оригинального кода, свойство Key оставлено “чтобы было”, без него легко обойтись, а метод CreateLimiter заменяет свойство Factory оригинального класса составного ключа: он служит для получения ограничителя секции в делегате-адаптере.
Класс(обобщенный) для объектов составного ключа выглядит следующим образом:
public struct DefaultKeyType<TPartitionKey> : DefaultKeyType { public DefaultKeyType(String? policyName, TPartitionKey? key, Func<TPartitionKey, RateLimiter>? factory = null) { PolicyName=policyName; _key=key; _factory=factory!; } TPartitionKey? _key; public Func<TPartitionKey?, RateLimiter> _factory; public String? PolicyName { get; } public Object? Key => _key; public RateLimiter CreateRateLimiter() { return _factory.Invoke(_key); } Boolean IEquatable<DefaultKeyType>.Equals(DefaultKeyType? other) { if (other == null) return false; if (GetType()!=other.GetType()) return false; DefaultKeyType<TPartitionKey> typed_other = (DefaultKeyType<TPartitionKey>)other; if(_key==null && typed_other._key==null) return true; if(_key==null || typed_other._key==null) return false; else return _key.Equals(typed_other._key); } public override Int32 GetHashCode() { return (PolicyName?.GetHashCode()??0) ^ (_key?.GetHashCode()??0); } }
В этом объекте нет никаких полей/свойств общих типов, требующих приведения к точному типу (ну, кроме интерфейсного свойства Key, которое “чтобы было”). Сравнение и вычисление хэш-кода объектов (Equals и GetHashCode) составного ключа использует и свойство (автоматическое) имени политики - строку PolicyName, и поле исходного ключа _key точного типа. Исходные данные для секции, полученные из секционирующего делегата или метода - исходный ключ секционирования этой секции и делегат для создания ограничителя секции - сохраняются в полях соответствующих им точных типов _key и _factory, через вызов делегата в поле _factory реализуется интерфейсный метод CreateRateLimiter.
А метод создания делегата-адаптера для приготовленной политики выглядит следующим образом:
internal static Func<HttpContext, RateLimitPartition<DefaultKeyType>> ConvertPartitioner<TPartitionKey>(string? policyName, Func<HttpContext, RateLimitPartition<TPartitionKey>> partitioner) { return context => { RateLimitPartition<TPartitionKey> partition = partitioner(context); DefaultKeyType<TPartitionKey> partitionKey = new DefaultKeyType<TPartitionKey>(policyName, partition.PartitionKey); return new RateLimitPartition<DefaultKeyType>(partitionKey, partitionKey=>partitionKey.CreateRateLimiter()); }; }
делегат-адаптер получает исходные данные для секции на основании контекста запроса, создает составной ключ политики, сохраняя в нем имя политики, исходный ключ секционирования и исходный делегат создания ограничителя секции, и возвращает в качестве данных для секции ограничителя, реализующего политики, запись, содержащую этот составной ключ и, в качестве делегата, создающего ограничитель секции - делегат, вызывающий метод CreateRateLimiter составного ключа и возвращающий созданный им ограничитель.
Таким вот образом, нигде не жертвуя информацией о точных типах, можно организовать работу с составными ключами, содержащими исходные ключи разных типов.
Третий заслуживающий упоминания аспект - и он мне не нравится - в том, что разрешения от глобального ограничителя и от ограничителя по политикам запрашиваются в коде компонента-обработчика по очереди, причем запрашиваться они там (как и в сцепленных ограничителях) могут с задержкой, в асинхронном режиме. В результате, возможна ситуация, когда разрешение от глобального обработчика получается, но, пока происходит запрос от ограничителя по политикам, долго не используется. Тем самым доступное для обработки число разрешений может паразитным образом снижаться без того, чтобы запросы запускались на обработку. Лично я бы реализовал в случае чрезмерной задержки прекращение текущего ожидания по таймауту и повторный запрос разрешений. Но, как сделано, так сделано.
Приложение 1. Пример класса, реализующего интерфейс политики ограничения.
К сожалению, среди публично доступных классов компонента ограничения запросов в ASP.NET Core нет удобного для использования класса, реализующего интерфейс политики ограничения IRateLimiterPolicy. Это затрудняет возможность легко и просто использовать те методы добавления именованных политик, которые используют политику ограничения. В частности, при использовании только стандартных методов, описанных в Руководстве (первая статья цикла), без использования класса, реализующего IRateLimiterPolicy, невозможно задать при добавлении именованной политики специфичный для нее обработчик отказа в выдаче разрешения. Кроме того, без класса, реализующего этот интерфейс, невозможно использование безымянных политик (пример их использовании приведен в приложении к Руководству).
Поэтому в качестве первого примера нестандартного использования компонента ограничения запросов ASP.NET Core я решил сделать простой класс DelegatedRateLimiterPolicy, реализующий метод GetPartition интерфейса политики с помощью передаваемого в его конструктор секционирующего делегата нужного типа. С помощью этого класса несложно создавать объекты, реализующие интерфейс политики, которые можно использовать для добавления политик ограничения с расширенными относительно доступных только через стандартный набор функций возможностями - как именованных, так и безымянных. Этот класс входит в состав библиотеки классов MVVRus.AspNetCore.RateLimiting, она доступна в виде исходного кода на GitHub.
В качестве пример использования класса DelegatedRateLimiterPolicy я скопировал сюда из руководства пример использования этого класса в демонстрационном приложении Minimal API, в котором этот класс применяется для создания безымянной политики ограничения запросов с селекцией по пользователю и с использованием алгоритма фиксированного окна, привязанной к одной из точек назначения.
using MVVRus.AspNetCore.RateLimiting; using System.Threading.RateLimiting; var builder = WebApplication.CreateBuilder(args); builder.Services.AddRateLimiter(_ => { }); var app = builder.Build(); app.UseRateLimiter(); app.MapGet("/", () => "Hello World!"); app.MapGet("/limited", ()=>"Limited.").RequireRateLimiting(new DelegatedRateLimiterPolicy<String>( (HttpContext httpContext) => RateLimitPartition.GetFixedWindowLimiter( httpContext.User.Identity?.Name ?? "", _ => new FixedWindowRateLimiterOptions { PermitLimit = 10, QueueLimit = 0, Window = TimeSpan.FromMinutes(1) } ) )); app.Run();
В этом примере запросы с путем “/” (возвращающие строку “Hello World!”) доступны без ограничений, а запросы запросы с путем “/limited” (возвращающие строку “Limited.”) ограничены - их допускается всего десять в минуту. Т.к. аутентификация в этом приложении для простоты не настроена, то все запросы - анонимные, и потому они попадают под одно и то же ограничение. Если бы в приложении была настроена аутентификация, то запросы от разных пользователей ограничивались бы независимо друг от друга.
Далее приводится описание работы класса DelegatedRateLimiterPolicy, реализующего интерфейс политики ограничения с помощью секционирующего делегата. Оно предназначено исключительно для тех, кому интересно знать, как этот класс работает: чтобы его просто использовать этот класс, изложенное далее описание читать не обязательно.
Исходный код класса DelegatedRateLimiterPolicy, реализующего интерфейс IRateLimiterPolicy
Исходный код примера доступен на GitHub.
using Microsoft.AspNetCore.RateLimiting; using System.Threading.RateLimiting; namespace MVVRus.AspNetCore.RateLimiting { public class DelegatedRateLimiterPolicy<TPartitionKey> : IRateLimiterPolicy<TPartitionKey> { Func<HttpContext, RateLimitPartition<TPartitionKey>> _partitioner; public Func<OnRejectedContext, CancellationToken, ValueTask>? OnRejected { get; } public DelegatedRateLimiterPolicy(Func<HttpContext, RateLimitPartition<TPartitionKey>> partitioner, Func<OnRejectedContext, CancellationToken, ValueTask>? onRejected = null) { _partitioner = partitioner; OnRejected=onRejected; } public RateLimitPartition<TPartitionKey> GetPartition(HttpContext httpContext) { return _partitioner(httpContext); } } }
Класс DelegatedRateLimiterPolicy основан на той же идее, что и рассмотренный выше внутренний класс DefaultRateLimiterPolicy компонента ограничения ASP.NET Core, используемый в качестве класса хранения приготовленной политики: метод GetPartition в нем реализован через вызов делегата-первого параметра конструктора, имеющего тот же набор параметров и то же возвращаемое значение, что и метод GetPartition реализуемого интерфейса политики. Но, в отличие от класса хранения приготовленной политики DefaultRateLimiterPolicy, этот класс - обобщенный, он может использовать ключи секционирования любого типа: этот тип передается как параметр-тип класса. Тип делегата, который требуется для реализации метода GetPatrtition, оказывается точно таким же, как и тип делегата, передаваемого в метод PartitionedRateLimiter.Create для создания секционированного ограничителя. Так что в этом делегате для выбора и настройки алгоритма ограничения можно использовать те же самые вспомогательные статические методы класса RateLimitPartition для создания и настройки алгоритма ограничения - GetFixedWindowLimiter, GetSlidingWindowLimiter, GetTokenBucketLimiter, GetConcurrencyLimiter - что и для глобального ограничителя.
Второй, необязательный, параметр конструктора этого класса - это делегат обратного вызова для обработки отклонения запроса, специфический для этой политики. Он вызывается при получении отказа в выдаче разрешения, его функция описана в этой статье раньше.
Приложение 2. Политика, использующая произвольный селективный ограничитель.
Второй пример создания класса политики - более сложный. В нем демонстрируется, как можно с помощью политик ограничения решить более сложную задачу - использовать в политике в качестве ограничителя произвольный селективный ограничитель - любой, который можно использовать как глобальный ограничитель в свойстве GlobalLimiter в параметрах настройки ограничения RateLimiterOptions. Для краткости политику, рассматриваемую в данном разделе, я буду называть политика с селективным ограничителем
Прежде всего, поясню, в чем состоит сложность. Политики изначально устроены так, что их поведение может, за счет использования секционирующего метода или делегата в качестве их основы, имитировать поведение секционированного ограничителя - разбивать запросы на группы по значению одного параметра - ключа и применять отдельное ограничение в рамках одной политики (то есть, для конкретных точек назначения маршрутизации к каждой такой группе). В статье ранее было описано, как обработчик ограничений по политикам (технически он является секционированным ограничителем) использует эти ключи как часть составного ключа (другая часть - это имя политики) для секций, с которыми он работает. Но имитировать поведение сцепленного селективного ограничителя, использующего одновременно несколько селективных ограничителей, возможно - с разными ключами секционирования, политики неспособны. Отчасти это ограничение может смягчить появление сцепленных базовых ограничителей появившихся в версии .NET 10, но от ограничения селективности единственным параметром-ключом это все равно не избавит. Тем не менее, пожелания применять селективные ограничители общего вида в политике ограничения(то есть, использовать в качестве области применения этих селективных ограничителей конкретный набор из определенных маршрутов и точек назначения) на практике время от времени возникают. Поэтому в качестве более сложного примера нестандартного использования политик ограничения я и выбрал решение этой задачи.
Если у вас есть потребность использовать политику с селективным ограничителем, то можно просто скопировать себе из репозитория проект, содержащий эту политику, собрать себе сборку из этого проекта (в будущем я собираюсь оформить эту сборку в виде NuGet-пакета, но пока что это не сделано) и сразу переходить к примеру в следующем разделе, чтобы посмотреть, какие действия нужно выполнить для использования политики на основе селективного ограничителя.
Для тех же, кому интересно, как эта политика устроена и работает, я описал это в последующих разделах приложения. Однако это объяснение, как работает такая политика, требует предварительного изучения материала из этой статьи, как вообще устроены и применяются политики - а это ещё много букв, читать которые потребует дополнительного времени и желания. Но если время и желание есть - добро пожаловать.
Пример настройки веб-приложения для использования политики с селективным ограничителем.
Пример простейшего веб-приложения (созданного из шаблона ASP.NET Core Empty), использующего политику на основании сцепленного селективного ограничителя с именем “SelectivePolicy”, применяемую только к корню приложения. Пример доступен на GitHub:
using Microsoft.AspNetCore.RateLimiting; using MVVRus.AspNetCore.RateLimiting; using System.Threading.RateLimiting; using static Microsoft.AspNetCore.Http.StatusCodes; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); String policy_name = "SelectivePolicy"; //Имя политики. Можно выбрать любое. //Шаг 1: конфигурирование контейнера сервисов на использование функции ограничения запросов ASP.NET Core с политикой с селективным ограничителем. builder.Services.AddRateLimiter( options => { //Шаг 1.1: создание сцепленного селективного ограничителя, объединяющего два секционированных PartitionedRateLimiter<HttpContext> limiter = PartitionedRateLimiter.CreateChained( //Шаг 1.1.1: создание первого секционированного ограничителя PartitionedRateLimiter.Create( (HttpContext ctx) => RateLimitPartition.GetFixedWindowLimiter( ctx.User.Identity?.Name??"", _ => new FixedWindowRateLimiterOptions() { PermitLimit=10, Window=TimeSpan.FromMinutes(1) } ) ), //Шаг 1.1.2: Создание второго секционированного ограничителя PartitionedRateLimiter.Create( (HttpContext ctx) => RateLimitPartition.GetConcurrencyLimiter( ctx.User.Identity?.Name??"", _ => new ConcurrencyLimiterOptions() { PermitLimit=3 } ) ) ); //Шаг 1.2: создание политики с селективным ограничителем SelectiveRateLimiterPolicy policy = new SelectiveRateLimiterPolicy( limiter, (context, _) => { context.HttpContext.Response.StatusCode=Status429TooManyRequests; return ValueTask.CompletedTask; } ); //Шаг 1.3: добавление политики с селективным ограничителем в параметры настройки ограничителя options.AddPolicy(policy_name, policy); //... далее могут быть установлены другие параметры настройки ограничения } ); var app = builder.Build(); //Шаг 2: добавление в конвейер компонента-обработчика ключа контекста app.UseBackLink(); //Шаг 3: добавление в конвейер компонента-обработчика ограничителя app.UseRateLimiter(); //Шаг 4: Подключение политики к точке назначения маршрутизации. app.MapGet("/", () => "Hello World!").RequireRateLimiting(policy_name); app.Run();
Пример иллюстрирует подключение именованной политики, основанной на сцепленном селективном ограничителе, к точке назначения маршрутизации (в данном случае, эта точка назначения - корень веб-приложения). Подключение выполняется в несколько шагов.
-
Добавление именованной политики в параметры настройки ограничителя ASP.NET Core; этот шаг состоит из нескольких подшагов:
1.1 создание сцепленного селективного ограничителя, этот ограничитель одновременно ограничивает число запросов от одного пользователя величиной 10 в минуту и число параллельно выполняемых запросов от одного пользователя - величиной 3;
1.2 создание политики на базе этого сцепленного селективного ограничителя;
1.3: добавление политики с селективным ограничителем в параметры настройки ограничителя;
добавление в конвейер компонента-обработчика ключа контекста запроса, требуемого для работы политики ограничения с селективным ограничителем;
добавление в конвейер компонента-обработчика ограничителя, который будет обрабатывать запросы в соответствии с политикой;
указание использовать политику для обработки подключений к точке назначения Minimal API, размещенной в корне приложения.
В остальных разделах этого приложения содержится описание того, как устроена и работает политика с селективным ограничителем. Для того, чтобы просто использовать политику с селективным ограничителем в своей программе, читать эти разделы не обязательно.
Код, реализующий политику с селективным ограничителем, является частью проекта-примера (его исходный код доступен на GitHub). В этот же проект входит и предыдущий пример класса DelegatedRateLimiterPolicy, реализующего интерфейс политики ограничения на базе секционирующего делегата, но к тому примеру относится только один файл - DelegatedRateLimiterPolicy.cs, - а остальные файлы в папке относятся к описываемому примеру. И даже по количеству файлов видно, насколько этот пример сложнее того, предыдущего.
О добавлении политики с селективным ограничителем.
Чтобы использовать в приложении политику с селективным ограничителем, нужно добавить ее в параметры настройки ограничителя на этапе конфигурирования сервисов, например - в делегате, передаваемом в метод AddRateLimiter. Для добавления следует использовать метод AddPolicy класса параметров настройки ограничителя RateLimiterOptions - ту его форму, которая принимает в качестве аргумента интерфейс политики ограничения. Добавляемую политику и селективный ограничитель для нее необходимо предварительно создать. Пример добавления в параметры настройки политики ограничения с селективным ограничителем приведен выше. Эту же политику можно добавить как безымянную, использовав форму метода RequireRateLimiting, принимающую в качестве параметра политику (а не ее имя), пример, как это делается (на примере другой политики) - в приложении в первой статье цикла (руководстве)
В силу того, как организована обработка политик ограничения в компоненте-обработчике ограничителя, хоть в метод GetPartition политики и передается значение контекста обрабатываемого запроса (типа HttpContext), нельзя просто так взять и использовать это значение для вызова селективного ограничителя, чтобы получить от него разрешение на выполнение запроса (или отказ в таком разрешении). Точнее, можно было бы, если бы контекст запроса (HttpContext) был бы уникален для каждого нового запроса - а это необязательно, т.к. один и тот же контекст может использоваться повторно для обработки разных следующих друг за другом запросов.
Почему?
Тогда можно было бы использовать HttpContext как ключ секции в секционирующем методе GetPartition политики ограничения, а в качеств делегата в данных для секции, создающего ограничитель этой секции (его входной параметр, напоминаю - ключ секции) - вернуть делегат, который вызывает исходный селективный ограничитель, на котором основана политика, с параметром-ресурсом - этим самым контекстом, запомненным в замыкании созданного делегата. Но контекст запроса в ASP.NET Core может использоваться повторно при обработке совсем другого запроса (и его параметры при этом, естественно, будут другими). А потому, если вновь созданная при обработке изначального запроса секция сохранится до начала повторного использования контекста, использованного для этого изначального запроса, селективный ограничитель может быть вызван из ограничителя этой секции при обработке запроса с тем же повторно использованным объектом контекста, но - используемым при обработке совсем другого запроса с совсем другими параметрами. А при хорошем потоке запросов - хотя бы десяток в секунду - секция ограничителя может пережить обработку запроса и дожить до повторного использования контекста запросто: 10 секунд времени жизни секции (оно жестко прописано, по крайней мере - в версии для .NET 9) в режиме простоя - это, при таком темпе - целая вечность.
Ключ секционирования для политики с селективным ограничителем.
В силу изложенного выше, от ключа секционирования политики с селективным ограничителем требуется, чтобы он был во-первых, уникальным в рамках всего приложения среди всех однотипных ключей в течение достаточно большого промежутка времени - такого, чтобы секция, для которой он является ключом, состарилась и была удалена из списка секций ограничителя по политикам. Ну, а во-вторых, ключ секции должен содержать ссылку на контекст запроса, в котором он был создан - чтобы делегат в данных для секции, возвращаемых методом GetPartition политики, смог бы найти и использовать этот контекст для вызова селективного ограничителя. Для разработки политики с селективным ограничением я создал именно такой ключ - ключ контекста запроса - с нужными свойствами, для внешнего мира он имеет интерфейс IHttpContextBackLinkFeature. Этот ключ используется в качестве ключа секционирования в политике с селективным ограничителем.
Подробности
Для сравнения на равенство ключей контекста запроса интерфейс IHttpContextBackLinkFeature унаследован от интерфейса IEquatable<IHttpContextBackLinkFeature>, и, соответственно, любая реализация этого интерфейса обязана реализовывать и типизированный метод сравнения ключей контекста запроса Equals. Плюс к этому, ключи запроса являются очищаемыми (disposable), так как интерфейс IHttpContextBackLinkFeature является наследником IDisposable. Интерфейс ключа контекста запроса содержит свойство BackLink - ссылку на контекст запроса, для которого создан этот ключ, и событие DisposedEvent без каких-либо дополнительных данных, через которое происходит оповещение об очистке объекта ключа контекста.
Объект ключа контекста реализуется классом BackLinkImpl, который кроме интерфейса ключа контекста также реализует интерфейс IEquatable для самого себя. Сравнение на равенство для этого объекта выполняется одним из методов Equals (их несколько с разными типами параметров), которые проверяют, что второй объект, с которым выполняется сравнение, тоже имеет тип BackLinkImpl и содержит в поле _key то же самое значение. А поле _key всегда уникально, потому что устанавливается в значение, которое возвращает Interlocked.Increment статического поля класса. Ну, точнее, рано или поздно произойдет переполнение, и значения начнут повторяться, но поскольку значения ключа - 64-битные, то времени до повторения того же значения ключа пройдет очень много: приращение ключа производится один раз за запрос и всего на единицу, а запросов - не миллиарды в секунду, и даже не миллионы, так что возможностью повторения ключа в течение времени жизни секции можно пренебречь.
При очистке ключа контекста в его методе Dispose вызывается событие DisposedEvent, которое используется, чтобы обеспечить как можно более раннее устаревание, удаление из списка и отправку в мусор секции ограничителя по политикам, ссылающейся на этот ключ, и всего, что с ней связано (как именно это делается, подробнее написано дальше).
Компонент-обработчик для управления ключом контекста запроса
Класс политики ограничения с селективным ограничителем, таким образом, реализует интерфейс IRateLimiterPolicy<IHttpContextBackLinkFeature> Так как веб-сервер при создании контекста запроса ничего про этот ключ не знает и добавить его в контекст запроса не может, то этот ключ необходимо создать и добавить в контекст запроса самостоятельно, чтобы политика с селективным ограничителем (её метод GetPartition) могла его извлечь и использовать. Я принял решение, что создает и добавляет ключ специально написанный компонент-обработчик(middleware) ключа контекста запроса. Чтобы политика с селективным ограничителем, могла применяться, необходимо этот компонент-обработчик добавить в конвейер приложения перед компонентом-обработчиком ограничителя. Это удобно сделать с помощью вызова метода расширения UseBackLink для интерфейса построителя конвейера IApplicationBuilder. Ключ контекста запроса сохраняется с использованием стандартного для ASP.NET Core, но не слишком хорошо документированного механизма расширения - коллекции функций (feature collection), которая содержится в свойстве Features класса контекста запроса HttpContext. Компонент-обработчик ключа контекста добавляет его в коллекцию функций как тип IHttpContextBackLinkFeature и вызывает следующий обработчик конвейера. После выполнения обработки запроса последующими обработчиками в конвейере компонент-обработчик ключа контекста удаляет ключ контекста для текущего запроса из коллекции функций и вызывает его очистку, чтобы сделать секцию в ограничителе по политикам с ключом контекста запроса, обработка которого была закончена, устаревшей и побыстрее удалить ее из списка(точнее словаря) секций (как именно это происходит, описано дальше по тексту).
Подробности
Метод расширения UseBackLink определен в классе BackLinkBuilderExtensions. Он использует UseMiddleware для установки в конвейер компонента-обработчика класса BackLinkMiddleware. Метод Invoke класса BackLinkMiddleware создает экземпляр объекта ключа контекста класса BackLinkImpl, ссылающегося на текущий контекст запроса, добавляет его в список функций Features этого контекста как тип IHttpContextBackLinkFeature, запускает следующий компонент-обработчик конвейера, асинхронно дожидается его выполнения и, после завершения этого компонента-обработчика, удаляет объект ключа контекста запроса из списка функций запроса и выполняет очистку (вызывает метод Dispose()) этого объекта.
В принципе, возможно альтернативное решение, которое выглядит, на первый взгляд, лучше: создавать объект ключа контекста не в этом компоненте-обработчике, а непосредственно в методе секционирования политики GetPartition. Я отказался от этого решения по причине того, что своевременную очистку этого ключа выполнять всё-таки желательно, чтобы как можно быстрее убрать запись секции для этого ключа из ограничителя по политикам со всеми ссылками, которые эта запись содержит (по завершении обработки этого запроса эта запись не нужна - она только лишнюю память жрет), а в самом методе GetPartition такую очистку выполнять ещё рано. Так что делать очистку объекта ключа контекста все равно придется в том же самом компоненте-обработчике для управления ключом контекста, а для этого все равно этот ключ придется добавлять в коллекцию функций запроса. То есть, много на таком решении не выиграть. Но зато в таком альтернативном решении есть шанс незаметно забыть добавить компонент-обработчик, увеличив тем самым время жизни совершенно ненужной записи секции - сейчас-то отсутствие компонента обработчика приведет к отсутствию в коллекции функций запроса ключа, что сразу же вызовет исключение, которое не заметить труднее.
Класс политики с селективным ограничителем.
В конструктор объекта политики с селективным ограничением передается первым параметром ссылка на используемый в нем селективный ограничитель, она запоминается во внутреннем поле объекта.
Метод секционирования GetPartition политики с селективным ограничением, прежде всего, извлекает из переданного ему контекста запроса ключ контекста политики (если этого ключа нет - вызывается исключение) и возвращает данные для секции, содержащие извлеченный ключ запроса и делегат, получающий ключ секционирования и создающий для него базовый (т.е., не селективный) ограничитель секции. Эти данные для секции с ключом контекста будут использованы, как это было описано выше, секционирующим ограничителем, реализующим ограничение по политикам, для создание секции с составным ключом политики, включающим этот исходный ключ контекста.
Создаваемый делегатом из данных для секции ограничитель секции (базовый) - это экземпляр класса базового ограничителя-адаптера для вызова селективного ограничителя, о нем будет рассказано дальше.
А ещё политика с селективным ограничителем реализует свойство OnRejected интерфейса политики IRateLimiterPolicy как автоматическое свойство - оно возвращает делегат для обработки отказа в выдаче разрешения, переданный в конструктор политики через необязательный второй параметр (его значение по умолчанию равно null), запоминаемый в конструкторе в этом свойстве.
Подробности
Класс политики с селективным ограничителем называется SelectiveRateLimiterPolicy. Используемый в нем селективный ограничитель сохраняется в поле _limiter. Делегат в возвращаемых методом секционирования GetPartition данных для секции создается из метода Factory. Этот делегат возвращает вновь созданный экземпляр класса базового ограничителя-адаптера для вызова селективного ограничителя SelectiveRateLimiterAdapter, в конструктор которого передаются селективный ограничитель из поля _limiter и значение ключа контекста запроса (типа IHttpContextBackLinkFeature), извлеченного из контекста запроса, переданного в метод GetPartition.
Базовый ограничитель-адаптер для вызова селективного ограничителя.
Базовый ограничитель-адаптер для вызова селективного ограничителя делает именно то, что написано в заголовке: он наследует классу базового ограничителя RateLimiter, но переадресует все заявки на получение разрешений (и вызовы метода получения статистики - тоже) селективному ограничителю, ссылка на который передается в его конструктор. Для вызова методов этого селективного ограничителя данный адаптер использует контекст запроса, который базовый ограничитель-адаптер получает из ключа контекста, также передаваемого в конструктор этого адаптера. Особенностью базового ограничителя-адаптера является реализация свойства IdleDuration базового класса. Она аналогична реализации этого же свойства в адаптере с управлением временем жизни (класс ManagedLifetimeLimiter) из приложения к предыдущей статье цикла: пока очистка адаптера не выполнена, оно возвращает null, указывая на то, что адаптер активен, а после выполнения очистки экземпляра адаптера оно начинает возвращать максимально возможное значение промежутка времени (TimeSpan.MaxValue). Сделано это для управления временем жизни секции, ограничителем для которой является этот адаптер: периодическая задача, отслеживающая время простоя секции в секционированном ограничителе, выполняющем ограничения по политикам, по превышению этим свойством предельного значения времени простоя (жестко заданного в коде компонента-ограничителя как 10 секунд), удаляет секцию из списка активных секций в этом секционированном ограничителе. В конструкторе адаптера также устанавливается обработчик события очистки ключа контекста запроса, переданного в конструктор. Этот обработчик вызывает очистку объекта адаптера по этому событию, что впоследствии приведет к удалению секции для этого ключа упомянутой выше периодической задачей отслеживания времени простоя секции.
Подробности
Класс базового ограничителя-адаптера для селективного ограничителя называется SelectiveRateLimiterAdapter (его исходный код):
public sealed class SelectiveRateLimiterAdapter: RateLimiter { PartitionedRateLimiter<HttpContext> _limiter; IHttpContextBackLinkFeature? _key; public SelectiveRateLimiterAdapter(PartitionedRateLimiter<HttpContext> limiter, IHttpContextBackLinkFeature key) { _limiter = limiter; _key = key; _key.DisposedEvent+= BacklinkDisposeCallback; } public override TimeSpan? IdleDuration => Volatile.Read(ref _key) is null? TimeSpan.MaxValue : null; public override RateLimiterStatistics? GetStatistics() { IHttpContextBackLinkFeature? key = Volatile.Read(ref _key); if(key == null) throw new ObjectDisposedException(nameof(GetStatistics)); return _limiter.GetStatistics(key.BackLink); } protected override ValueTask<RateLimitLease> AcquireAsyncCore(Int32 permitCount, CancellationToken cancellationToken) { IHttpContextBackLinkFeature? key = Volatile.Read(ref _key); if(key == null) throw new ObjectDisposedException(nameof(AcquireAsyncCore)); return _limiter.AcquireAsync(key.BackLink, permitCount, cancellationToken); } protected override RateLimitLease AttemptAcquireCore(Int32 permitCount) { IHttpContextBackLinkFeature? key = Volatile.Read(ref _key); if(key == null) throw new ObjectDisposedException(nameof(AttemptAcquireCore)); return _limiter.AttemptAcquire(key.BackLink, permitCount); } protected override void Dispose(Boolean disposing) { if(disposing) UnregisterDisposeCallback(); base.Dispose(disposing); } protected override ValueTask DisposeAsyncCore() { UnregisterDisposeCallback(); return base.DisposeAsyncCore(); } void UnregisterDisposeCallback() { IHttpContextBackLinkFeature? key = Interlocked.Exchange(ref _key, null); if(key!=null) key.DisposedEvent-=BacklinkDisposeCallback; } void BacklinkDisposeCallback(Object? sender, EventArgs e) { Dispose(); }
В конструктор базового ограничителя-адаптера для селективного ограничителя передаются два параметра: ссылка на селективный ограничитель, для которого создается адаптер (она запоминается в поле PartitionedRateLimiter<HttpContext> _limiter) и ключ контекста запроса (запоминается в поле IHttpContextBackLinkFeature? _key). Обработчиком события очистки объекта ключа контекста является метод BacklinkDisposeCallback.
Как это работает всё вместе.
Теперь, когда мы рассмотрели все составные части решения, посмотрим что получилось в результате, как это работает всё вместе. Обработка запроса с помощью политики с селективным ограничителем в результате выглядит следующим образом:
компонент-обработчик ключа контекста добавляет в контекст запроса (в его коллекцию функций) ключ контекста запроса, содержащий обратную ссылку на контекст запроса и уникальное для каждого запроса значение;
компонент-обработчик ограничителя вызывает секционированный ограничитель, предназначенный для обработки политик ограничения;
ограничитель для обработки политик, обнаружив в метаданных запроса ссылку на политику ограничения, основанную на селективном ограничителе, (имя политики или саму безымянную политику) находит, если ссылка была именем политики, нужную приготовленную политику и вызывает ее секционирующий метод GetPartition для получения данных для секции;
секционирующий метод приготовленной политики определяет путем объединения имени политики и ключа контекста запроса составной ключ приготовленной политики для этого запроса и формирует данные для секции ограничителя обработки политик; в эти данные включаются значение составного ключа и делегат для создания ограничителя для секции, который при своей работе возвращает базовый ограничитель-адаптер для вызова селективного ограничителя (как результат вызова делегата для создания ограничителя секции из состава исходных данных для секции, созданных секционирующим методом политики);
ограничитель для обработки политик ищет в своем списке секций секцию для этого составного ключа и не находит ее, потому что часть составного ключа - ключ контекста запроса - уникален для каждого запроса;
не найдя в списке секций секцию для обработки текущего запроса, ограничитель обработки политики создает ее; при её создании он косвенным образом вызывает делегат создания ограничителя из состава исходных данных для секции, возвращенных секционирующим методом политики; этим созданным ограничителем будет базовый ограничитель-адаптер для вызова селективного ограничителя, на котором основана политика ограничения;
ограничитель по политикам обращается с заявкой на получение разрешения на обработку запроса к этому ограничителю-адаптеру, который вызывает соответствующий метод подачи заявки селективного ограничителя с дополнительным параметром - контекстом обрабатываемого запроса, извлеченным из сохраненного в адаптере значения ключа контекста запроса;
селективный ограничитель, на котором основана политика, возвращает ответ на заявку в соответствии со своими настройками и хранящимся в нем контекстом выдачи разрешений (количество доступных на текущий момент для выдачи разрешений и т.д.);
компонент-обработчик ограничителя в соответствии с полученным ответом вызывает либо следующий компонент-обработчик конвейера приложения, либо производит обработку отказа в выдаче разрешения;
после завершения следующего компонента-обработчика конвейера либо обработки отказа компонент-обработчик ограничителя завершает свою работу;
получив управление после завершения компонента-обработчика ограничителя, компонент-обработчик ключа контекста удаляет ключ контекста из списка функций контекста запроса; в связи с этим созданная в секционированном ограничителе по политикам при обработке этого запроса секция становится фактически недоступной, так как ее ключ больше не может возникнуть при обработке какого-либо запроса, а потому эту секцию следует удалить из списка, но напрямую это сделать невозможно;
чтобы инициировать удаление ненужной секции компонент-обработчик ключа контекста вызывает очистку (метод Dispose()) объекта ключа контекста этой секции (который он только что удалил из коллекции функций запроса);
очистка ключа контекста вызывает событие очистки, обработчик события очистки в ограничителе-адаптере секции очищает его, в результате этот ограничитель-адаптер секции становится устаревшим: его свойство времени простоя (IdleDuration) теперь возвращает не null (ограничитель активен), а значение, заведомо превышающее таймаут простоя;
запускаемая по таймеру (напоминаю - зашито в коде: раз в 100 миллисекунд) задача обслуживания ограничителя по политикам обнаруживает, что ограничитель секции для ключа контекста завершаемого запроса простаивает больше, чем допустимо (зашито в коде: 10 секунд ), а потому эта секция удаляется из списка секций и становится, вместе со связанными с ней объектами, мусором, подлежащим сборке.
В целом об этом решении: оно работает, но сконструировано оно поперек идеологии обработки политик ограничения в компоненте-обработчике функции ограничения скорости обработки запросов в ASP.NET Core: идеология, воплощенная в ограничителе по политикам компонента-обработчика, явно рассчитана на использование некоторого сравнительно небольшого числа долгоживущих секций, каждая из которых содержит свой ограничитель, поддерживающий свой собственный контекст выдачи разрешений. А при обработке политики ограничения, основанной на произвольном селективном ограничителе, приходится создавать отдельную секцию для каждого обрабатываемого запроса, а после завершения запроса - отправлять ее в мусор вместе с объектом адаптера вызова селективного ограничителя, на котором основывается политика. При этом, контекст выдачи разрешений (то есть, сколько их выдано, сколько ещё можно выдать или какова очередь заявок, когда появятся новые разрешения для выдачи) поддерживается этим самым селективным ограничителем, а адаптер вызова селективного ограничителя, выглядящий снаружи как ограничитель секции, в поддержании этого контекста никак не участвует.
Короче, решение из этого примера, если по терминологии ITSM - типичное “обходное решение”, а по-простому - костыль. Но, к сожалению, чтобы переделать его по уму, чтобы оно, по той же терминологии, стало “структурным решением”, нужно менять всю архитектуру ограничения по политикам. А это не в моих силах.
На этом я завершаю данную, последнюю (на текущий момент), статью цикла, посвященного функции ограничения скорости обработки запросов в ASP.NET Core.