Это - вторая статья цикла про функцию ограничения скорости обработки запросов в ASP.NET Core. Она содержит концептуальное (т.е. раскрывающее состав и взаимодействие частей функции друг с другом) описание классов универсального компонента ограничения скорости .NET. Функция ограничения скорости обработки запросов в ASP.NET Core, которая является предметом рассмотрения всего цикла, базируется именно на этом универсальном компоненте.
Предупреждение: если вам не требуется или не интересно просто для себя (как это интересно мне) разбираться, как устроена и работает функция ограничения скорости обработки запросов в ASP.NET Core, то эта статья, скорее всего, покажется вам длинной и занудной. Потому что в ней рассказывается о весьма специфических подробностях, знание которых совершенно не требуются для того чтобы просто взять и начать использовать в своей программе функцию ограничения скорости обработки запросов ASP.NET Core. Для использования этой функции, скорее всего достаточно будет изучить примеры - или из первой статьи цикла - руководства по использованию, или вообще из документации на сайте Microsoft. В таком случае вам, наверное, читать эту статью не стоит. Но, возможно, и в этом случае вам стоит хотя бы заглянуть в приложения к ней. Там я, в качестве иллюстрации к основному материалу статьи, описал сделанные мной компоненты, позволяющие использовать функцию ограничения скорости нестандартным способом: возможно, вы найдёте применение одному из таких компонентов в своей программе. Компоненты эти оформлены в виде библиотек классов .NET, так что для их использования уже сейчас можно взять их в исходном виде и добавить в свое решение (solution). Причем, при описании каждого компонента я постарался вынести в начало их описания пример его использования - так, чтобы для использования компонента не требовалось читать остальной текст приложения, где написано как он устроен и работает.
Ну, а если вам пришлось разбираться (потому что эта функция не работает так, как вы ожидали) или, как мне, просто захотелось разобраться для себя, как работает функция ограничения скорости обработки запросов в ASP.NET Core - читайте дальше.
О структуре статьи.
Итак, данная статья - это часть цикла, в состав которого входит первая статья - руководство по использованию функции ограничения скорости обработки запросов, в котором даны примеры использования без погружения в детали реализации этой функции, и набор статей, более детально описывающих, как реализованы различные части этой функции.
Состав цикла
Руководство по использованию функции ограничения скорости обработки входящих запросов в ASP.NET Core.
Универсальный компонент ограничения скорости в .NET - эта статья.
Устройство и работа классов базовых ограничителей универсального компонента ограничения.
Компонент-обработчик ограничения скорости обработки запросов в ASP.NET Core.
В основном тексте в тематических разделах дано на уровне концепций описание состава и работы соответствующей части, которой посвящен раздел. Базовые понятия, используемые в этом описании, выделены курсивом. В скрытом тексте с заголовком “Подробности” в этих разделах описаны детали реализации: приведен исходный код классов, названия и назначения полей и методов, обнаруженные интересные приемы и т.п. Этот текст можно пропустить без ущерба для общего понимания. Читать его стоит, только если вам интересны эти детали. Для общего понимания, как работает ограничение скорости обработки запросов, этот скрытый текст читать не обязательно.
В скрытом тексте с заголовком “Сводка” ниже содержится краткое изложение материала из тематических разделов. Эту сводку можно использовать для первичного знакомства со статьей, а при необходимости можно ознакомиться с дополнительными подробностями в соответствующем разделе.
Сводка
Ограничение скорости обработки запросов в ASP.NET Core использует возможности универсального компонента ограничения скорости .NET. Этот компонент доступен в самых разных типах приложений.
Основой универсального компонента ограничения является набор классов ограничителей. Классы ограничителей имеют методы (синхронный - AttemptAcquire и асинхронный - AcquireAsync) для приема заявок на получение разрешений на дальнейшую обработку. Эти методы возвращают объекты ответа класса RateLimitLease (или его потомков). Объект ответа содержит признак, выданы ли разрешения, и, возможно, дополнительную информацию в виде набора метаданных: словаря с ключами-строками и произвольными объектами в качестве значений. После завершения обработки объект ответа следует очистить (вызвать его метод Dispose).
Классы ограничителей относятся к одной из двух разновидностей. Классы, относящиеся к первой разновидности - базовые ограничители - выдают разрешения единообразно для всех подаваемых им заявок. Обычно они используют какой-либо алгоритм выдачи разрешений, который применяется ко всем заявкам. Вторая разновидность - это классы селективных ограничителей, которые делят подаваемые им заявки на группы в соответствии со значением (далее оно называется ресурсом), которое передается в их методы подачи заявок в качестве дополнительного параметра, и для каждой такой группы решения о выдаче разрешений принимаются независимо, и, возможно - согласно разным алгоритмам.
Классы ограничителей образуют несколько иерархий наследования, в основе каждой из которых лежит первичный класс. В данной статье рассмотрены первичный классы базового ограничителя - RateLimiter, базового ограничителя скорости (наследник предыдущего класса) - ReplenishingRateLimiter и селективного ограничителя - PartitionedRateLimiter (является основой иерархии, независимой от иерархии базового ограничителя).
Далее в статье подробно разобраны разновидности селективных ограничителей: секционированный и сцепленный селективные ограничители. Классы этих ограничителей являются внутренними для компонента и публично не доступны, и экземпляры этих классов создаются статическими методами, определенными в компоненте.
Секционированный селективный ограничитель выглядит как набор секций, где каждая секция содержит ключ секционирования, вычисляемый из значения ресурса, и использует независимый от других секций экземпляр базового ограничителя. При запросе разрешения для конкретного значения ресурса для этого значения вычисляется значение ключа секционирования, по этому ключу выбирается секция, и запрашивается разрешение у экземпляра базового ограничителя, назначенного этой секции. Экземпляр секционированного селективного ограничителя создается методом PartitionedRateLimiter.Create.
Секционированный селективный ограничитель строится вокруг секционирующего делегата. Секционирующий делегат передается как аргумент в метод Create, создающий экземпляр секционированного ограничителя. Секционирующий делегат принимает как параметр значение ресурса и возвращает для него данные для секции. В состав данных для секции входят ключ секционирования и делегат, создающий базовый ограничитель для секции на основе значения ключа секционирования.
Для сцепленного селективного ограничителя методы запроса разрешения его класса возвращают ответ с разрешением только если он смог получить разрешения от всех селективных ограничителей, входящих в его состав. Сцепленный селективный ограничитель создается методом PartitionedRateLimiter.CreateChained. При создании экземпляра этого ограничителя в этот метод передается (через список параметров переменной длинны) набор ограничителей, которые будут включены в состав этого сцепленного ограничителя.
При подаче заявки на некоторое число разрешений сцепленный селективный ограничитель пытается по очереди получить это число разрешений от всех входящий в его состав ограничителей, подавая заявку методом того же типа - синхронным или асинхронным. Если хотя бы один из входящих в состав ограничителей не сможет выделить разрешение, то сцепленный ограничитель возвращает полученный от него ответ на заявку с отказом в разрешении. Если же все ограничители смогли выделить запрошенное количество разрешений, то сцепленный ограничитель возвращает комбинированный ответ на заявку с полученным разрешением.
Для упрощения создания в универсальном компоненте ограничения секционированных ограничителей на основе базовых ограничителей, реализующих определенные в нем алгоритмы - алгоритмических ограничителей, - в статическом классе RateLimiterPartition имеется набор статических методов, упрощающих создание таких секционирующих ограничителей.
Эти вспомогательные методы при их вызове создают данные для секции на основе передаваемого им через первый параметр значения ключа секционирования. Их можно вызывать изнутри секционирующего делегата, передаваемого в метод PartitionedRateLimiter.Create - достаточно добавить в него код для получения значения ключа из значения ресурса. Список вспомогательных методов для использования алгоритмических ограничителей из универсального компонента ограничения следующий:
GetFixedWindowLimiter для использования алгоритма с фиксированным окном (тип создаваемого ограничителя - FixedWindowRateLimiter);
GetSlidingWindowLimiter для использования алгоритма со скользящим окном (тип создаваемого ограничителя - SlidingWindowRateLimiter);
GetTokenBucketLimiter для использования алгоритма емкости для жетонов (тип создаваемого ограничителя - TokenBucketRateLimiter);
GetConcurrencyLimiter для использования алгоритма ограничения параллелизма (тип создаваемого ограничителя - ConcurrencyLimiter).
В качестве второго параметра в указанные вспомогательные методы передается делегат, получающий ключ секционирования и возвращающий для этого значения ключа экземпляр параметров настройки для создаваемого алгоритмического ограничителя секции, класс этого экземпляра параметров соответствует алгоритму, реализуемому ограничителем секции. Важно отметить, что если вспомогательный метод предназначен для создания ограничителя скорости, то в параметрах настройки, возвращаемых делегатом не следует указывать режим автоматического пополнения (AutoReplenishment не должно быть true).
Поскольку объем материала по универсальному компоненту ограничения получился слишком большим для одной статьи, рассмотрение реализованных в данном компоненте конкретных классов базовых ограничителей выделено в отдельную статью цикла.
В разделе с заголовком “Про код” я изложил (в одноименном скрытом тексте, чтобы уменьшить объём этой и без того объёмной статьи) свои мысли о коде, реализующем описанные в статье классы. Кому вдруг интересно это мое мнение, могут с ним ознакомиться, а остальные вполне могут безболезненно пропустить этот текст.
В приложениях к статьям этого цикла, включая и эту, которую вы читаете, приведены примеры, как можно использовать сведения из этих статей для реализации нестандартного использования тех частей функции ограничения скорости, которые описаны в этих статьях, и рассмотрены сделанные на этой базе компоненты, которые можно использовать в своих программах. Код примеров опубликован в репозитории на github под лицензией, допускающей его свободное использование.
Содержание
Об универсальном компоненте ограничения в целом.
Ограничение скорости обработки запросов в ASP.NET Core использует возможности универсального компонента ограничения скорости .NET. Этот компонент доступен в самых разных типах приложений. В частности, в документации Microsoft приведен (и потом многократно пересказан в интернете) пример использования этого компонента для ограничения скорости подачи запросов через HttpClient.
Для понимания того, как работает функция ограничения скорости обработки запросов в ASP.NET Core(которое есть тема всего цикла), необходимо понимать, как функционирует этот универсальный компонент. К сожалению, документация Microsoft крайне скупа в описании его функций, а исходный текст компонента, хотя и доступен, не позволяет легко и просто восстановить концептуальное описание его работы. По этой причине я счел необходимым в своей статье описать работу этого компонента - далее в статье я буду называть его просто универсальный компонент ограничения.
Этот компонент содержит набор классов, определенных в пространстве имен System.Threading.RateLimiting. Для использования в приложениях .NET он доступен в виде пакета NuGet с тем же именем System.Threading.RateLimiting. В состав SDK для написания приложений ASP.NET Core этот пакет уже входит, так что для таких приложений дополнительно добавлять в проект этот пакет не требуется.
Общее описание работы компонента.
Процесс использования универсального компонента ограничения может быть описан следующим образом. Пользователь компонента - внешний по отношению к компоненту модуль - подает заявку на получение разрешения, Далее в статье я буду называть ее просто заявка. Заявка подается через метод экземпляра класса из состава компонента, который отвечает за выдачу этих разрешений. Далее в статье я буду называть этот экземпляр ограничитель. Какой именно ограничитель отвечает за выдачу разрешений, определяется пользователем компонента. Вызванный метод ограничителя определяет, можно ли выдать разрешение и возвращает ответ на заявку в виде объекта, который содержит признак, выдано ли разрешение, и, возможно, дополнительную информацию. Далее в статье я буду называть этот объект ответ на заявку.
Если для подачи заявки используется асинхронный метод ограничителя, то ограничитель при отсутствии возможности выдать разрешение немедленно, может поместить заявку в очередь, чтобы выдать разрешение потом, когда это станет возможно.
После окончания использования объекта ответа на заявку пользователь компонента должен очистить объект ответа на заявку, используя его метод Dispose. Вызов этого метода сообщит ограничителю, что он может использовать ресурсы, которые были связаны с этим ответом, если такие ресурсы отслеживаются ограничителем.
Объект ответа на заявку.
Ответ на заявку прежде всего, содержит признак получено ли разрешение или же заявка отклонена, а также, возможно - дополнительную информацию об ответе в виде метаданных. Первичный класс для объектов ответа на заявку - абстрактный класс RateLimitLease. Ответы на заявки, выдаваемые конкретным ограничителем, являются экземплярами классов, унаследованных от этого класса.
В первичном классе ответа на заявку определены свойства и методы, общие для всех ответов на заявки. Во-первых, это - свойство IsAcquired. Оно имеет значение true если разрешение было получено, false - если заявка была отклонена. Во-вторых, это - метаданные ответа, которые представляют собой набор объектов, каждый из которых сопоставлен строке - имени метаданных. Для доступа к метаданным в классе RateLimitLease определены:
свойство MetadataNames - содержит последовательность имен всех содержащихся в объекте ответа на заявку метаданных;
метод TryGetMetadata пытается получить метаданные с указанным в аргументе именем, метаданные в конкретном экземпляре могут отсутствовать, даже если их имя содержится в свойстве MetadataNames этого экземпляра;
метод GetAllMetadata() возвращает последовательность пар из имени и значения для всех содержащихся в объекте ответа на заявку метаданных.
Первичный класс ответа на заявку реализует интерфейс очистки (IDisposable). Как уже указано выше, получатель ответа на заявку по завершении обработки должен вызвать метод Dispose этого интерфейса, чтобы освободить возможные связанные с объектом ответа ресурсы ограничителя.
В классе RateLimitLease определен также обобщенный вариант TryGetMetadata, который служит для извлечения типизированных метаданных, с проверкой/приведением их типа. В этот метод в качестве имени передается не строка, а значение типизированного имени метаданных типа MetadataName<T>, где T - это ожидаемый тип метаданных. Также у этого типа есть свойство Name, которое возвращает строку - имя метаданных. В статическом (необобщенном) классе MetadataName определена пара статических значений этого типа для наиболее важных метаданных универсального компонента ограничения: MetadataName<TimeSpan> RetryAfter - рекомендуемый промежуток ожидания перед повторной попыткой подачи и MetadataName<string> ReasonPhrase - описание причины отклонения заявки. Значение типа MetadataName<T> также можно можно создать обобщенным методом MetadataName.Create<T>, указав при вызове тип метаданных как параметр-тип и имя метаданных как параметр метода.
Подробности
сигнатура(ссылка на исходный код):
public abstract class RateLimitLease : IDisposable { public abstract bool IsAcquired { get; } public abstract bool TryGetMetadata(string metadataName, out object? metadata); // Извлечение метаданных с указанным именем с проверкой/приведением их типа: public bool TryGetMetadata<T>(MetadataName<T> metadataName, [MaybeNull] out T metadata); public abstract IEnumerable<string> MetadataNames { get; } public virtual IEnumerable<KeyValuePair<string, object?>> GetAllMetadata(); //Очистка (освобождение) полученного объекта выделения разрешения. public void Dispose(); protected virtual void Dispose(bool disposing) { } }
Детали реализации. Большинство методов первичного абстрактного класса ответа на заявку являются абстрактными. Они реализованы в классах-потомках, которые специфичны для классов ограничителей и будут рассмотрены в описаниях соответствующих классов ограничителей.
Первичный класс ответа заявки содержит реализацию метода GetAllMetadata() по умолчанию. Эта реализация получает последовательность имен метаданных из свойства MetadataNames, перечисляет ее, запрашивая с помощью TryGetMetadata() значение для каждого из имен и формирует перечисление из пар “имя-значение”. Производные классы могут заменять эту реализацию на свою, например, для повышения производительности, если они уже хранят метаданные в виде пар “имя-значение”.
Методы с именами Dispose в первичном классе ответа на заявку реализуют типовой для иерархий классов .NETшаблон очистки
Ограничители: базовые и селективные.
Универсальный компонент ограничения содержит набор классов, экземпляры которых занимаются тем, что выдают (или не выдают) разрешения в ответ на поданные им заявки. Логично называть такие классы классы ограничителей, а их экземпляры (а иногда и сами классы) - ограничители.
Классы ограничителей делятся на две разновидности. Первая разновидность - это классы, выдают разрешения единообразно для всех подаваемых им заявок. Обычно они используют какой-либо алгоритм выдачи разрешений, который применяется ко всем заявкам. Вторая разновидность - это классы, которые делят подаваемые им заявки на группы в соответствии со значением, которое передается в их методы подачи заявок в качестве дополнительного параметра. Для разных групп, в которые заявки попадают на основе этого значения, решения о выдаче разрешений принимаются независимо, и, возможно - согласно разным алгоритмам.
В этой и последующих статьях для каждого из классов первой группы, то есть выдающих разрешения единообразно, по одному алгоритму, будет использоваться термин класс базового ограничителя. Соответственно, объекты - экземпляры этих классов будут называться базовыми ограничителями. Классы второй группы, которые могут независимо ограничивать запросы в зависимости от переданного в них дополнительного значения, будут называться классы селективных ограничителей, а их экземпляры (а иногда и сами классы) - селективные ограничители.
Абстрактный первичный класс базового ограничителя.
Все классы базовых ограничителей являются наследниками абстрактного первичного класса RateLimiter. Этот класс предоставляет пользователям ограничителя следующие методы и свойства.
Во-первых, это методы подачи заявки. Первый параметр этих методов (permitCount) указывает, сколько именно единиц разрешения (по умолчанию: 1) указано в заявке. Как именно понимать единицы разрешения - это зависит от приложения-пользователя. Универсальный компонент ограничения определяет только использование значения 0 - оно используется для проверки возможности получения разрешения для хотя бы одной единицу разрешения, и при этом даже при успешной проверке разрешение все равно фактически не выдается. Такая заявка далее называется пробная заявка. В обсуждаемом в этом цикле статей использовании универсального компонента для ограничения скорости обработки запросов в ASP.NET Core, единицей разрешения является обрабатываемый запрос.
Метод AttemptAcquire запрашивает выдачу разрешения немедленно (т.е. синхронно). Если разрешение в текущий момент выдано быть не может, этот метод немедленно возвращает отклоненный ответ на заявку. Метод AcquireAsync, в отличие от предыдущего метода, может поместить в очередь заявку, которую он не может удовлетворить сейчас (если в очереди есть место), а код, вызывающий этот метод - перейти в асинхронное ожидание, а потом, когда наступит возможность - получить ответ на заявку с полученным (или отклоненным) разрешением. Если же места в очереди нет, то немедленно возвращается отклоненный ответ на заявку. У этого метода есть второй параметр - маркер отмены, с помощью которого можно прекратить ожидание в очереди.
Метод GetStatistics возвращает статистику работы экземпляра ограничителя: текущие значения числа доступных разрешений и суммарного количества запрошенных разрешений в заявках, находящихся в очереди, для экземпляра, а также - полное за все время существования экземпляра число выданных разрешений и разрешений, в выдаче которых было отказано.
Свойство IdleDuration содержит либо null, если данный экземпляр используется сейчас (то есть, есть актуальные выданные им разрешения), либо величину промежутка времени после его последнего использования. Это свойство используется кодом секционированного ограничителя для своевременной очистки неиспользуемых секций (см. далее).
Первичный класс базового ограничителя реализует интерфейсы очистки, как синхронной (IDisposable), так и асинхронной (IAsyncDisposable).
Подробности
сигнатура(ссылка на исходный код) класса первичного базового ограничителя:
public abstract class RateLimiter : IAsyncDisposable, IDisposable { public abstract RateLimiterStatistics? GetStatistics(); public abstract TimeSpan? IdleDuration { get; } public RateLimitLease AttemptAcquire(int permitCount = 1); protected abstract RateLimitLease AttemptAcquireCore(int permitCount); public ValueTask<RateLimitLease> AcquireAsync(int permitCount = 1, CancellationToken cancellationToken = default); protected abstract ValueTask<RateLimitLease> AcquireAsyncCore(int permitCount, CancellationToken cancellationToken); public void Dispose(); protected virtual void Dispose(bool disposing) { } public async ValueTask DisposeAsync() protected virtual ValueTask DisposeAsyncCore() { return default; } }
Для любителей теории этот класс мог бы послужить, на первый взгляд, хорошей иллюстрацией приема проектирования “шаблонный метод” (“template method pattern”). В нем этих шаблонных методов - целых четыре. Два из них - методы подачи заявки AttemptAcquire и AcquireAsync: каждый из них проверяет входной параметр, что он больше или равен 0, а затем вызывает реализацию - защищенный виртуальный (абстрактный) метод со своим именем, к которому дописан суффикс Core и теми же параметрами. Другие два шаблонных метода - это методы реализации очистки в иерархии классов - синхронной и асинхронной - в соответствии с рекомендациями Microsoft: там тоже используются защищенные виртуальные методы - Dispose(Bool) и DisposeAsyncCore(), соответственно.
Но если ознакомиться с реализацией классов этой иерархии на практике, то пример перестает быть хорошим: значительная часть кода, реализующего эти методы в существующем коде компонента, получена путем тупого (или, иногда, хитрого) дублирования в оконечных классах иерархии, вместо того, чтобы вынести общую часть реализации выше по иерархии, а различия между реализациями для оконечных классов выполнить как раз, используя этот самый приемом “шаблонный метод”. Но о том, как эти классы реализованы, читайте в следующей статье.
сигнатура(ссылка на исходный код класса моментального снимка статистики на момент вызова метода GetStatistics (с моими комментариями):
public class RateLimiterStatistics { public long CurrentAvailablePermits { get; init; } //Текущее число доступных для выдачи разрешений public long CurrentQueuedCount { get; init; } //Текущее число разрешений, запрашиваемых в заявках из очереди (размер очереди) public long TotalFailedLeases { get; init; } //Суммарное число выданных ограничителем разрешений public long TotalSuccessfulLeases { get; init; } //Суммарное число разрешений в заявках, отклоненных ограничителем }
Первичный класс базового ограничителя скорости
Алгоритмы ограничения, реализованные в классах базовых ограничителей компонента ограничения, делятся на две большие группы - алгоритмы ограничения скорости (реализуемых базовыми ограничителями скорости) и прочие. Работа базовых ограничителей скорости основана на том, что в них новые разрешения, которые они могут выдавать по заявкам, добавляются периодически. Количество и периодичность добавления новых разрешений указывается через параметры настройки при создании экземпляра базового ограничителя скорости. Выданные этими классами разрешения рассматриваются как одноразовые: при очистке объекта ответа на заявку эти разрешения не возвращаются и повторно не выдаются.
Из определенных в универсальном компоненте алгоритмов ограничения к алгоритмам ограничения скорости относятся алгоритмы фиксированного окна, скользящего окна и с пополняемой емкостью для жетонов-разрешений. Все эти алгоритмы ограничения скорости ограничивают на долгом периоде скорость выдачи разрешений указанной через параметры настройки скоростью поступления новых разрешений. А отличия этих алгоритмов состоит в том, как они используют ранее полученные, но не выданные разрешения. Эти отличия будут рассмотрены в описаниях соответствующих конкретных базовых ограничителей в следующей статье.
Все классы базовых ограничителей, реализующие алгоритмы ограничения скорости, наследуются от абстрактного класса ReplenishingRateLimiter. В этом классе определены два режима работы ограничителя скорости - режим автоматического пополнения и режим ручного пополнения. Определить режим работы ограничителя скорости можно по значению определенного в ReplenishingRateLimiter свойства IsAutoReplenishing. В автоматическом режиме пополнение осуществляется фоновым процессом самого ограничителя, а в режиме ручного пополнения для правильной работы ограничителя использующий его внешний процесс должен вызывать определенный в ReplenishingRateLimiter метод TryReplenish, желательно - с периодичностью, указанной в определенном в ReplenishingRateLimiter свойстве ReplenishmentPeriod.
Подробности
Исходный код базового ограничителя скорости:
public abstract class ReplenishingRateLimiter : RateLimiter { public abstract TimeSpan ReplenishmentPeriod { get; } public abstract bool IsAutoReplenishing { get; } public abstract bool TryReplenish(); }
О подробностях реализации класса ReplenishingRateLimiter сказать нечего, так как этот класс является целиком абстрактным и не содержит исполняемого кода.
К числу прочих (то есть, не входящих в группу алгоритмов ограничения скорости) алгоритмов, реализованных в классах базовых ограничителей, определенных в универсальном компоненте ограничения, относится единственный алгоритм - алгоритм ограничения использования, который, из-за его обычного способа использования, чаще называют алгоритмом ограничения параллелизма.
Первичный класс селективного ограничителя.
Селективные ограничители могут по-разному ограничивать выдачу разрешений для заявок, связанных с разными объектами. Первичным классом для селективных ограничителей является класс PartitionedRateLimiter. В нем определены свойства и методы, через которые селективные ограничители взаимодействуют с использующим их кодом. Методы первичного класса селективных ограничителей PartitionedRateLimiter во многом подобны методам первичного класса базовых ограничителей RateLimiter.
В методы подачи заявок точно так же передается количество запрашиваемых разрешений (по умолчанию - 1), а для асинхронного метода - ещё и маркер отмены. Но в этих методах есть одно важное отличие: в методы подачи заявок селективных ограничителей - AttemptAcquire и AcquireAsync - первым параметром передается значение, с которым связана заявка. Класс PartitionedRateLimiter является обобщенным, с одним параметром-типом. Этот параметр-тип является типом значения первого параметра методов подачи заявок. Это значение я далее в статье буду называть ресурс, а его тип (параметр-тип класса PartitionedRateLimiter) - тип ресурса. Выбор именно такого названия обусловлен тем, что в коде .NET для этого типа (а он широко используется как параметр-тип в обобщенных классах и методах универсального компонента ограничения скорости) используется имя TResource. Для функции ограничения запросов в ASP.NET Core ресурсом (по значению которого производится селекция) всегда является контекст HTTP-запроса, соответственно, тип ресурса всегда будет HttpContext. Но в текущей статье цикла, посвященной исключительно универсальному компоненту ограничения скорости, предназначенному для самых разных областей применения, я буду использовать универсальные термины “ресурс” и “тип ресурса”.
В метод GetStatistics селективного ограничителя тоже передается значение ресурса, так как статистика для разных групп ресурсов, ограничиваемых по-разному, ведется отдельно. Первичный класс селективного ограничителя, подобно базовому ограничителю, так же реализует интерфейсы очистки, как синхронной (IDisposable), так и асинхронной (IAsyncDisposable). А вот свойства IdleDuration у селективного ограничителя, в отличие от базового, нет.
Подробности
Сигнатура первичного класса селективного ограничителя (ссылка на исходный код) с моими комментариями:
public abstract class PartitionedRateLimiter<TResource> : IAsyncDisposable, IDisposable { public abstract RateLimiterStatistics? GetStatistics(TResource resource); public RateLimitLease AttemptAcquire(TResource resource, int permitCount = 1); protected abstract RateLimitLease AttemptAcquireCore(TResource resource, int permitCount); public ValueTask<RateLimitLease> AcquireAsync(TResource resource, int permitCount = 1, CancellationToken cancellationToken = default); protected abstract ValueTask<RateLimitLease> AcquireAsyncCore(TResource resource, int permitCount, CancellationToken cancellationToken); protected virtual void Dispose(bool disposing) { } //Фактическая очистка (в первичном классе отсутствует). public void Dispose(); protected virtual ValueTask DisposeAsyncCore(); //Фактическая асинхронная очистка. public async ValueTask DisposeAsync(); // Метод для создания селективного адаптера-ограничителя для нового типа ресурса TOuter на основе этого исходного ограничителя (для типа ресурса TResource) // и функции преобразования нового типа ресурса в в тип ресурса исходного ограничителя (TResource); // параметр leaveOpen=true( указывает, что созданный адаптер-ограничитель не должен производить очистку исходного ограничителя на базе ресурса. public PartitionedRateLimiter<TOuter> WithTranslatedKey<TOuter>(Func<TOuter, TResource> keyAdapter, bool leaveOpen); }
Имя первичного класса селективных ограничителей несколько сбивает с толку. Оно больше подходит для одной из конкретных реализаций - секционированного ограничителя (рассмотрен ниже), где действительно значения ресурсов разбиваются на разделы, они же секции - на группы с одинаковым ключом секционирования. А вот для другой реализации - сцепленного ограничителя - никаких явных разделов не просматривается. Похоже, такое имя возникло по неведомым нам историческим причинам.
Первичный класс селективного ограничителя, подобно первичному классу базового ограничителя, точно так же содержит те же четыре пары “публичный метод”-“защищенный шаблонный метод - реализация” . Две из них - методы подачи заявки AttemptAcquire и AcquireAsync с реализациями - защищенными с теми же именами, к которым дописан суффикс Core (и теми же параметрами), две других - методы реализации очистки в иерархии классов - синхронной и асинхронной - в соответствии с рекомендациями Microsoft.
Классы, реализующие селективные ограничители.
Селективные ограничители реализуются одним из следующих классов:
DefaultPartitionedRateLimiter<TResource, TKey>- секционированный ограничитель. Экземпляр этого класса обращается за получением разрешений к одному из нескольких независимых базовых ограничителей - ограничителей секций. Значение ресурса определяет, к какой именно из секций будет отнесен запрос, и, следовательно, от какого экземпляра базового ограничителя требуется получать разрешение. Этот селективный ограничитель рассмотрен в следующем разделе. Параметр-тип TKey указывает на тип ключа секционирования, также см. следующий раздел.ChainedPartitionedRateLimiter<TResource>- сцепленный селективный ограничитель. Для получения разрешения этот класс требует одновременного получить разрешения от всех селективных ограничителей, входящих в его состав. Этот селективный ограничитель более подробно рассмотрен ниже в соответствующем разделе.TranslatingLimiter<TInner, TResource>- адаптер для существующего селективного ограничителя, позволяющий использовать его для другого типа ресурса. Так как в компоненте ограничения запросов ASP.NET Core используется только один тип ресурса - HttpContext, - а потому для этого компонента этот адаптер не актуален, то в этой статье он рассматриваться не будет.
Все эти классы являются потомками первичного класса селективного ограничителя. Все они имеют параметр-тип TResource - тип ресурса. И все они - внутренние: пользователи фреймворка не могут создавать их экземпляры напрямую. Создаются эти классы только через публично доступные методы классов, входящих в пакет ограничения запросов для .NET. Экземпляры первых двух классов создаются через методы, соответственно, Create и CreateChained необобщенного статического класса PartitionedRateLimiter (не путать с одноименным обобщенным первичным классом селективного ограничителя). Экземпляры класса адаптера, приспосабливающего существующий селективный ограничитель для другого типа ресурса, создаются методом WithTranslatedKey экземпляра существующего селективного ограничителя для исходного типа ресурса.
Секционированный селективный ограничитель.
Логически такой ограничитель выглядит как набор секций, где каждая секция имеет ключ секционирования, вычисляемый из значения ресурса, и использует независимый от других секций экземпляр базового ограничителя. При запросе разрешения к конкретному ресурсу вычисляется значение ключа секционирование для него, по этому ключу выбирается секция и запрашивается разрешение у экземпляра базового ограничителя, назначенного этой секции.
Объект такого ограничителя создается методом Create<TResource,TKey>(Func<TResource, RateLimitPartition<TKey>> partitioner, IEqualityComparer<TKey>? equalityComparer = null). Параметры-типы метода Create имеют следующий смысл. TResource обозначает тип ресурса (см. выше в разделе “Первичный класс селективного ограничителя”), TKey - тип ключа секционирования, то есть - значения, по которому производится разделение на секции: каждому значению ключа соответствует своя секция ограничителя. В указанный метод передаются два параметра (второй из них - необязательный): partitioner - секционирующий делегат и equalityComparer - компаратор для сравнения значений ключа на равенство (он передается, только если нужен нестандартный для типа ключа компаратор). Секционирующий делегат имеет тип Func<TResource, RateLimitPartition<TKey>>, то есть он принимает в качестве аргумента значение ресурса и возвращает данные для секции, соответствующие этому значению ресурса. Данные для секции - это экземпляр обобщенной структуры RateLimitPartition<TKey>>, который содержит значение ключа секционирования, и делегат создания ограничителя секции, который может создать экземпляр ограничителя для секции (этот ограничитель - базовый).
Для определенных в универсальном компоненте ограничения алгоритмов в состав компонента входят вспомогательные методы, упрощающие создание создающие секционирующих делегатов, использующих эти алгоритмы для ограничителей секций. Об этих вспомогательных методах будет рассказано ближе к концу этой статьи.
Класс секционированного ограничителя DefaultPartitionedRateLimiter - обобщенный, он имеет те же два параметра-типа, что и метод PartitionedRateLimiter.Create, который его создает - TResource(тип ресурса) и TKey(тип ключа секционирования).
Подробности
Сигнатура - доступные снаружи методы/свойства - и важные внутренние поля(ссылка на исходный код):
internal sealed class DefaultPartitionedRateLimiter<TResource, TKey> : PartitionedRateLimiter<TResource> where TKey : notnull { private readonly Dictionary<TKey, Lazy<RateLimiter>> _limiters; private readonly Func<TResource, RateLimitPartition<TKey>> _partitioner; public DefaultPartitionedRateLimiter(Func<TResource, RateLimitPartition<TKey>> partitioner, IEqualityComparer<TKey>? equalityComparer = null); public override RateLimiterStatistics? GetStatistics(TResource resource); protected override RateLimitLease AttemptAcquireCore(TResource resource, int permitCount); protected override ValueTask<RateLimitLease> AcquireAsyncCore(TResource resource, int permitCount, CancellationToken cancellationToken); private RateLimiter GetRateLimiter(TResource resource); protected override void Dispose(bool disposing); protected override async ValueTask DisposeAsyncCore(); }
Данные для секции.
Эти данные, представляющие собой экземпляр структуры, возвращает, в частности, секционирующий делегат. Но используются они не только в этом делегате, а довольно широко во всем компоненте ограничения скорости. Эта структура - обобщенная, ее единственных параметр-тип - это тип ключа секционирования. Данные для секции содержат значение ключа секционирования для секции и делегат создания ограничителя секции, возвращающий объект базового ограничителя для переданного ему значения ключа секционирования. Обычно для каждой секции создается отдельный, новый объект ограничителя секции.
Подробности
public struct RateLimitPartition<TKey> { public RateLimitPartition(TKey partitionKey, Func<TKey, RateLimiter> factory) public TKey PartitionKey { get; } public Func<TKey, RateLimiter> Factory { get; } }
Эта структура - обобщенная, ее единственных параметр-тип TKey - это тип ключа секционирования. Эта структура содержит значение ключа секционирования (свойство Key) и делегат, создающий объект базового ограничителя для переданного ему значения ключа (свойство Factory).
Как устроен и работает секционированный ограничитель.
Если описывать вкратце, то в методах подачи заявки и получения статистики класса секционированного ограничителя сначала вызывается переданный в его конструктор секционирующий делегат, который возвращает данные для секции. По ключу секционирования из возвращенных данных во внутреннем словаре секций ищется секция для этого ключа. Если секция не найдена, то создается новая секция с указанным ключом и значением - ссылкой с отложенной инициализацией на ограничитель секции, причем делегат инициализации этой ссылки вызывает делегат создания ограничителя секции из данных для секции. После этого метод подачи заявки или получения статистики секционирующего ограничителя читает ссылку на ограничитель секции (при этом для вновь созданной секции происходит инициализация этой ссылки с созданием ограничителя), обращается к соответствующему методу ограничителя секции и возвращает полученный от него ответ на заявку или запись статистики.
Подробности
В конструктор класса секционированного ограничителя передаются те же параметры, что и методу класса PartitionedRateLimiter.Create, который его создает: один обязательный - секционирующий делегат, он сохраняется во внутреннем поле _partitioner, и один необязательный - equalityComparer, компаратор для сравнения значений ключа на равенство и для получения хэш-кода ключа, он реализует обобщенный интерфейс IEqualityComparer для типа ключа TKey. Внутреннее поле _limiters экземпляра является словарем секций. Для каждого имеющегося в нем ключа секции (тип ключа - TKey) в этом словаре хранится ссылка на значение экземпляра базового ограничителя секции с отложенной инициализацией (то есть тип значения в словаре - Lazy<RateLimiter>) с помощью некоего инициализирующего делегата. Как создается этот инициализирующий делегат и почему здесь вообще используется отложенная инициализация вместо прямой ссылки на экземпляр базового ограничителя, я напишу ниже, в описании метода GetRateLimiter. Ключ секции вместе со значением ссылки с отложенной инициализацией на ограничитель секции логически составляют запись секции. Если в конструктор секционированного ограничителя передан компаратор, то этот компаратор передается в конструктор словаря. Изначально этот словарь пуст.
Реализации методов получения разрешений и статистики AttemptAcquireCore/AcquireAsyncCore/GetStatistics прежде всего получают ссылку на ограничитель для нужной секции из словаря с помощью внутреннего метода GetRateLimiter, которому передается как аргумент значение ресурса из первого параметра типа TResource.
Метод GetRateLimiter сначала вызывает сохраненный в поле _partitioner секционирующий делегат, переданный ранее в конструктор, с аргументом - переданным в этот метод значением ресурса. Секционирующий делегат возвращает данные для секции, соответствующие этому значению ресурса - структуру типа RateLimitPartition<TKey>, состоящую из двух полей: Key - значения ключа секционирования для переданного в делегат значения ресурса и Factory - делегат создания ограничителя секции. Затем метод GetRateLimiter ищет ссылку на ограничитель секции в словаре _limiters по полученному от секционирующего делегата ключу секционирования. Если этого ключа ещё нет в словаре, то в словарь добавляется запись с этим ключом и значением - ещё не инициализированной ссылкой с отложенной инициализацией на экземпляр ограничителя секции. При этом в качестве инициализатора для этой ссылки используется лямбда-выражение, которое вызывает тот самый делегат из поля Factory данных для секции, возвращенных предыдущим вызовом секционирующего делегата. Этот делегат в инициализаторе вызывается с аргументом - значением ключа секции. Инициализатор возвращает созданный делегатом из поля Factory экземпляр ограничителя секции.
Затем метод GetRateLimiter читает значение ссылки на ограничитель секции для обрабатываемой им секции - либо вновь созданной, либо ранее существовавшей. При этом, если секция - вновь созданная, то при чтении происходит отложенная инициализация ссылки на ограничитель секции. Если же секция уже существовала, метод GetRateLimiter просто читает уже инициализированную ссылку на существующий экземпляр ограничителя секции, и делегат из поля Factory при этом не вызывается. В любом случае метод GetRateLimiter возвращает (возможно, после завершения отложенной инициализации) базовый ограничитель секции, соответствующей ключу, полученному из вызова _partitioner для переданного в GetRateLimiter значения ресурса.
Смысл такой навороченной реализации метода GetRateLimiter - в том, чтобы для получения ключа секции и экземпляра ограничителя секции было достаточно вызвать делегат _partitioner однократно, вне зависимости от того, создана ли уже запись секции или нет (что на момент вызова _partitioner неизвестно), не создавая при этом в куче лишних объектов. Стоит ли такое усложнение кода экономии на лишнем вызове метода - вопрос философский, ибо всё уже сделано так, как оно сделано. Но, в любом случае, такой прием - возврат из метода делегата-фабрики, создающего объект, используя неизвестное в момент вызова метода значение параметра вместо возврата фактически созданного объекта - он сам по себе небезынтересный.
В конструкторе класса секционированного ограничителя также создается и запускается периодически возобновляемая по таймеру фоновая задача, работающая все то время, пока секционированный ограничитель существует и не очищен. Интервал запуска таймера в настоящее время (в .NET 9) жестко прописан в коде и составляет 100 миллисекунд. Эта задача при возобновлении обрабатывает все существующие записи секций. При проверке каждая запись секции проверятся на то, что она не устарела: что свойство IdleDuration ограничителя секции имеет не пустое значение и не превышает таймаута устаревания. Таймаут устаревания также жестко прописан в коде и составляет 10 секунд. Если запись секции устарела, то она - ключ вместе со значением - ссылкой на ограничитель секции - удаляется из словаря, а ограничитель, на который эта ссылка ссылается, очищается. Если же запись не устарела, то для нее проверяется, является ли ограничитель секции ограничителем скорости(т.е. - является ли он потомком класса ReplenishingRateLimiter), и если да - для него производится пополнение разрешений вызовом метода ReplenishingRateLimiter.TryReplenish (который, если для ограничителя настроен автоматический режим пополнения, просто не делает ничего). Для упрощения перечисления записей секции и их очистки класс секционированного ограничителя поддерживает вспомогательные структуры данных (списки), которые я здесь не рассматриваю.
Управление значением свойства IdleDuration находится целиком в ведении класса ограничителя секции, так что будет ли зафиксировано устаревание, на самом деле, целиком зависит от логики, реализованной в этом классе. Во входящих в универсальный компонент ограничителя базовых классах, предназначенных для использования в качестве ограничителя секции, это свойство реализовано, так сказать, честно: если есть активные выданные этим ограничителем разрешения (число доступных разрешений меньше предельного), то оно возвращает null, в противном случае оно возвращает интервал времени с момента, когда активных выданных разрешений не стало. Но так реализовывать свойство IdleDuration совсем не обязательно. В частности, в приложении к следующей статье как раз описан базовый ограничитель-декоратор, который реализует это свойство по-другому. Этот ограничитель-декоратор позволяет явно управлять временем жизни базового ограничителя, на котором он основан. Он, по факту, добавляет к нему функцию управления временем жизни. А именно, пока не была произведена очистка (Dispose) ограничителя, он всегда показывает, что этот ограничитель активный (IdleDuration возвращает null), а после очистки - что этот ограничитель является заведомо устаревшим (IdleDuration возвращает TimeSpan.MaxValue, что заведомо больше любого таймаута устаревания).
Сцепленный селективный ограничитель.
Для получения разрешения этому классу ограничителя требуется получить разрешения от всех селективных ограничителей, входящих в его состав. При создании экземпляра этого ограничителя его конструктору передается (через список параметров переменной длинны) набор ограничителей, который будет включать в себя этот сцепленный ограничитель.
Сцепленный селективный ограничитель создается методом CreateChained<TResource> все того же необобщенного статического класса PartitionedRateLimiter. Этот метод имеет параметр-тип - тип ресурса, для которого создается ограничитель - и переменное число параметров - селективных ограничителей для того же типа ресурса. В соответствии с правилами языка C# вместо списка параметров переменной длинны в этот метод можно передать массив значений того же типа, что и параметры из списка - то есть, массив селективных ограничителей. Список параметров переменной длины или же массив передается в виде массива напрямую в конструктор сцепленного селективного ограничителя.
При подаче заявки на разрешение сцепленный селективный ограничитель пытается по очереди получить указанное в заявке количество разрешений от всех входящих в его состав ограничителей, подавая им заявку методом того же типа - синхронным или асинхронным. Если хотя бы один из входящих в состав ограничителей не сможет выделить запрошенное количество разрешений, то сцепленный ограничитель возвращает полученный от этого ограничителя ответ на заявку с отказом в разрешении. При этом все ранее полученные ответы (с выданными разрешениями) очищаются. Если же все ограничители смогли выделить запрошенное количество разрешений, то сцепленный ограничитель возвращает комбинированный ответ на заявку с полученным разрешением, в котором сохраняет список всех полученных ответов с разрешениями. При очистке комбинированного ответа производится очистка всех ответов из этого списка.
Очистка сцепленного селективного ограничителя просто выставляет флаг очистки, но не приводит к очистке входящих в его состав ограничителей. Так что ограничители, входящие в состав сцепленного ограничителя, могут, в принципе, использоваться совместно.
Класс сцепленного селективного ограничителя ChainedPartitionedRateLimiter - обобщенный, он имеет тот же параметр-тип TResourse, что и метод CreateChained.
Подробности
Сигнатуры - доступные снаружи методы/свойства и важные внутренние поля
internal sealed class ChainedPartitionedRateLimiter<TResource> : PartitionedRateLimiter<TResource> { private readonly PartitionedRateLimiter<TResource>[] _limiters; public override RateLimiterStatistics? GetStatistics(TResource resource); protected override RateLimitLease AttemptAcquireCore(TResource resource, int permitCount); protected override async ValueTask<RateLimitLease> AcquireAsyncCore(TResource resource, int permitCount, CancellationToken cancellationToken); protected override void Dispose(bool disposing); private sealed class CombinedRateLimitLease : RateLimitLease { private RateLimitLease[]? _leases; private HashSet<string>? _metadataNames; public override bool IsAcquired => true; public override IEnumerable<string> MetadataNames { get {/*...*/ } } public override bool TryGetMetadata(string metadataName, out object? metadata); protected override void Dispose(bool disposing); } }
В основе реализации сцепленного селективного ограничителя ChainedPartitionedRateLimiter лежит массив _limiters компонентов сцепленного ограничителя, каждый из которых является селективным ограничителем для того же типа ресурса. Значение этого поля копируется в конструкторе ChainedPartitionedRateLimiter из его параметра и содержит ссылку на массив переданных в качестве параметров ограничителей, комбинируемых в этом сцепленном ограничителе. Изменение, появившееся .NET 10: теперь поле _limiters содержит ссылку на вновь создаваемый массив, в который копируются из массива-параметра конструктора ссылки на ограничители, поэтому потенциальные изменения этого массива (если он передается именно как массив, а не список переменного числа параметров) после создания сцепленного ограничителя больше не влияют на список используемых им ограничителей-компонентов.
Реализации методов подачи заявок AttemptAcquireCore/AcquireAsyncCore последовательно для каждого элемента массива пытаются получить заказанное количество разрешений. При этом, даже в асинхронном варианте, разрешения запрашиваются строго последовательно, без попыток получать разрешения параллельно. Если все ограничители, являющиеся компонентами, выделяют запрошенные разрешения, то сцепленный ограничитель создает составной объект ответа на заявку типа CombinedRateLimitLease и возвращает его. При первом же получении от компонента ответа на заявку с отказом сцепленный ограничитель очищает все ранее полученные ответы на заявки и возвращает этот ответ с отказом.
Класс составного ответа на заявку является частным (private) и потому доступен только внутри класса сцепленного ограничителя (изменение, появившееся .NET 10: класс составного ответа на заявку CombinedRateLimitLease теперь вынесен в отдельный файл, получил модификатор доступа internal и используется в появившемся в этой версии составным базовым ограничителем, однако для пользователей универсального компонента ограничения это изменение ни на что не влияет). Поэтому получателю разрешения от сцепленного ограничителя доступны только свойства и методы, определенные в базовом классе ответа на заявку RateLimitLease.
В конструкторе составного ответа на заявку во внутреннем поле-массиве _leases сохраняются все ответы на заявки с выданными разрешениями, полученные от компонентов сцепленного ограничителя. При очистке объекта составного ответа на заявку его методом Dispose все эти объекты ответов очищаются. Свойство IsAcquired (разрешение получено) составного ответа на заявку всегда возвращает true, т.к. этот объект объединяет ответы на заявки обязательно с выданными разрешениями.
При первом обращении к свойству MetadataNames составного ответа на заявку создается и запоминается во внутреннем поле хэш-набор имен всех метаданных из всех ответов на заявки, из которых образован этот объект составного ответа на заявку, именно этот набор (приведенный к перечислению строк IEnumerable<String> возвращается в качестве значений свойства при первом и последующих к нему обращениях. Метод TryGetMetadata просматривает по порядку все ответы на заявки, составляющие этот составной ответ и извлекает первые найденные метаданные с указанным именем, если такие метаданные вообще существуют. В этом случае метод игнорирует другие возможные дубликаты из других ответов на заявки и сразу возвращает true. Если метаданные с указанным именем не удается найти ни в одном составляющем составной ответ объекте ответе на заявку, то возвращаемое в выходном параметре значение устанавливается в null и метод возвращает false.
Реализации метода получения статистики селективного ограничителя GetStatistics также последовательно для каждого элемента списка входящих в его состав ограничителей получает статистики от каждого из них. Затем этот метод вычисляет статистику на основе набора полученных статистик в соответствии со смыслом каждого показателя: для поля числа доступных разрешений CurrentAvailablePermits возвращается минимальное значение этого поля из набора, для остальных полей возвращаются суммы значений этих полей в наборе.
Вспомогательные статические методы для создания секционирующих делегатов, использующих алгоритмы, реализованные в универсальном компоненте ограничения.
Для упрощения создания секционированных ограничителей на основе базовых ограничителей, реализующих определенные в компоненте алгоритмы (далее эти реализации называются алгоритмические ограничители), в универсальном компоненте ограничения в статическом классе RateLimiterPartition имеется набор статических методов, упрощающих создание таких секционирующих ограничителей. Напомню, что для создания секционированного ограничителя используется обобщенный метод PartitionedRateLimiter.Create<TResource,TKey>. В этот метод передается один обязательный аргумент - секционирующий делегат, принимающий единственный параметр - ресурс (его тип - параметр-тип TResource, в функции ограничения запросов для ASP.NET Core этот тип всегда HttpContext). Возвращает этот делегат данные для секции, которые содержат значение ключа секционирования и делегат, предоставляющий экземпляр ограничителя для этой секции. Подробности описаны выше, в описании секционированного ограничителя.
Обсуждаемые вспомогательные методы возвращают данные для секции на основе передаваемого им через первый параметр значения ключа секционирования. Их можно вызывать изнутри секционирующего делегата - достаточно добавить в него код для получения значения ключа из значения ресурса и вызвать вспомогательный метод с аргументом - этим ключом. Как это делается - см. пример в руководстве(первой статье цикла). Все эти методы - обобщенные, они содержат один параметр-тип TKey - тип ключа секционирования. Список вспомогательных методов для использования алгоритмических ограничителей из универсального компонента ограничения следующий:
GetFixedWindowLimiter для использования ограничителя секции, применяющего алгоритм с фиксированным окном (тип создаваемого ограничителя - FixedWindowRateLimiter);
GetSlidingWindowLimiter для использования ограничителя секции, применяющего алгоритм со скользящим окном (тип создаваемого ограничителя - SlidingWindowRateLimiter);
GetTokenBucketLimiter для использования ограничителя секции, применяющего алгоритм емкости для жетонов (тип создаваемого ограничителя - TokenBucketRateLimiter);
GetConcurrencyLimiter для использования ограничителя секции, применяющего алгоритм ограничения параллелизма (тип создаваемого ограничителя - ConcurrencyLimiter).
В качестве второго параметра указанные вспомогательные методы принимают делегат, возвращающий для передаваемого в него значения ключа секционирования экземпляр параметров настройки для создаваемого алгоритмического ограничителя секции, тип которых соответствует алгоритму, реализуемому ограничителем секции. Важно отметить, что если вспомогательный метод предназначен для создания ограничителя скорости (то есть - один из первых трех в списке выше), то в параметрах настройки, возвращаемых делегатом, не следует указывать режим автоматического пополнения (AutoReplenishment = true). Потому что вспомогательный метод всё равно отключит автоматический режим пополнения в параметрах настройки, но для этого ему придется создавать дополнительную копию этих параметров в куче, тем самым больше нагружая процесс сборки мусора и чаще вызывая сборщик мусора.
Подробности
Вспомогательные статические методы из этого раздела определены в статическом классе RateLimitPartition (не обобщенном). Сигнатура этого класса(ссылка на исходный код):
namespace System.Threading.RateLimiting { public static class RateLimitPartition { public static RateLimitPartition<TKey> Get<TKey>(TKey partitionKey, Func<TKey, RateLimiter> factory); public static RateLimitPartition<TKey> GetNoLimiter<TKey>(TKey partitionKey); public static RateLimitPartition<TKey> GetConcurrencyLimiter<TKey>(TKey partitionKey, Func<TKey, ConcurrencyLimiterOptions> factory); public static RateLimitPartition<TKey> GetTokenBucketLimiter<TKey>(TKey partitionKey, Func<TKey, TokenBucketRateLimiterOptions> factory); public static RateLimitPartition<TKey> GetSlidingWindowLimiter<TKey>(TKey partitionKey, Func<TKey, SlidingWindowRateLimiterOptions> factory); public static RateLimitPartition<TKey> GetFixedWindowLimiter<TKey>(TKey partitionKey, Func<TKey, FixedWindowRateLimiterOptions> factory); }
Кроме упомянутых вспомогательных методов, этот класс содержит еще два: Get и GetNoLimiter. Первый из них просто принимает в качестве параметров компоненты структуры данных для создания секции - ключ секции (первый параметр) и делегат создающий базовый ограничитель для этой секции (второй параметр) и возвращает вновь созданную структуру данных для создания секции, содержащую эти параметры. Этот метод используется в качестве вспомогательного во всех остальных методах этого класса. Второй из этих методов создает с помощью вышеупомянутого метода Get данные для секции с переданным ему ключом (первый и единственный параметр) и делегатом, создающим ограничитель секции, который всегда возвращает ответ на заявку с выданным разрешением (класс этого ограничителя - NoopLimiter) и возвращает эти данные.
Вспомогательный метод GetConcurrencyLimiter для создания секционирующих делегатов, использующих алгоритм ограничения одновременного использования (параллелизма), создает с помощью метода Get и возвращает данные для секции, содержащие переданный ему через первый параметр ключ секции и делегат, который на основе переданного ему ключа секции создает новый экземпляр класса ConcurrencyLimiter, причём в качестве параметра - настроек (экземпляра типа ConcurrencyLimiterOptions) - в конструктор этого экземпляра передается результат вызова конфигурирующего делегата - второго параметра метода GetConcurrencyLimiter.
Вспомогательные методы для создания секционирующих делегатов, использующих алгоритмы ограничения скорости обработки запросов - GetFixedWindowLimiter, GetSlidingWindowLimiter и GetTokenBucketLimiter - устроены чуть сложнее. В возвращаемом ими делегате тоже сначала вызывается конфигурирующий делегат - второй параметр вспомогательного метода - для получения экземпляра класса настроек нужного для создания алгоритмического ограничителя типа. Но затем в полученном экземпляре класса настроек в возвращаемом делегате проверяется поле AutoReplenishment (оно дублируется во всех классах параметров настройки для алгоритмов ограничения скорости). И если это поле равно true, то возвращаемый делегат создает в куче новый экземпляр соответствующего класса параметров настройки, копирует в него все настройки, кроме поля AutoReplenishment, а это поле устанавливают в false. И уже эта копия передается (в делегате создания базового ограничителя секции, который передается в метод Get) в конструктор соответствующего базового ограничителя скорости вместо оригинального экземпляра параметров настройки. Это делается, чтобы создаваемые вспомогательными статическими методами базовые ограничители скорости пользовались для пополнения разрешений вызовами метода пополнения TryReplenish по таймеру секционированного ограничителя, а не создавали по собственному таймеру для каждого базового ограничителя для каждой секции. Создание таких копий параметров вызывает лишнее выделение памяти из кучи (и, соответственно - лишнюю работу для сборщика мусора). Поэтому, во избежание создания лишних объектов, в делегатах, создающих параметры настройки для вспомогательных методов, использующих алгоритмы ограничения скорости, настоятельно рекомендуется сразу устанавливать поле AutoReplenishment в false.
Про код.
Про код
Конечно, в коде рассмотренных классов такого безобразного пренебрежения лучшими практиками ООП, как имеющее место в классах базовых алгоритмических ограничителей, массовое дублирование кода, выполняющего схожие функции (см. следующую статью), не наблюдается. Но авторы кода и в этих классах эффективными приемами ООП местами пренебрегают. Рассмотрим, например такой фрагмент кода класса секционированного ограничителя DefaultPartitionedRateLimiter
if (rateLimiter.Value.Value is ReplenishingRateLimiter replenishingRateLimiter) { try { replenishingRateLimiter.TryReplenish(); } catch (Exception ex) { aggregateExceptions ??= new List<Exception>(); aggregateExceptions.Add(ex); } }
Этот фрагмент - часть тела метода класса секционированного ограничителя DefaultPartitionedRateLimiter, срабатывающего по таймеру и отвечающего за периодические операции. Конкретно данный фрагмент отвечает за вызов метода пополнения числа доступных разрешений с течением времени для ограничителей скорости. Чем этот фрагмент не соответствует духу ООП? Тем, что этот код явно проверяет, что ссылка на ограничитель секции в записи секции указывает на класс, производный от того типа, ссылка на который хранится (исходного класса иерархии), и если да - приводит ссылку к этому типу, и выполняет для него специальную обработку. Чем это плохо? Прежде всего - тем, что такое поведение этого кода является неожиданным. Внешний код, использующий его (а именно - код секционирующего делегата, используемого при создании этого секционированного ограничителя) имеет право полагаться на то, что в нужном месте он может вернуть экземпляр любого класса, унаследованного от исходного класса иерархии (RateLimiter) , и код класса секционированного ограничителя не будет с ним делать ничего такого, что выходит за рамки интерфейса исходного класса. То есть, класс секционированного ограничителя здесь использует специфическое знание того, как устроена иерархия классов базовых ограничителей. Это плохо, потому что тем самым код секционированного ограничителя оказывается связан с кодом классов базовых ограничителей случайной, совершенно ненужной связью - то есть возникает элемент спутанности (coupling), а потому такой код сложнее для понимания и использования и менее гибок.
На практике (см. приложение к следующей статье) для меня это проявилось в том, что при создании декоратора для модификации поведения базовых алгоритмических ограничителей (а для этого пришлось применить агрегирование, потому что авторы универсального компонента ограничения зачем-то запретили наследование от классов базовых алгоритмических ограничителей), предназначенного быть ограничителем секции в секционированном ограничителе, мне потребовалось унаследовать его от класса базового ограничителя скорости, а не от первичного класса базового ограничителя, как это выглядит логичным на первый взгляд, если не знать детали работы секционированного ограничителя (упомянутую проверку на подкласс ограничителя скорости). А чтобы сохранить возможность применения этого декоратора для модификации поведения подчиненных ограничителей, как являющихся, так и не являющихся ограничителями скорости, этот декоратор проверяет, является ли его подчиненный ограничитель ограничителем скорости, и если нет, то не переадресует ему специфические для ограничителя скорости обращения к свойствам или вызовы методов, а выполняет их эмуляцию сам - так, чтобы результатом было отсутствие каких-либо действий.
Ещё одно следствие такой, чрезмерно завязанной на конкретный подкласс, реализации пополнения ограничителей секций в секционированном ограничителе - невозможность использования ручного режима пополнения для ограничителей скорости, входящих в состав сцепленного ограничителя секции. Сцепленный базовый ограничитель реализован прямолинейно, без тех трюков, которые я использовал для своего ограничителя-декоратора, и, что логично, его класс унаследован от первичного класса базового ограничителя RateLimiter. Поэтому процедура, запускаемая по таймеру в секционированном ограничителе, не вызывает (и не может вызывать) методы ручного пополнения разрешений TryReplenish ограничителей скорости, входящих в состав сцепленного ограничителя.
Как бы можно было бы реализовать пополнение разрешений в секционированном ограничителе по уму? Да вынести нужный для пополнения разрешений в ограничителе скорости метод TryReplenish в первичный класс иерархии, реализовав его там как виртуальный метод, не делающий ничего (такие методы иногда называют полуабстрактными), примерно так:
public abstract class RateLimiter : IAsyncDisposable, IDisposable { //... virtual void TryReplenish() {} }
А реальную функциональность этого метода для ограничителей скорости реализовать на уровне класса базового ограничителя скорости ReplenishingRateLimiter(или, как в нынешней реальности, с царящем в ней дублированием - производного от него класса базового алгоритмического ограничителя). Тогда бы авторам компонента не пришлось проверять в коде секционированного ограничителя тип класса экземпляра, и код бы выглядел значительно проще:
try { rateLimiter.Value.Value.TryReplenish(); } catch (Exception ex) { aggregateExceptions ??= new List<Exception>(); aggregateExceptions.Add(ex); }
А заодно можно было бы перенести в класс RateLimiter и специфичные для ограничителя скорости свойства, с возвратом через них фиктивных значений: вполне можно считать, что, раз ограничителю ручные пополнения не требуются, то он пополняется автоматически (IsAutoReplenishing возвращает true), а его период пополнения практически бесконечен (ReplenishmentPeriod возвращает TimeSpan.MaxValue) - короче, реализовать их так, как я сделал это в классе ограничителя-декоратора. Ну, а мне бы не пришлось тогда прибегать к трюкам при создании своего ограничителя-декоратора. Но, увы, авторы кода универсального компонента ограничения так сделать не сподобились.
Еще также следует отметить несколько неудачный выбор названия PartitionedRateLimiter для первичного класса иерархии селективных ограничителей. Считаю также неправильным, что классы, реализующие специфические селективные ограничители (например, класс секционированного ограничителя DefaultPartitionedRateLimiter) не были открыты для наследования (похоже, это - ещё одно проявление нелюбви авторов компонента к ООП), но это вполне обходится использованием агрегирования вместо наследования.
Заслуживает внимания и взятия себе на вооружении прием, использованный в возвращаемых секционирующим делегатом данных для секции: в них содержится не только ключ секции, но и делегат для создания ограничителя секции, который можно использовать для отложенной инициализации поля структуры данных секции, содержащего этот ограничитель. Этот прием позволил обойтись всего одним вызовом секционирующего делегата, вне зависимости от того, создана ли уже секция с таким ключом или нет.
В реализации сцепленного ограничителя мне не нравится, что в процессе ожидания получения разрешения от последующих ограничителей в цепочке удерживаются неограниченное время в неиспользуемом состоянии ранее полученные разрешения от предыдущих ограничителей в цепочке: такое решение снижает скорость обработки запросов ниже указанной (так как часть пригодных к выдаче разрешений уже получена, но не используется, а удерживается в процессе этого ожидания), а в особо неприятных случаях способно даже вызвать взаимную блокировку.
Приложение. Пример селективного ограничителя: связывание разрешений с внешними объектами.
Как пример того, что можно сделать, имея информацию по работе ограничителей из состава универсального компонента ограничения скорости, написанную в этой статье, я продемонстрирую, как с помощью этой информации, используя подход ООП, можно создать элемент библиотеки классов для решения целой категории нестандартных задач по ограничению скорости, для которых прямого, “по руководству”, решения в универсальном компоненте ограничения скорости не предусмотрено.
Какие варианты решений может предоставить библиотека классов в рамках ООП
Библиотека классов может предоставлять использующей её программе три вида сущностей, полезных для использования в этой программе.
Во-первых, библиотека может предоставлять определенные в ней объекты с фиксированным поведением. Свойства таких объектов до некоторой степени настраиваются при их создании, но указать свой код, выполняющийся при выполнении этого вида сущностей с целью расширения вариантов их поведения, невозможно. Такой вид предоставляемых библиотекой сущностей наиболее прост для понимания и использования, потому что этот вид не специфичен именно для ООП, его аналоги предоставляются и библиотеками (и наборами системных вызовов ОС) работающих в чисто процедурной парадигме. Например, именно к этому виду сущностей относятся файлы, которые есть практически во всех современных ОС. В универсальном компоненте ограничителя скорости, который мы рассматриваем, к этому виду относятся, например, базовые алгоритмические ограничители.
Во-вторых, библиотека может предоставлять объекты, поведение которых может до некоторой степени модифицироваться процедурами с обратным вызовом: при создании такого объекта в параметрах его настройки можно передать ссылку на процедуру (одну или несколько), которые будут вызываться в определенных местах кода, выполняемого в методах этого объекта. Пример сущности этого вида в универсальном компоненте ограничения - секционированный ограничитель, при создании которого в него передается секционирующий делегат. Такой вид тоже не специфичен именно для ООП: он встречается даже в некоторых системных вызовах ОС, например, в функциях асинхронного ввода/вывода (ReadFileEx и т.п.) из Win32 API, где можно указать процедуру, выполняющуюся при завершении этого асинхронного ввода/вывода. ООП расширяет рамки этого вида сущностей использованием шаблона “Стратегия”: предоставлением возможности, передать в параметрах настройки ссылку на класс (точнее, на его интерфейс), методы которого реализуют согласованный набор процедур обратного вызова. Работать с объектами этого вида чуть сложнее, чем с объектами первого вида, поскольку процедуры обратного вызова необходимо реализовывать самостоятельно.
Третий, специфичный именно для парадигмы ООП вид полезных сущностей, предоставляемых библиотекой классов - базовые классы для решения целых категорий задач. Эти классы содержат в своих методах общий код, используемый для решения любых задач нужной категории. Но использовать их экземпляры напрямую для решения каких-либо задач нельзя (а зачастую и невозможно чисто технически): для практического использования эти классы требуют расширения их кодом, предназначенными для решения специфической задачи. Расширение производится путем наследования от этого базового класса специфического класса для решения конкретной задачи и перекрытием виртуальных методов, определенных в базовом классе, с добавлением в них нужного для решения этой конкретной задачи кода. Поэтому использовать сущность этого вида сложнее всего - для этого нужно уметь полноценно пользоваться ООП и, в частности, наследованием. Но зато этот вид предоставляет больше возможностей. Примером сущности этого вида, предоставляемым библиотекой классов, может являться класс DbContext из библиотеки ORM Entity Framework. В рассматриваемом этой статье универсальном компоненте ограничения скорости сущностей этого вида фактически нет. Разработчики библиотеки шарахаются от возможности использования любых содержащих хоть какую-то функциональность, а не только определяющих интерфейс классов своей библиотеки как базовых как чёрт от ладана: все такие классы либо сделаны внутренними для библиотеки (классы селективных ограничителей), либо запрещенными для расширения (sealed, как классы базовых алгоритмических ограничителей). Единственные доступные для расширения классы - это исходные классы для иерархий классов ограничителей, практически не содержащие кода - и то, видимо, потому, что сделать их недоступными для расширения невозможно технически. Из этого факта, а также - из того, что эти классы определены именно как классы, а не как интерфейсы (что было бы логично для сущностей, единственная роль которых - задавать интерфейс взаимодействия) я делаю вывод, что разработчики универсального компонента ограничения не любят ООП и не умеют в ООП. Но это так, к слову, статья не об этом.
Пример из этой статьи являет собой решение в виде базового класса (то есть, предоставляет третий вариант решения из перечисленных в скрытом тексте выше) для задачи связывания с долгоживущими объектами, существующими в программе, разрешений, полученных от компонента ограничения, и получения на их основе разрешений на обработку отдельных коротких запросов, связанных с этими объектами. Этому классу посвящена первая часть приложения к статье. А поскольку приведенный в этой части пример базового класса получился весьма абстрактным, то для иллюстрации его использования для решения уже конкретной задачи я счел полезным привести пример того, как этот базовый класс использовать: то есть, как унаследовать от этого базового класса специфический класс-ограничитель, решающий конкретную задачу. Пусть эта задача - ограничение числа используемых активных сеансов, которые можно создать в рамках одной группы сеансов (то есть, сеанса пользователя) - и имеет смысл в практически неиспользуемой конфигурации с использованием моей библиотеки ActiveSession, для примера это не важно. Этому примеру посвящена вторая часть приложения к статье.
Часть 1: реализация собственного класса селективного ограничителя - ограничителя по внешнему объекту
Базовый класс, служащий примером в этой части приложения к статье, предназначен для решения описанной далее категории задач. Предположим, что программа предназначена для обработки коротких запросов, представляемых короткоживущими объектами ресурсов, и в программе необходимо обеспечить ограничение скорости обработки (или числа одновременно обрабатываемых) запросов, причем ограничение нужно выполнять отдельно для разных групп этих запросов. Серверная часть веб-приложения являет собой хороший пример такой программы, а объектом ресурса в ней является контекст запроса (HttpContext в ASP.NET Core). Предположим также, что каждый ресурс (или большинство из них), связан с неким долгоживущим объектом в программе - в контексте этого примера он называется внешним объектом, и ограничивать (в принципе или главным образом) надо скорость создания или число одновременно существующих таких внешних объектов, и уже на основе ограничения для этих объектов предполагается ограничивать связанные с ними запросы. Предположим также, что эти внешние объекты предоставляются какой-то внешней библиотекой, код который недоступен или его модифицировать нежелательно, а создание внешних объектов происходит неявно где-то внутри этой библиотеки, так что мы не можем применить ограничение в том месте, где внешние объекты создаются. Так вот, класс, описанный в этом примере как раз и предназначен для ограничения скорости создания (или числа) связанных с запросами внешних объектов, в той точке применения ограничения, где выдаются разрешения для обработки отдельных запросов.
В применении к функции ограничения ASP.NET Core (описанию которой посвящён этот цикл статей в целом) такой точкой применения как раз является встроенный в неё компонент-обработчик (middleware) ограничения скорости обработки запросов в конвейере приложения: он ограничивает выполнение именно на уровне отдельных запросов. Так что архитектура приложения на ASP.NET Core вполне подходит для использования класса из этого примера (точнее - его наследника) для работы в рамках функции ограничения скорости обработки запросов.
Но довольно общих слов, перейдем к коду примера.
Код примера.
Класс из этого примера входит в состав библиотеки классов MVVRus.Extensions.RateLimiting. В ближайшем будущем я планирую оформить эту библиотеку в качестве NuGet-пакета и добавить его в галерею (как сделаю - отмечу это в статье). Но пока это не сделано, желающие использовать этот класс могут взять исходный проекта библиотеки код из репозитория на GitHub и сделать ссылку на него в своем проекте. Или же можно взять исходные тексты классов прямо из этой статьи и включить их прямо в проект приложения.
В состав кода примера входят:
сам класс селективного ограничителя по внешнему объекту LinkedLeaseRateLimiter;
интерфейс IShareableLeaseOwnerдля объекта хранилища, используемого классом ограничителя по внешнему объекту для связи внешнего объекта и выданного на него разрешения и для получения производных разрешений на ресурсы на основании выданного референсного разрешения на внешний объект ;
базовый класс для реализации хранилища SingleShareableLeaseOwner, реализующий простейшую политику выдачи производных разрешений - “политику одного разрешения”: она позволяет выдавать произвольное количество производных разрешений при наличии референсного; этот класс, в частности, будет использован в примере конкретного класса-ограничителя во второй части;
класс ответа на заявку на производное разрешение DerivedLease, экземпляры этого класса возвращаются методами подачи заявки для объекта хранилища.
Исходный код класса LinkedLeaseRateLimiter с комментариями
public abstract class LinkedLeaseRateLimiter<TResource, TPartitionKey> : PartitionedRateLimiter<TResource> where TPartitionKey:notnull { PartitionedRateLimiter<TResource>? _baseLimiter; //Референсный ограничитель, null означает, что объект был очищен. Boolean _disposeBaseLimiter = false; //Флаг необходимости очистки референсного ограничителя при очистке этого объекта. protected abstract IShareableLeaseOwner<TResource>? GetLeaseStore(TResource resource); public LinkedLeaseRateLimiter(PartitionedRateLimiter<TResource> baseLimiter) { _baseLimiter = baseLimiter; } //Защищенный конструктор, позволяющий очистку референсного ограничителя, созданного в конструкторе производного класса. protected LinkedLeaseRateLimiter(PartitionedRateLimiter<TResource> baseLimiter, Boolean disposeBaseLimiter) { _baseLimiter = baseLimiter; _disposeBaseLimiter = disposeBaseLimiter; } //Метод получения статистики public override RateLimiterStatistics? GetStatistics(TResource resource) { return GetBaseLimiterThrowIfDisposed().GetStatistics(resource); } //Метод асинхронной подачи заявки, с возможным ожиданием в очереди protected override async ValueTask<RateLimitLease> AcquireAsyncCore(TResource resource, Int32 permitCount, CancellationToken cancellationToken) { PartitionedRateLimiter<TResource> base_limiter = GetBaseLimiterThrowIfDisposed(); IShareableLeaseOwner<TResource>? leaseStore = GetLeaseStore(resource); if(leaseStore!=null) return await leaseStore.AcquireLeaseAsync(base_limiter, resource, permitCount, cancellationToken); else return await base_limiter.AcquireAsync(resource,permitCount, cancellationToken); } //Метод синхронной подачи заявки protected override RateLimitLease AttemptAcquireCore(TResource resource, Int32 permitCount) { PartitionedRateLimiter<TResource> base_limiter = GetBaseLimiterThrowIfDisposed(); IShareableLeaseOwner<TResource>? leaseStore = GetLeaseStore(resource); if(leaseStore!=null) return leaseStore.AcquireLease(base_limiter, resource, permitCount); else return base_limiter.AttemptAcquire(resource, permitCount); } //Часть шаблона очистки protected override void Dispose(Boolean disposing) { PartitionedRateLimiter<TResource>? base_limiter = Interlocked.Exchange(ref _baseLimiter, null); if(disposing && _disposeBaseLimiter) if(base_limiter!=null) base_limiter.Dispose(); base.Dispose(disposing); } //Часть шаблона асинхронной очистки protected async override ValueTask DisposeAsyncCore() { PartitionedRateLimiter<TResource>? base_limiter = Interlocked.Exchange(ref _baseLimiter, null); if(_disposeBaseLimiter && base_limiter!=null) await base_limiter.DisposeAsync(); await base.DisposeAsyncCore(); } //Получение референсного ограничителя с проверкой что объект не был очищен. private PartitionedRateLimiter<TResource> GetBaseLimiterThrowIfDisposed() { PartitionedRateLimiter<TResource>? base_limiter = Volatile.Read(ref _baseLimiter); if(base_limiter==null) throw new ObjectDisposedException(nameof(LinkedLeaseRateLimiter<TResource,TPartitionKey>)); return base_limiter; } }
Исходный код интерфейса IShareableLeaseOwner
public interface IShareableLeaseOwner:IDisposable { void Release(DerivedLease derivedLease); event EventHandler DisposedEvent; } public interface IShareableLeaseOwner<TResource>: IShareableLeaseOwner { DerivedLease AcquireLease(PartitionedRateLimiter<TResource> baseLimiter, TResource resource, Int32 permitCount); ValueTask<DerivedLease> AcquireLeaseAsync(PartitionedRateLimiter<TResource> baseLimiter, TResource resource, Int32 permitCount, CancellationToken cancellationToken); }
Исходный код класса SingleShareableLeaseOwner
public abstract class SingleShareableLeaseOwner<TResource> : IShareableLeaseOwner<TResource> { Task<RateLimitLease>? _rawLeaseTask=null; protected abstract Boolean TryGetLease(out RateLimitLease? lease); protected abstract Boolean TrySetLease(ref RateLimitLease lease); public event EventHandler? DisposedEvent; public DerivedLease AcquireLease(PartitionedRateLimiter<TResource> baseLimiter, TResource resource, Int32 permitCount) { if(IsDisposed) throw new ObjectDisposedException(nameof(SingleShareableLeaseOwner<TResource>)); RateLimitLease? lease, acquired_lease=null; Boolean must_dispose_lease = false; if(!TryGetLease(out lease)) { lease = acquired_lease = baseLimiter.AttemptAcquire(resource, 1); must_dispose_lease = !TrySetLease(ref lease); } DerivedLease result = MakeDerived(lease!, permitCount); if(must_dispose_lease) acquired_lease?.Dispose(); return result; } public async ValueTask<DerivedLease> AcquireLeaseAsync(PartitionedRateLimiter<TResource> baseLimiter, TResource resource, Int32 permitCount, CancellationToken cancellationToken) { if(IsDisposed) throw new ObjectDisposedException(nameof(SingleShareableLeaseOwner<TResource>)); RateLimitLease? lease, acquired_lease = null; Boolean must_dispose_lease = false; if(!TryGetLease(out lease)) { Task<RateLimitLease>? current_lease_task; //No raw (i.e. base) lease yet. Try to acquire it async while((current_lease_task=Volatile.Read(ref _rawLeaseTask)) == null) { TaskCompletionSource start_tcs = new TaskCompletionSource(); //Used to delay the raw lease acquisition task Task<RateLimitLease> new_raw_lease_task = start_tcs.Task.ContinueWith( task => baseLimiter.AcquireAsync(resource, 1, cancellationToken).AsTask(), cancellationToken, TaskContinuationOptions.OnlyOnRanToCompletion, TaskScheduler.Default ).Unwrap(); current_lease_task = Interlocked.CompareExchange(ref _rawLeaseTask, new_raw_lease_task, null); if(current_lease_task!=null) // _rawLeaseTask has been already set while we creating new_raw_lease_task start_tcs.SetCanceled(); else // Now we can allow raw lease acquisition task to be performed start_tcs.TrySetResult(); } lease = acquired_lease = await current_lease_task; must_dispose_lease = !TrySetLease(ref lease); RateLimitLease? placeholder; Task? abandoned; if(!lease.IsAcquired && !TryGetLease(out placeholder)) abandoned = Interlocked.CompareExchange(ref _rawLeaseTask, null, current_lease_task); //Plan to acquire a permissive lease ones more } DerivedLease result = MakeDerived(lease!, permitCount); if(must_dispose_lease) acquired_lease?.Dispose(); return result; } public void Release(DerivedLease derivedLease) { //Nothing to do in this class } protected virtual void Dispose(Boolean disposing) { if(disposing) { EventHandler? t = Volatile.Read(ref DisposedEvent); FireEvent(t); } } public void Dispose() { // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method Int32 disposedValue = Interlocked.Exchange(ref _disposedValue, 1); if(disposedValue>0) { Dispose(disposing: true); GC.SuppressFinalize(this); } } [MethodImpl(MethodImplOptions.NoInlining)] void FireEvent(EventHandler? handler) { handler?.Invoke(this, EventArgs.Empty); } protected Boolean IsDisposed { get => Volatile.Read(ref _disposedValue)>0; }
Исходный код класса DerivedLease
public class DerivedLease : RateLimitLease { IShareableLeaseOwner? _owner; IDictionary<String, Object?> _metadata; volatile Boolean _need_release = true; public DerivedLease(IShareableLeaseOwner owner, Boolean isAcquired, Int32 permitCount, IDictionary<String,Object?> metadata) { _owner=owner; IsAcquired=isAcquired; _metadata=metadata; _owner.DisposedEvent+=OwnerDisposeHandler; } public override Boolean IsAcquired { get; } public override IEnumerable<String> MetadataNames=>_metadata.Keys; public override Boolean TryGetMetadata(String metadataName, out Object? metadata) { if(_metadata.ContainsKey(metadataName)) { metadata = _metadata[metadataName]; return true; } else { metadata = null; return false; } } public Int32 PermitCount { get; } void OwnerDisposeHandler(Object? Sender, EventArgs toIgnore) { _need_release=false; Dispose(); } protected override void Dispose(Boolean disposing) { if(disposing) { IShareableLeaseOwner? owner = Interlocked.Exchange(ref _owner, null); if(owner != null) owner.DisposedEvent -= OwnerDisposeHandler; if(_need_release) owner?.Release(this); } }
Чтобы использовать базовый класс ограничителя по внешнему объекту LinkedLeaseRateLimiter для решения конкретной задачи с конкретным типом внешнего объекта, необходимо унаследовать свой класс ограничителя от этого класса и реализовать используемый им интерфейс, то есть, необходимо выполнить следующие действия:
Создать свой класс хранилища, реализующий обобщенный интерфейс для объекта хранилища IShareableLeaseOwner<TResource>, где TResource - это тип объектов ресурсов, связанных с внешним объектом. Эти объекты ресурсов олицетворяют запросы, при обработке которых производится обращение к экземпляру класса селективного ограничителя. В приложениях ASP.NET Core тип ресурса - это всегда контекст запроса HttpContext. Объект хранилища логически ассоциирован с конкретным внешним объектом. Он должен выполнять две функции. Первая функция - получать при необходимости и сохранять полученное референсное разрешение для ассоциированного внешнего объекта. Вторая функция - в соответствии с той или иной (на выбор разработчика) политикой выдачи производных разрешений выдавать производные разрешения (или отказывать в их выдаче) на основе имеющегося референсного разрешения для ассоциированного внешнего объекта. Эти производные разрешения предназначены для принятия решения обработчиком запросов о возможности обработки данного запроса. Для использования простейшей политики - “политики одного разрешения” (выдача произвольного количества производных разрешений на основе одного выданного референсного) класс хранилища можно унаследовать от класса SingleShareableLeaseOwner<TResource>, для этого необходимо реализовать (т.е. переопределить) определенные в нем абстрактные методы чтения и сохранения референсных разрешений TryGetLease и TrySetLease.
Унаследовать свой класс селективного ограничителя от LinkedLeaseRateLimiter<TResource, TPartitionKey>. Для этого, как минимум, необходимо переопределить определенный в этом классе абстрактный метод GetLeaseStore, который для указанного в его аргументе значения ресурса возвращает реализацию интерфейса хранилища для связанного с этим ресурсом внешнего объекта (то есть, объект из п.1).
Пример того, как можно выполнить эти действия, рассмотрен во второй части данного приложения.
В остальных разделах этой части содержится описание того, как устроен и работает класс ограничителя по внешнему объекту и связанные с ним классы. Для использования этих классов в своей программе читать эти разделы не обязательно.
Класс селективного ограничителя по внешнему объекту.
Класс селективного ограничителя по внешнему объекту, расширяющий возможности универсального компонента ограничения в плане решения обсуждаемой категории задач - ограничения скорости/параллелизма обработки ресурсов, связанных с внешними объектами, называется LinkedLeaseRateLimiter. Чтобы реализовать свой селективный ограничитель - чему, собственно, посвящен пример - требуется реализовать абстрактные методы исходного класса селективного ограничителя: методы подачи заявок - синхронный (AttemptAcquireCore) и асинхронный(AcquireAsyncCore), и метод получения статистики (GetStatistics).
Конструктор класса LinkedLeaseRateLimiter принимает единственный параметр: ссылку на селективный ограничитель, выдающий разрешения, связанные с внешним объектом. Этот ограничитель называется далее в описании этого примера референсный ограничитель. Работа класса LinkedLeaseRateLimiter основана на том, что разрешения, выданные внешнему объекту (референсные разрешения) от референсного ограничителя, сохраняются в некоем хранилище связанном с этим внешним объектом. Хранилище, - это объект, который реализует интерфейс хранилища (о нем см. следующий раздел).
Поиск (а при необходимости - и создание) хранилища для внешнего объекта, связанного с обрабатываемым значением ресурса, выполняется методом GetLeaseStore (в данном классе определенным как абстрактный), который возвращает ссылку на нужное хранилище либо null, если такое хранилище отсутствует и не может быть создано. Методы подачи заявок класса LinkedLeaserateLimiter - AttemptAcquireCore и AcquireAsyncCore - получают нужное хранилище и делегируют обработку запросов разрешений этому хранилищу, возвращая от него полученный ответ на заявку. Если хранилище для обрабатываемого значения ресурса отсутствует, методы получения заявок ограничителя по внешнему объекту просто вызывают с полученными ими параметрами соответствующие им методы референсного ограничителя и возвращают полученные от них ответы на заявки. Метод получения статистики GetStatistics ограничителя по внешнему объекту всегда обращается для получения результата к одноименному методу референсного ограничителя и возвращает его результат.
Таким образом, для решения конкретной задачи ограничения запросов для конкретного типа ресурсов, связанных с внешним объектом требуется отдельно реализовать класс ограничителя, унаследованный от класса LinkedLeaserateLimiter, и класс хранилища реализующий интерфейс IShareableLeaseOwner, через который ограничитель взаимодействует с хранилищем.
Подробности
Для получения интерфейса хранилища, связанного с внешним объектом, с которым связано текущее значение ресурса, используется метод GetLeaseStore, в него в качестве параметра передается текущее значение ресурса. Этот метод специфичен для каждого конкретного типа ресурса и внешнего объекта, а потому в классе LinkedLeaseRateLimiter он определен как абстрактный, то есть, в классах-наследниках для решения конкретных задач этот метод обязательно должен быть переопределен. Если для текущего значения ресурса интерфейс внешнего хранилища не может быть получен, метод GetLeaseStore должен возвращать null.
Если интерфейс хранилища для обрабатываемого значения ресурса был получен, то методы подачи заявок класса LinkedLeaserateLimiter - AttemptAcquireCore и AcquireAsyncCore - передают заявку на получения разрешения для обрабатываемого ресурса методам интерфейса хранилища - соответственно, методам AcquireLease и AcquireLeaseAsync - и возвращают полученные от них ответы на заявки. При этом в методы подачи заявок хранилища передаются ссылка на референсный ограничитель и все параметры, переданные в методы подачи заявок ограничителя по внешнему объекту. Если хранилище, связанное с текущим значением ресурса, отсутствует (метод GetLeaseStore вернул при получении хранилища null), то методы подачи заявок ограничителя по внешнему объекту обращаются за получением разрешения к методам референсного ограничителя (AttemptAcquire и AcquireAsync соответственно).
Хранилище понимается как некая абстракция, от которой требуется только, чтобы она реализовала интерфейс хранилища IShareableLeaseOwner, оно может быть как частью внешнего объекта, так и отдельным объектом, эти детали находятся за пределами абстракции. Связь с внешним объектом означает, что выданное разрешение можно найти по ссылке на внешний объект, содержащейся в значении ресурса - то есть, фактически, по значению ресурса. Это же хранилище отвечает не только за хранение референсного разрешения, но и за получение его от референсного ограничителя, и за политику выдачи разрешений на его основе для обработки ресурсов (производных разрешений). То есть, выдаваемые селективным ограничителем по внешнему объекту разрешения суть производные от референсного разрешения (или разрешений), полученного хранилищем для внешнего объекта и хранящегося в нем. Само это хранилище, как именно оно сохраняет выданное для внешнего объекта разрешение, и его политика выдачи производных разрешений на основе референсного разрешения не задаются классом LinkedLeaseRateLimiter (именно поэтому он и выглядит столь абстрактно), всё это - часть реализации хранилища.
Интерфейс хранилища.
Этот интерфейс используется классом селективного ограничителя по внешнему объекту из предыдущего раздела. Функции селективного ограничения по внешнему объекту выполняются совместно классом ограничителя по внешнему объекту (или его потомком) и классом, реализующим для него интерфейс хранилища.
Класс интерфейса хранилища отвечает за два разных сравнительно независимых друг от друга аспекта работы хранилища.
Аспект хранения разрешения: хранение референсных разрешений, выданных для внешних объектов. Хранилище определяет, где и как хранятся ответы с полученными разрешениями, и при каких условиях производится завершение использования этих разрешений - очистка соответствующего объекта(или объектов) ответа.
Аспект политики управления производными разрешениями для ресурсов: включает получение референсных разрешений и выдачу разрешений на обработку объектов ресурсов (производных разрешений), связанных с выданными референсными разрешениями для внешних объектов. Хранилище само определяет политику выдачи производных разрешений на основе имеющихся и получаемых референсных разрешений (какие требуются референсные разрешения для выдачи производных разрешений), запрашивает необходимые референсные разрешения, формирует ответы на заявки для производных разрешений и определяет, что делать, если нужное референсное разрешение не было получено (ответ на заявку на его выдачу получен отрицательный).
Оба эти аспекта скрываются за единым интерфейсом хранилища IShareableLeaseOwner.
Подробности
Технически интерфейс хранилища делится на не-специфичную не-обобщенную часть, не зависящую от типа ресурса унаследованную от IDisposeble, и специфичную для типа ресурса обобщенную часть (её параметр тип - это тип ресурса), унаследованную от не-обобщенной части.
Методы AcquireLease и AcquireLeaseAsync интерфейса хранилища IShareableLeaseOwner служат для подачи заявки на получение разрешения (соответственно, синхронной и асинхронной), событие DisposedEvent позволяет отслеживать факт очистки объекта хранилища (его необходимо вызвать в реализации метода Dispose интерфейса хранилища), а метод Release служит для оповещения хранилища о том, что объект производной заявки, переданный через параметр этого метода, был очищен и более не используется.
Класс реализации интерфейса хранилища, устанавливающий политику одного разрешения.
Хотя оба вышеупомянутых аспекта реализации интерфейса хранилища - реализации хранения и политики управления разрешениями - существуют относительно независимо, реализовываться они должны в рамках одних и тех же методов, так что разнести их по разным классам непросто. Но используя подходы ООП - наследование и полиморфизм - в рамках шаблона “Шаблонный метод” - вполне возможно. В состав этого примера я включил абстрактный класс, реализующий интерфейс хранилища, с реализацией только аспекта политики разрешений: класс SingleShareableLeaseOwner. Этот абстрактный класс реализует конкретную политику управления разрешениями - “политику одного разрешения”, но абстрагируется от способа хранения выданного разрешения: для работы с сохраненным разрешением используются абстрактные методы TryGetLease и TrySetLease. Эти методы должны быть переопределены в производном классе конкретного хранилища. В примере во второй части приложения я покажу, как можно создать класс конкретного хранилища на базе класса SingleShareableLeaseOwner.
Политика управления разрешениями этого класса реализует следующие принципы, называемые политикой одного разрешения:
Для выдачи любого числа ответов на запрос производных разрешений для обработки ресурсов необходимо и достаточно получить только один ответ на заявку на референсное разрешение для внешнего объекта, с которым эти ресурсы связаны, и с которым связано это хранилище. В ответе на заявку на получение производного разрешения возвращается значение признака получения разрешения (IsAcquired) из ответа для референсного разрешения, на основании которого формируется ответ.
В методах подачи заявки прежде всего производится попытка использовать для выдачи производных разрешений сохраненный ответ на заявку на референсное разрешение, если такового нет, то разрешение запрашивается у референсного ограничителя.
Метод подачи заявки пытается пытается сохранить полученное от референсного ограничителя значение ответа на заявку.
Имеющийся ответ на заявку на референсное разрешение - ранее сохраненный, а при его отсутствии - только что полученный от референсного ограничителя - используется для формирование ответа на поданную в метод получения заявку на производное разрешение. В создаваемый ответ на заявку на получение производного разрешения копируются свойства из имеющегося ответа на заявку на получение референсного разрешения - признак получения разрешения и метаданные.
Количество выданных производных разрешений для обработки связанных с внешним ресурсом, на который было получено референсное разрешение, никак не ограничивается и не учитывается. Метод Release интерфейса хранилища, вызываемый при очистке производного разрешения, не делает ничего.
При очистке класс реализации политики одного разрешения вызывает событие DisposedEvent, определенное в интерфейсе хранилища.
Таким образом, политика одного разрешения, реализуемая обсуждаемым классом, состоит в том, что для получения любого количества разрешений для обработки ресурсов необходимо и достаточно получить ровно одно разрешение для того внешнего объекта, который связан с обрабатываемыми значениями ресурса.
Подробности
Абстрактный метод TryGetLease получает сохраненный ответ на заявку на референсное разрешение, если он есть в хранилище. Если ответ в хранилище есть, то он копируется в выходной параметр lease и метод возвращает true, если же в хранилище его нет, то метод должен вернуть false и установить выходной параметр lease в null.
Абстрактный метод TrySetLease пытается сохранить ответ на заявку на референсное разрешение, полученный от референсного ограничителя. Он может вернуть true, если этот ответ был сохранен, или false если сохранение не произведено - либо потому что ответ сохранению по тем или иным причинам не подлежит, либо в хранилище уже есть ранее сохраненный ответ, который сохраняемым ответом заменить нельзя. Во втором случае необходимо заменить для вызывающего кода переданный из него в этот метод для сохранения (через параметр по ссылке) вновь полученный ответ на тот, который уже был сохранен ранее, чтобы вызывающий код работал далее именно с ним.
В методах подачи заявки AcquireLease и AcquireLeaseAsync прежде всего запрашивается методом TryGetLease сохраненный ответ на заявку на референсное разрешение. Если такового нет (TryGetLease вернул false), то разрешение запрашивается у референсного ограничителя путем подачи заявки методом соответствующего типа - синхронным (AttemptAcquire) или асинхронным (AcquireAsync). Если сохраненный ответ на запрос референсного разрешения отсутствовал и был произведен запрос референсного разрешения у референсного ограничителя, то метод подачи заявки пытается сохранить полученный ответ в хранилище методом TrySetLease. Результат, возвращенный этим методом, указывает, должен ли объект ответа быть очищен в методе подачи заявки класса SingleShareableLeaseOwner (результат - false) или же (результат - true) за его очистку должен отвечать производный класс хранилища, который, собственно, хранит это разрешение и знает, как его найти, чтобы выполнить его очистку.
Метод Release в данном классе не делает ничего, он нужен только для того, чтобы класс соответствовал интерфейсу IShareableLeaseOwner, который реализуется этим классом.
Метод MakeDerived создает экземпляр ответа на запрос производной заявки на основе референсного ответа на заявку и числа запрошенных в ней разрешений. Этот метод сделан виртуальным, чтобы класс-наследник мог вернуть в качестве ответа экземпляр другого класса, производного от DerivedLease
В классе SingleShareableLeaseOwner реализован стандартный для .NET шаблон очистки для иерархии классов. Реальная очистка производится виртуальным методом Dispose(Boolean). В данном классе этот метод вызывает событие DisposedEvent со всеми предосторожностями, препятствующими потенциальной гонке при вызове делегата события и одновременной отписке от события, в том числе - и после выполнения оптимизаций компилятором и JIT. Не знаю, нужен ли в современном .NET такой сложный ритуал вызова с отдельным методом FireEvent, который запрещено встраивать (когда-то он был нужен), но, на всякий случай, я его реализовал.
Свойство IsDisposed, доступное только из этого класса и его наследников указывает, выполнялась ли очистка этого экземпляра класса.
Класс ответа на заявку на производное разрешение.
Экземпляры этого класса, он называется DerivedLease, возвращаются методами подачи заявки объекта хранилища. От исходного класса ответа на заявку RateLimitLease, от которого он унаследован, класс DerivedLease отличается тем, что сохраняет внутри себя интерфейс хранилища, которое его выдало (хранилище-владелец), число разрешений в заявке и копию свойств - признак выдачи разрешения и метаданные - референсного разрешения.
Подробности
В экземпляре этого класса в поле _owner сохраняется ссылка на хранилище-владелец. В автоматическом свойстве PermitCount сохраняется число запрошенных разрешений в заявке, для которой этот экземпляр является ответом (доступ извне возможен только на чтение). В свойстве IsAcquired сохраняется признак, были ли выданы разрешения, а в поле _metadata - словарь всех метаданных из ответа на референсную заявку. Поле _need_release - признак необходимости вызова метода Release владельца при очистке данного экземпляра. В конструкторе этого объекта производится подписка на событие DisposedEvent, его обработчик - метод OwnerDisposeHandler, который сбрасывает признак необходимости вызова метода Release хранилища-владельца и вызывает очистку экземпляра.
Метод очистки объекта Dispose производит отписку от события DisposedEvent хранилища-владельца и, при наличии необходимости (_need_release==true), вызывает метод Release хранилища-владельца. Свойство признака выдачи разрешений IsAcquired первичного класса реализовано как автоматическое свойство, а методы работы с метаданными первичного класса работают со словарем _metadata этих метаданных.
Часть 2: использование ограничителя по внешнему объекту - ограничение числа активных сеансов в группе.
Вторая часть приложения посвящена иллюстрации того, как использовать абстрактные классы из первой части для решения конкретной задачи - ограничения веб-запросов, связанных с долгоживущими внешними объектами. Но прежде чем иллюстрировать, нужно иметь материал для иллюстрации - те самые долгоживущие внешние объекты, которые связаны с запросами. А с поиском таких объектов в самом фреймворке ASP.NET Core есть затруднения - грубо говоря, их нет.
Подробности
Долгоживущих объектов в той части ASP.NET Core, которая связана напрямую с обработкой запросов - выдачей в ответ на запросы веб-страниц (MVC controllers and views, Razor Pages) или же доступом через API (MVC API Controllers, Minimal API)- нет. Эта часть фреймворка по своей архитектуре рассчитана, в основном, на обработку несвязанных друг с другом запросов без создания в коде серверной части веб-приложения объектов, хранящих состояние между запросами. Сохранение состояния в сеансе ASP.NET Core (доступном через интерфейс ISession) тоже для примера не подходит: состояние сеанса между запросами хранится не в виде объекта в коде, а в сериализованном виде в распределенном кэше (в базе данных и т.п.), а объект с интерфейсом ISession создается на базе информации из кэша для каждого запроса заново. Так что в такой архитектуре связывать выданные разрешения не с чем и хранить их негде. В других частях фреймворка (например, в SignalR) долгоживущие объекты есть, но в тех частях совершенно не факт, что используемые в них сообщения напрямую связаны с отдельными веб-запросами: очень часто (например, в случае WebSocket) транспорт использует одиночные долгоживущие подключения к серверу для посылки множества сообщений и получении ответов на них, и ограничивать темп установления таких подключений смысла нет.
Так что сам по себе ASP.NET Core не дает материала для иллюстрации использования ограничителя по внешнему объекту.
При необходимости долгоживущие объекты и привязка запросов к ним в приложениях чаще реализуются специфическими для конкретного приложения средствами (например, фоновыми сервисами), но использовать столь не универсальные средства для иллюстрации мне бы не хотелось.
Поэтому в поисках материала для иллюстрации мне пришлось обратиться к своей библиотеке ActiveSession, которая как раз определяет пригодные для демонстрации долгоживущие объекты - активные сеансы и группы активных сеансов - для использования их в сторонних программах. И пример, который я хочу показать, демонстрирует ограничение числа используемых долгоживущих объектов - активных сеансов библиотеки ActiveSession, которые можно создать в рамках одного сеанса ASP.NET Core (того, который доступен через интерфейс ISession, далее в этой части я буду называть его пользовательским сеансом), которому в библиотеке соответствует группа активных сеансов. Но так как эта задача - специфическая для сторонней библиотеки ActiveSession и вводимых ею сущностей, то прежде чем изучать решение, лучше прочитать небольшое введение в эту библиотеку в скрытом тексте ниже. Но можно попробовать обойтись и без этого, а просто считать активные сеансы некими абстрактными долгоживущими объектами, связанными с веб-запросом: для понимания, что делает ограничитель по внешнему объекту (в рассматриваемом случае - активному сеансу), вообще-то, не требуется знать детали работы активных сеансов: достаточно понимать, что активные сеансы - это некие долгоживущие объекты, с которыми может быть связан один или несколько запросов к веб-серверу. То есть, что это - те самые внешние объекты, абстрактный базовый класс ограничителя по которым рассматривался в первой части, и этого будет достаточно для понимания техники использования этого абстрактного базового класса ограничителя.
Маленькое введение в библиотеку ActiveSession
Библиотека ActiveSession дает возможность, пока пользователь работает с веб-приложением в браузере, выполнять код на сервере в фоновом режиме. А результаты работы этого кода — и промежуточные, и окончательные — веб-приложение может потом получать через дополнительные запросы к серверу. Причем, этот код обрабатывает данные и выполняет операции, специфичные именно для этого конкретного экземпляра веб-приложения и работающего с ним конкретного пользователя: для разных пользователей, работающих с разными экземплярами, независимо выполняются разные, не связанные друг с другом экземпляры фоновой программы. Код, выполняющий фоновую операцию, используемые этим кодом общие данные и набор логически связанных веб-запросов, возвращающих результаты операции, называется активным сеансом. На сервере активный сеанс представлен объектом активного сеанса.
Для одного пользовательского сеанса в браузере может быть создано несколько активных сеансов. Все эти сеансы объединяются в одну группу активных сеансов. Группа активных сеансов на сервере также представлена как объект - объект группы активных сеансов.
Группа активных сеансов, к которой принадлежит запрос, определяется через значения, сохраненные в пользовательском сеансе. Активный сеанс, с которым связан конкретный запрос, определяется через аналогичные значения, хранящиеся в пользовательском сеансе, и другими параметрами этого запроса, такими как путь в URL - набор этих параметров может быть, вообще говоря, произвольным. В контексте запроса связь с конкретными активным сеансом и группой активных сеансов отражается как ссылки на объекты активных сеансов и групп активных сеансов, связанные с этим контекстом. Технически эта связь осуществляется с помощью механизма расширения контекста запроса, под названием функции (features). Набор этих расширений доступен через свойство Features контекста запроса. Но так как документация на ASP.NET Core особо не заостряет внимание на том, как пользоваться этим механизмом расширения, я счел полезным в библиотеке ActiveSession определить вспомогательные методы расширения для получения этих объектов - и активного сеанса, и группы активных сеансов - из контекста запроса. И в коде ограничителя по числу активных сеансов, рассматриваемом в этой части, для извлечения связанных с веб-запросом активного сеанса и группы активных сеансов использованы именно эти методы расширения.
Итак, задача, которая решается в этом примере - это ограничение числа используемых одновременно активных сеансов, которое можно использовать в рамках одной группы активных сеансов, или, что то же самое - одного пользовательского сеанса, то есть, грубо говоря - вкладок/окон с одной или несколькими страницами сайта, открытых в одном и том же браузере на одном и том же компьютере.
Класс из этого примера входит в состав библиотеки классов MVVRus.Extensions.ActiveSession.RateLimiting. В ближайшем будущем я планирую оформить эту библиотеку в качестве NuGet-пакета и добавить его в галерею (как сделаю - отмечу это в статье). Но пока это не сделано.
Ограничитель числа активных сеансов как пример использования базовых классов из первой части приложения путем наследования.
Сначала завершим рассмотрение начатой темы - того, как можно использовать базовый класс ограничителя по внешним объектам для решения конкретной задачи. Если вам эта тема не интересна, а вам просто нужен пример того, как именно использовать конкретный класс ограничителя числа активных сеансов библиотеки ActiveSession - переходите к следующему разделу.
В первой части приложения были указаны действия, которые следует выполнить при использовании описанных в той части классов для решения конкретной задачи из категории ограничения по внешним объектам. В этой же, второй, части приведены для примера этих действий выдержки из кода классов, решающих конкретную задачу из этой категории - ограничение числа одновременно используемых активных сеансов.
Итак, для адаптации абстрактных классов и интерфейсов из первой части к этой конкретной задаче необходимо сделать два действия.
Действие первое - создание класса хранилища, реализующего интерфейс IShareableLeaseOwner:
Исходный код класса хранилища ActiveSessionLeaseInfo
internal class ActiveSessionLeaseInfo : SingleShareableLeaseOwner<HttpContext> { public const String KEY = "{9A54776E-156B-470D-9431-C293E179B9EB}"; internal RateLimitLease? _lease; public Boolean WasLeaseEverRejected { get; private set; } = false; protected override void Dispose(Boolean disposing) { if(disposing) { RateLimitLease? lease = Interlocked.Exchange(ref _lease, null); lease?.Dispose(); } base.Dispose(disposing); } protected override Boolean TryGetLease(out RateLimitLease? lease) { lease = Volatile.Read(ref _lease); return lease != null; } protected override Boolean TrySetLease(ref RateLimitLease lease) { ArgumentNullException.ThrowIfNull(lease, nameof(lease)); if(!lease.IsAcquired) { WasLeaseEverRejected = true; return false; } return Interlocked.CompareExchange(ref _lease, lease, null)!=null; } }
Класс хранилища ActiveSessionLeaseInfo унаследован от описанного в первой части базового класса SingleShareableLeaseOwner, реализующего политику одного разрешения. Класс ActiveSessionLeaseInfo добавляет к классу SingleShareableLeaseOwner возможность сохранения ответа на заявку на референсное разрешение: определяет поле для хранения ответа на заявку и код для сохранения ответа и его извлечения из значения этого поля - методы TryGetLease и TrySetLease. Никаких других функций, кроме реализации хранилища, этот класс не выполняет, так что здесь его исходный код приведен целиком. Описание того, как устроен и работает этот класс, находится дальше, в соответствующем разделе.
Действие второе - создание класса селективного ограничителя по внешнему объекту ActiveSessionsPerGroupLimiter.
Фрагмент исходного кода класса ограничителя числа активных сеансов в группе
Здесь приведена только часть кода, необходимая для иллюстрации действий по использованию базового класса ограничителя по внешним объектам LinkedLeaseRateLimiter. Полный же код класса (в котором выполняются и другие функции) доступен на GitHub. А сигнатура (методы и важнейшие поля) вместе с описанием, как этот класс работает, приведены далее в статье в посвященном ему разделе.
public class ActiveSessionsPerGroupLimiter : LinkedLeaseRateLimiter<HttpContext, ILocalSession> { public ActiveSessionsPerGroupLimiter(ConcurrencyLimiterOptions options) : base(PartitionedRateLimiter.Create(PartitionerMaker(options), Comparer), true) { } protected override IShareableLeaseOwner<HttpContext>? GetLeaseStore(HttpContext context) { IActiveSession? active_session = context.GetActiveSession(); ActiveSessionLeaseInfo? lease_info = null; if(active_session == null || !active_session.IsAvailable) return null; if(!TryGetValue(out lease_info)) try { active_session.Properties.Add(ActiveSessionLeaseInfo.KEY, lease_info=new ActiveSessionLeaseInfo()); active_session.TakeOwnership(lease_info); } catch(ArgumentException) { lease_info?.Dispose(); if(!TryGetValue(out lease_info)) throw new InvalidOperationException($"{nameof(ActiveSessionsPerGroupLimiter)}: Unexpected error while processing the active session with Id={active_session.Id}"); } catch { lease_info?.Dispose(); throw; } return lease_info; Boolean TryGetValue(out ActiveSessionLeaseInfo? value) { Object? value_object; Boolean result = active_session.Properties.TryGetValue(ActiveSessionLeaseInfo.KEY, out value_object); value = value_object as ActiveSessionLeaseInfo; if(result && value==null) throw new InvalidOperationException($"{nameof(ActiveSessionsPerGroupLimiter)}: Null reference or non-{nameof(ActiveSessionLeaseInfo)} object found at the predefined lease info key {ActiveSessionLeaseInfo.KEY} in the Properties of the active session with Id={active_session.Id}"); return result; } } // Остальная часть класса ActiveSessionsPerGroupLimiter (здесь не рассматривается) // В ней определен и метод создания секционирующего делегата PartitionerMaker, и объект компаратора Comparer, // которые используются для создания референсного ограничителя,и ссылки на которые есть выше, при вызове конструктора базового класса. // ... }
Класс ограничителя числа активных сеансов в группе ActiveSessionsPerGroupLimiter унаследован от базового класса ограничителя по внешнему объекту LinkedLeaseRateLimiter. В приведенном фрагменте его кода показано действие, требуемое для того, чтобы можно было создать экземпляр этого класса, а именно - переопределение в классе абстрактного метода GetLeaseStore, определенного в базовом классе: этот метод получает экземпляр хранилища связанного с активным сеансом, к которому относится запрос, а если такой экземпляр ещё не создан - создает его и связывает с этим активным сеансом.
Как именно устроен и работает этот метод - описано далее, в разделе описания класса ограничителя числа активных сеансов в группе.
Если вкратце: ...
… рассматриваемый метод извлекает объект хранилища из словаря Properties объекта активного сеанса, если объект в словаре отсутствует - создает новый объект и сохраняет его в словаре, и этот сохраненный ранее или вновь созданный объект хранилища возвращается в вызывающий метод.
Пример использования ограничителя числа активных сеансов в группе в программе, использующей библиотеку ActiveSession.
Пример операторов верхнего уровня в файле Program.cs для использования библиотеки ActiveSession с ограничителем числа активных сеансов в группе.
//... var builder = WebApplication.CreateBuilder(args); //... Здесь производится добавление требуемых сервисов для ActiveSession и другие действия по настройке контейнера сервисов веб-приложения. builder.Services.AddRateLimiter(options => {/*... здесь производится установка параметров настройки функции ограничения запросов*/}); builder.Services.AddActiveSessionInfrastructure(); // или вызвать здесь другие методы, добавляющие исполнители (см. руководство по библиотеке ActiveSessions): // AddActiveSessions или методы расширения из класса StdRunnerServiceCollectionExtensions builder.Services.AddActiveSessionPerGroupLimiting(new ConcurrencyLimiterOptions{PermitLimit=10}); var app = builder.Build(); //... Здесь производится добавление требуемых компонентов-обработчиков для ActiveSession и другие действия по настройке конвейера веб-приложения. // Порядок добавления методов UseXXX для библиотеки ActiveSession и функции ограничения запросов должен быть именно таким, как ниже! app.UseActiveSessions(); app.UseActiveSessionPerGroupLimiting(); app.UseRateLimiter(); //... остальной код конфигурации конвейера и запуска веб-приложения
В этом примере библиотека ActiveSession и ограничитель числа активных сеансов настраиваются для использования на глобальном уровне и применяются ко всем запросам, обрабатываемым приложением.
В остальных разделах этой части содержится описание того, как устроен и работает класс ограничителя числа активных сеансов в группе и связанные с ним классы. Для того, чтобы просто использовать эти классы в своей программе, читать эти разделы не обязательно.
Класс ограничителя числа активных сеансов в группе.
Класс ограничителя числа активных сеансов в группе ActiveSessionsPerGroupLimiter унаследован от абстрактного обобщенного класса ограничителя по внешнему объекту, рассмотренного в первой части, с конкретно заданными параметрами-типами: типом ресурса - стандартным для ограничителей обработки запросов в ASP.NET Core контекстом запроса (HttpContext) и типом ключа секционирования - ссылкой на интерфейс объекта группы активных сеансов ILocalSession. Для получения возможности использовать абстрактный класс LinkedLeaseRateLimiter в качестве базового для рассматриваемого класса, необходимо передать в конструктор базового класса референсный ограничитель и реализовать абстрактных метод получения хранилища GetLeaseStore.
Референсный ограничитель, используемый в классе ограничителя числа активных сеансов в группе - это секционированный ограничитель, ключом секционирования которого является группа активных сеансов: это требуется, чтобы ограничение для каждой группы выполнялось независимо. Нужный ограничитель создается при вызове конструктора базового класса статическим методом PartitionedRateLimiter.Create с использованием секционирующего делегата, возвращаемого статическим методом ActiveSessionsPerGroupLimiter.PartitionMaker. Этот секционирующий делегат создает данные для секции, в которых делегат создания ограничителя секции возвращает ограничитель с управлением временем жизни ManagedLifetimeLimiter, базирующийся на ограничителе использования. Ограничитель с управлением временем жизни рассмотрен в приложении к следующей статье цикла) Параметр конструктора класса ActiveSessionsPerGroupLimiter - экземпляр класса параметров настройки ограничителя использования, на котором базируется создаваемый ограничитель секции с управлением временем жизни. В рассматриваемом классе ActiveSessionsPerGroupLimiter ограничитель с управлением временем жизни нужен, чтобы время жизни секции референсного ограничителя для конкретной группы активных сеансов - ключа этой секции - соответствовало времени жизни самой этой группы активных сеансов (то есть - чтобы секция удалялась из референсного ограничителя как можно быстрее после того, как эта группа перестала использоваться и ее объект был очищен ).
Метод GetLeaseStore прежде всего находит объект активного сеанса, связанный с запросом. Если нет объекта активного сеанса, связанного с запросом или этот объект помечен как недоступный, то метод GetLeaseStore возвращает null. В случае нахождения доступного активного сеанса этот метод находит в нем (в словаре в его свойстве Properties под предопределенным ключом ActiveSessionLeaseInfo.KEY) объект хранилища, а если такого объекта ещё нет - создает новый объект хранилища типа ActiveSessionLeaseInfo, помещает его в этот словарь под тем самым предопределенным ключом и настраивает очистку этого объекта хранилища по завершении активного сеанса. В качестве результата этот метод возвращает найденный или вновь созданный объект хранилища.
Подробности
Сигнатура класса ограничителя числа активных сеансов в группе ActiveSessionsPerGroupLimiter(ссылка на исходный код):
public class ActiveSessionsPerGroupLimiter : LinkedLeaseRateLimiter<HttpContext, ILocalSession> { public ActiveSessionsPerGroupLimiter(ConcurrencyLimiterOptions options) : base(PartitionedRateLimiter.Create(PartitionerMaker(options), Comparer), true) { } protected override IShareableLeaseOwner<HttpContext>? GetLeaseStore(HttpContext context); static Func<HttpContext, RateLimitPartition<ILocalSession>> PartitionerMaker(ConcurrencyLimiterOptions options); static void Registrar(RateLimiter limiter, ILocalSession sessionGroup); class SessionGroupComparer : IEqualityComparer<ILocalSession> { public Boolean Equals(ILocalSession? x, ILocalSession? y) { if(x is null) return (y is null); else return x.Id.Equals(y?.Id); } public Int32 GetHashCode([DisallowNull] ILocalSession obj) { return obj.GetType().GetHashCode() ^ obj.Id.GetHashCode(); } } static SessionGroupComparer Comparer = new SessionGroupComparer(); class DummmySessionGroup : ILocalSession { public String Id => "{416DFAB3-DF7A-4FB8-B294-73A9D813B869}"; public Boolean IsAvailable => false; public IServiceProvider SessionServices => throw new NotImplementedException(); public CancellationToken CompletionToken => throw new NotImplementedException(); public IDictionary<String, Object> Properties => throw new NotImplementedException(); } static DummmySessionGroup NullGroup = new DummmySessionGroup(); }
В статический метод PartitionedRateLimiter.Create при вызове его из конструктора ActiveSessionsPerGroupLimiter для создания референсного ограничителя передаются аргументы: секционирующий делегат, создаваемый статическим методом PartitionMaker, и компаратор для ключа секционирования - экземпляр вложенного класса SessionGroupComparer из статического поля ActiveSessionsPerGroupLimiter.Comparer. Параметр конструктора - данные для настройки ограничителя использования (значение класса ConcurrencyLimiterOptions), о котором говорилось выше - передается как аргумент в метод PartitionMaker.
Статический метод ActiveSessionsPerGroupLimiter.PartitionMaker создает и возвращает секционирующий делегат для создания референсного ограничителя, вызывающий определенную в нем локальную функцию Partitioner. В качестве параметра в этот метод передается значение класса параметров настройки ограничителя использования ConcurrencyLimiterOptions. Локальная функция Partitioner и создаваемый на ее основе секционирующий делегат возвращает данные для секции на основе переданного ему контекста запроса (HttpContext). В качестве значения ключа эта локальная функция использует доступную (значение свойства IsAvailable объекта группы равно true)группу активных сеансов для этого контекста запроса или, если запрос не связан с доступной группой сеансов - фиктивный объект группы из статическом поля ActiveSessionsPerGroupLimiter.NullGroup, свойство IsAvailable которого равно false. Далее, в зависимости от того, использована ли в качестве ключа реальная доступная группа активных сеансов или фиктивная, локальная функция возвращает данные для секции. Возвращаемые данные создаются одним из двух вспомогательных методов. Оба этих метода возвращают данные для секции с одним и тем же ключом (указанным), но с разными делегатами для создания ограничителя секции:
Если ключ - доступная группа сеансов (IsAvailable==true), то для создания данных для секции вызывается вспомогательный метод ManagedLifetimePartition.GetManagedLifetimeLimiter. Этот вспомогательный метод создает данные для секции, в которых делегат для создания ограничителя секции - ограничитель с управлением времени жизни (экземпляр класса ManagedLifetimeLimiter), базирующийся на ограничителе использования, который создает переданный в него через параметр делегат-фабрика. Этот вспомогательный метод и этот класс ограничителя подробно рассмотрены в приложении к следующей статье цикла. Если вкратце, то ограничитель с управлением времени жизни передает все заявки в подчиненный ограничитель, на котором он базируется, и возвращает все полученные от него ответы на переданные заявки. Единственное, что делает ограничитель с управляемым временем жизни сам - переопределяет возвращаемое время простоя (свойство IdleDuration) таким образом, чтобы очистка этого ограничителя приводила к как можно более быстрому удалению секции, для которой создан этот ограничитель; как это работает - см. описание этого ограничителя по ссылке чуть выше и описание того, как работает секционированный ограничитель, в соответствующем разделе этой статьи. Во вспомогательный метод ManagedLifetimePartition.GetManagedLifetimeLimiter передаются следующие аргументы:
ключ секции - в качестве аргумента передается интерфейс ассоциированной с контекстом запроса группы активных сеансов (она будет обязательно доступная);
делегат-фабрика, создающая подчиненный ограничитель; в данном случае используется та форма метода GetManagedLifetimeLimiter, в которой фабрика создает не сам подчиненный ограничитель, а данные для секции с использованием подчиненного ограничителя в качестве ограничителя секции; в данном случае в качестве фабрики передается делегат, который вызывает вспомогательный метод RateLimitPartition.GetConcurrencyLimiter (который, как было описано выше, создает данные для секции, с ограничителем использования в качестве ограничителя секции); в этот метод GetConcurrencyLimiter для создания ограничителя использования передаются (посредством ещё одного делегата) те параметры настройки, которые получил внешний по отношению к локальной функции Partitioner метод PartitionMaker через свой параметр;
делегат регистратора, - в качестве аргумента передается делегат метода Registrar, который связывает созданный ограничитель с объектом группы активных сеансов из первого параметра (этот метод будет описан чуть дальше).
Если ключ - фиктивная группа сеансов (IsAvailable==false), то для создания данных для секции вызывается вспомогательный метод RateLimitPartition.GetNoLimiter который в возвращаемые им данных для секции помещает делегат, создающий фиктивный ограничитель секции типа NoopLimiter, всегда возвращающий при подаче ему заявки ответ с выданным разрешением; таким образом, если запрос, не связанный ни с одной доступной группой активных сеансов, ограничению не подвергается: разрешение на его обработку выдается всегда.
Метод Registrar (точнее, его делегат) используется для связывания созданного ограничителя секции с управлением времени жизни с объектом группы активных сеансов-ключом для этой секции, при этом связь устанавливается таким образом, что при очистке объекта группы активных сеансов производится очистка связанного с ней ограничителя секции (что дальше приводит к быстрому удалению этой секции из секционированного ограничителя). Метод Registrar получает два параметра: первый, limiter - ограничитель секции с управлением временем жизни, второй, sessionGroup - ключ секции, объект группы активных сеансов, с которым связывается этот ограничитель. Связывание производится путем добавления ограничителя секции в словарь из свойства Properties объекта группы активных сеансов под предопределенным ключом SessionGroupASLimiterInfo.KEY и настройки выполнения очистки ограничителя при очистке этого объекта группы активных сеансов, делается это вызовом метода расширения TakeOwnership для объекта группы активных сеансов. Для исключения возможности возникновения гонки при регистрации, она производится при наложенной блокировке на объект группы активных сеансов.
Метод GetLeaseStore прежде всего находит вызовом метода расширения GetActiveSessionGroup для контекста запроса - значения класса HttpContext - объект активного сеанса, связанный с запросом. Затем он проверяет, что этот объект доступен (свойство IsAvailable==true). Если объект активного сеанса не найден или недоступен, этот метод возвращает null. В противном случае метод GetLeaseStore находит хранилище в объекте активного сеанса - в словаре из свойства Properties объекта активного сеанса под предопределенным ключом со значением ActiveSessionLeaseInfo.KEY. Если такого объекта ещё нет, метод GetLeaseStore создает новый объект хранилища типа ActiveSessionLeaseInfo?, сохраняет его в словаре под этим ключом и настраивает (полностью аналогично тому, как это делается выше для объекта ограничителя и группы сеансов) очистку объекта хранилища при очистке этого объекта активного сеанса. В случае, если добавление нового объекта хранилища привело к исключению, потому что другой объект был добавлен параллельным потоком раньше, метод GetLeaseStore очищает вновь созданный объект и возвращает этот, уже добавленный чуть ранее, объект. В случае возникновения исключения по другим причинам метод GetLeaseStore очищает вновь созданный объект хранилища и повторно вызывает это исключение. Таким образом, в качестве результата метод GetLeaseStore возвращает найденный или вновь созданный объект хранилища для доступного активного сеанса, которому принадлежит запрос, или null, если такой активный сеанс отсутствует.
Статическое поле Comparer содержит экземпляр класса компаратора для объектов групп активных сеансов - вложенного класса SessionGroupComparer: он реализует интерфейс IEqualityComparer<ILocalSession>. Объекты групп активных сеансов сравниваются по значению их свойства Id (это строка), хэш-код этого же свойства является хэш-кодом объекта группы активных сеансов.
Статическое поле NullGroup содержит экземпляр вложенного класса DummmySessionGroup, являющегося фиктивной реализацией интерфейса ILocalSession: из состава интерфейса реализуется только свойства Id (оно возвращает строковое преставление уникального UUID) и IsAvailable (оно возвращает false), обращение к другим свойствам и методам интерфейса вызывает исключение NotImplementedException.
Класс хранилища.
Класс хранилища для референсного разрешения ActiveSessionLeaseInfo, экземпляр которого возвращает метод GetLeaseStore класса ActiveSessionsPerGroupLimiter, унаследован от рассмотренного в предыдущей части обобщенного класса SingleShareableLeaseOwner<HttpContext>, с параметром-типом ресурса - типом контекста запроса. Рассматриваемый класс хранилища использует политику управления разрешениями базового класса - “политику одного разрешения”: для каждого активного сеанса пытается получить ровно одно референсное разрешение и, при наличии такого разрешения - выдает сколько угодно разрешений на выполнение запросов в рамках этого активного сеанса. Если ответ на заявку на получение референсного разрешения содержит отказ, то производное разрешение не выдается: метод подачи заявки в этом случае возвращает ответ с отказом.
Для получения возможности создания экземпляров класс ActiveSessionLeaseInfo перекрывает абстрактные методы Boolean TryGetLease(out RateLimitLease? lease) и Boolean TrySetLease(ref RateLimitLease lease). Помимо этого, класс ActiveSessionLeaseInfo перекрывает виртуальный метод void Dispose(Boolean) шаблона очистки и содержит свойство WasLeaseEverRejected, описанное ниже - это свойство используется для скорейшего завершения компонентом-обработчиком ActiveSessionLimitingMiddleware активного сеанса, разрешение на использование которого не было получено.
Класс ActiveSessionLeaseInfo содержит внутреннее, доступное только в классах библиотеки поле _lease, которое может содержать запомненный в этом классе ответ с выданным разрешением. Ответ с отклоненным разрешением в этом классе запомнен быть не может, это гарантируется методом TrySetLease.
Метод TryGetLease копирует значение поля _lease (это значение читается атомарной операцией) в свой выходной параметр и возвращает значение true если это значение - не пустое(null), иначе - false.
Метод TrySetLease для начала проверяет, что его полученный по ссылке параметр содержит ответ с выданным разрешением. Если это не так, то он не пытается копировать переданный в него ответ в поле _lease, но устанавливает свойство WasLeaseEverRejected в значение true и возвращает false. Таким образом гарантируется, что запомнен может быть только ответ с выданным разрешением. Если от референсного ограничителя получен ответ с разрешением, и значение поле _lease - пустое, то в это поле записывается значение ответа на заявку с полученным разрешением, переданное по ссылке через параметр. Иначе (то есть, если в этом поле уже было запомнено ранее выданное разрешение) переданное по ссылке в параметре значение заменяется этим, запомненным ранее, значением.
Подробности
Исходный код класса ActiveSessionLeaseInfo - приведен выше
Конфигурирование, вспомогательные классы для конфигурирования.
Для полноты изложения напишу в этом разделе про пару классов, используемых для интеграции обсуждаемого класса ограничителя активных сеансов в приложение ASP.NET Core.Если вы не собираетесь использовать ограничитель активных сеансов в своем приложении, то текст этого раздела можно не читать: с темой ограничения запросов он практически не связан.
Первый класс - это класс компонента-обработчика (middleware) для принудительного завершения активных сеансов, который устанавливается в конвейер веб-приложения и принудительно завершает активные сеансы, запрос на референсное разрешение для которых был отклонен.
Подробности
Исходный код класса компонента-обработчика ActiveSessionLimitingMiddleware:
public class ActiveSessionLimitingMiddleware { RequestDelegate _next; public ActiveSessionLimitingMiddleware(RequestDelegate next) { _next=next; } public async Task Invoke(HttpContext context) { await _next(context); IActiveSession? active_session = context.GetActiveSession(); if (active_session != null && active_session.IsAvailable) { ActiveSessionLeaseInfo? lease_info = active_session.Properties[ActiveSessionLeaseInfo.KEY] as ActiveSessionLeaseInfo; if(lease_info!= null) { if(Volatile.Read(ref lease_info._lease)==null && lease_info.WasLeaseEverRejected) await active_session.Terminate(context); } } } }
Метод Invoke компонента-обработчика (он вызывается при обработке конвейера) сразу вызывает следующий обработчик в конвейере, а после его завершения проверяет, что с запросом ассоциирован доступный объект активного сеанса, в котором не запомнен ответ на запрос на разрешение (который может быть только ответом с полученным разрешением), и установлен флаг WasLeaseEverRejected, означающий, что запрос разрешения производился, но был отклонен. Если все эти условия выполняются, то компонент-обработчик завершает активный сеанс вызовом его метода Terminate.
Второй класс - это статический класс, содержащий методы расширения, облегчающие настройку веб-приложения на использование ограничителя активных сеансов. В этом классе определены два метода расширения.
Первый метод расширения, UseActiveSessionPerGroupLimiting, добавляет в конвейер приложения обработчик принудительного завершения активных сеансов класса ActiveSessionLimitingMiddleware, который описан выше. Этот метод следует добавлять в коде конфигурирования конвейера приложения после компонента-обработчика инфраструктуры активных сеансов UseActiveSessions но до метода добавления компонента обработчика ограничения запросов ASP.NET Core UseRateLimiter - так, как это показано в примере в начале 2-й части. Второй метод расширения, AddActiveSessionPerGroupLimiting, добавляет в параметры настройки функции ограничения запросов ASP.NET Core, извлекаемые из контейнера сервисов, код, делающий ограничитель активных сеансов глобальным ограничителем или (если другой глобальный ограничитель уже был добавлен) частью сцепленного глобального ограничителя вместе с ранее добавленным. Этот метод можно добавлять в коде конфигурирования контейнера сервисов как до, так и после вызова метода AddRateLimiter. Второй параметр этого метода - экземпляр класса настроек ограничителя использования (ConcyrencyLimiter), эти настройки будут применяться ко всем ограничителям секций, которые будут созданы отдельно для каждой группы активных сеансов.
Подробности
Этот статический класс называется ActiveSessionLimitingExtensions.Его исходный код приведен ниже:
public static class ActiveSessionLimitingExtensions { public static IApplicationBuilder UseActiveSessionPerGroupLimiting(this IApplicationBuilder app) { app.UseMiddleware<ActiveSessionLimitingMiddleware>(); return app; } public static IServiceCollection AddActiveSessionPerGroupLimiting(this IServiceCollection services, ConcurrencyLimiterOptions limiterOptions) { services.PostConfigure<RateLimiterOptions>(AddActiveSessionPerGroupLimiter); return services; void AddActiveSessionPerGroupLimiter(RateLimiterOptions options) { ActiveSessionsPerGroupLimiter limiter = new ActiveSessionsPerGroupLimiter(limiterOptions); options.GlobalLimiter = options.GlobalLimiter==null ? limiter : PartitionedRateLimiter.CreateChained(options.GlobalLimiter, limiter); } } }
На этом описание примера создания и использования селективного ограничителя по внешнему объекту заканчивается.
