Рейт-лимитинг ваших 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.

Регистрация на занятие здесь.


ссылка на оригинал статьи https://habr.com/ru/company/otus/blog/645261/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *