
В процессе разработке у вас может возникнуть необходимость наложить на ваши 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/
Добавить комментарий