Рейт-лимитинг ваших Symfony API
В процессе разработке у вас может возникнуть необходимость наложить на ваши API какой-нибудь кастомный рейт-лимит (т.е. ограничить количество запросов для пользователей вашего API). В этой статье я покажу вам, как можно объединить компонент symfony/rate-limiter со стандартными контроллерами.
Рейт-лимит конфигурация
Наша конечная цель заключается в том, чтобы следующая рейт-лимит конфигурация работала на любом маршруте, на котором вы захотите, - благодаря атрибутам PHP8:
framework:
rate_limiter:
account_create:
policy: 'fixed_window'
limit: 5
interval: '60 minutes'
account_modify: # активация аккаунта, редактирование профиля
policy: 'fixed_window'
limit: 30
interval: '60 minutes'
В этой статье опущен разбор самого компонента, поэтому я рекомендую вам прочитать документацию Symfony по RateLimiter, если вы хотите разобраться, как он работает, и как его настраивать.
Атрибут
Прежде всего, нам нужен атрибут, который мы будем использовать в объявлении маршрутов, количество запросов по которым должно быть ограничено. Здесь нам дополнительно потребуется ключ конфигурации ($configuration), чтобы определить, какую именно рейт-лимит конфигурацию мы собираемся применить:
#[Attribute(Attribute::TARGET_METHOD)]
class RateLimiting
{
public function __construct(
public string $configuration,
) {
}
}
Контроллер
Теперь давайте применим наш атрибут к каком-нибудь контроллеру:
#[RateLimiting('account_create')]
#[Route('/create', methods: ['POST'])]
public function createAccount(): JsonResponse
{
// логика вашего контроллера ...
}
И это все, что нам нужно сделать, чтобы применить рейт-лимит к маршруту.
CompilerPass
Но для того, чтобы это заработало, нам нужно заставить Symfony понимать эти атрибуты. То есть нам нужен CompilerPass
для хранения всех маршрутов с нашим атрибутом, чтобы избежать рефлексии в рантайме:
class RateLimitingPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
if (!$container->hasDefinition(ApplyRateLimitingListener::class)) {
throw new \LogicException(sprintf('Can not configure non-existent service %s', ApplyRateLimitingListener::class));
}
$taggedServices = $container->findTaggedServiceIds('controller.service_arguments');
/** @var Definition[] $serviceDefinitions */
$serviceDefinitions = array_map(fn (string $id) => $container->getDefinition($id), array_keys($taggedServices));
$rateLimiterClassMap = [];
foreach ($serviceDefinitions as $serviceDefinition) {
$controllerClass = $serviceDefinition->getClass();
$reflClass = $container->getReflectionClass($controllerClass);
foreach ($reflClass->getMethods(\ReflectionMethod::IS_PUBLIC | ~\ReflectionMethod::IS_STATIC) as $reflMethod) {
$attributes = $reflMethod->getAttributes(RateLimiting::class);
if (\count($attributes) > 0) {
[$attribute] = $attributes;
$serviceKey = sprintf('limiter.%s', $attribute->newInstance()->configuration);
if (!$container->hasDefinition($serviceKey)) {
throw new \RuntimeException(sprintf(‘Service %s not found’, $serviceKey));
}
$classMapKey = sprintf('%s::%s', $serviceDefinition->getClass(), $reflMethod->getName());
$rateLimiterClassMap[$classMapKey] = $container->getDefinition($serviceKey);
}
}
}
$container->getDefinition(ApplyRateLimitingListener::class)->setArgument('$rateLimiterClassMap', $rateLimiterClassMap);
}
}
Здесь мы получаем все контроллеры и проверяем для каждого метода, есть ли у них наш атрибут, после чего связываем маршрут с соответствующей службой ограничения количества запросов и добавляем его в наш кэш.
Слушатель
Теперь, когда Symfony понимает наш атрибут и кэширует его, нам понадобится слушатель событий, чтобы подключиться к событию kernel.controller
и проверить, в порядке ли наш рейт-лимит или нет.
class ApplyRateLimitingListener implements EventSubscriberInterface
{
public function __construct(
private TokenStorageInterface $tokenStorage,
/** @var RateLimiterFactory[] */
private array $rateLimiterClassMap,
private bool $isRateLimiterEnabled,
private RequestStack $requestStack,
private RoleHierarchyInterface $roleHierarchy,
) {
}
public function onKernelController(KernelEvent $event): void
{
if (!$this->isRateLimiterEnabled || !$event->isMainRequest()) {
return;
}
$request = $event->getRequest();
/** @var string $controllerClass */
$controllerClass = $request->attributes->get('_controller');
$rateLimiter = $this->rateLimiterClassMap[$controllerClass] ?? null;
if (null === $rateLimiter) {
return; // этому контроллеру не назначена служба ограничения количества запросов
}
$token = $this->tokenStorage->getToken();
if ($token instanceof TokenInterface && in_array('ROLE_GLOBAL_MODERATOR', $this->roleHierarchy->getReachableRoleNames(($token->getRoleNames())))) {
return; // игнорируем ограничение количества запросов для модератора сайта и привилегированных ролей
}
$this->ensureRateLimiting($request, $rateLimiter, $request->getClientIp());
}
private function ensureRateLimiting(Request $request, RateLimiterFactory $rateLimiter, string $clientIp): void
{
$limit = $rateLimiter->create(sprintf('rate_limit_ip_%s', $clientIp))->consume();
$request->attributes->set('rate_limit', $limit);
$limit->ensureAccepted();
$user = $this->tokenStorage->getToken()?->getUser();
if ($user instanceof User) {
$limit = $rateLimiter->create(sprintf('rate_limit_user_%s', $user->getId()))->consume();
$request->attributes->set('rate_limit', $limit);
$limit->ensureAccepted();
}
}
public static function getSubscribedEvents(): array
{
return [KernelEvents::CONTROLLER => ['onKernelController', 1024]];
}
}
В этом примере я решил игнорировать ограничения количества запросов для наших глобальных модераторских ролей. Для всех остальных пользователей я проверяю рейт-лимит на двух уровнях: IP, а затем User, если они залогинены. Таким образом мы можем избежать рассылки спама пользователями с разных IP-адресов. Мне нравится использовать такие бизнес-правила, но вы можете настроить все по своему усмотрению.
Также вы можете заметить, что мы указываем службу ограничения количества запросов перед каждой проверкой: если у нас будет превышение рейт-лимита, будет выброшено исключение (благодаря методу ensureAccepted
), и вторая проверка не произойдет, у нас будет указана правильная служба ограничения количества запросов.
Заголовки
Наконец, чтобы получишь больше информации от службы ограничения количества запросов, мы можем сгенерировать несколько заголовков, чтобы указать, как прошел рейт-лимитинг, и какие-нибудь другие показатели:
final class RateLimitingResponseHeadersListener
{
public function onKernelResponse(ResponseEvent $event): void
{
if (($rateLimit = $event->getRequest()->attributes->get('rate_limit')) instanceof RateLimit) {
$event->getResponse()->headers->add([
'RateLimit-Remaining' => $rateLimit->getRemainingTokens(),
'RateLimit-Reset' => time() - $rateLimit->getRetryAfter()->getTimestamp(),
'RateLimit-Limit' => $rateLimit->getLimit(),
]);
}
}
}
Я взял имена заголовков из RFC заголовков RateLimit. Хоть это все еще черновик, но эти заголовки уже широко используются.
Вот и все - с помощью всего нескольких строк кода вы можете реализовать рейт-лимит для любого маршрута, просто добавив свой новый атрибут RateLimiting
!
Материал подготовлен в рамках курса «Symfony Framework».
Всех желающих приглашаем на бесплатное demo-занятие «Инвалидация кэша в распределённой системе». На demo-уроке будем заниматься по следующему плану:
1. Поднимаем инстанс хранилища + 4 инстанса раздающего API в докере
2. В хранилище заливаем картинку
3. С раздающего API получаем её и кэшируем в инстансе (обсудим, зачем мы должны ее кэшировать)
4. Дальше удаляем картинку в хранилище.
5. Показываем, что раздающее API продолжает её получать
6. Исправляем флоу, добавляя producer/consumer с оповещением об удалении.
7. Проверяем, что теперь всё работает ok.Регистрация на занятие здесь.