zenstruck / governator
Requires
- php: >=7.4
Requires (Dev)
- phpunit/phpunit: ^7.5
- predis/predis: ^1.0
- psr/cache: ^1.0
- psr/simple-cache: ^1.0
- symfony/cache: >=3.4.0
- symfony/phpunit-bridge: ^5.1
Suggests
- ext-redis: To use the Redis store
- predis/predis: To use the Redis store without ext-redis
- psr/cache: To use the PSR-6 store
- psr/simple-cache: To use the PSR-16 store
This package is auto-updated.
Last update: 2020-11-12 12:55:46 UTC
README
一个通用的 固定窗口 速率限制节流,具有直观且流畅的API,支持多种后端。
use Zenstruck\Governator\ThrottleFactory; use Zenstruck\Governator\Exception\QuotaExceeded; try { $quota = ThrottleFactory::for('redis://localhost') // create using the redis backend ->throttle('something') // the "resource" to throttle ->allow(10) // the number of "hits" allowed in the "window" ->every(60) // the duration of the "window" ->acquire() // "acquire" a lock on the throttle, increasing it's "hit" count ; $quota->hits(); // 1 $quota->remaining(); // 9 $quota->resetsIn(); // 60 (seconds) $quota->resetsAt(); // \DateTimeInterface (+60 seconds) } catch (QuotaExceeded $e) { // The lock could not be "acquired" $e->resetsIn(); // 50 (seconds) $e->resetsAt(); // \DateTimeInterface (+50 seconds) $e->hits(); // 11 $e->remaining(); // 0 }
文档
安装
$ composer require zenstruck/governator
使用
有几种不同的 速率限制节流策略。此库使用 固定窗口 策略。窗口由 限制 和 持续时间 定义。在 持续时间 内跟踪 击中。如果在 持续时间 内击中超过 限制,则被拒绝(抛出异常)。
节流有以下部分
- 存储:这是 持久化 节流的后端(见 可用存储)。
- 资源:您希望节流的资源的
string
表示。通常,一个名称以及用于区分每个用户的不同的 节流,如请求 IP/用户名(例如login-$ip-$username
)。 - 限制:在 窗口 内允许的 击中 数量。
- TTL:窗口的持续时间(以秒为单位)。
- 配额:节流的当前 状态。
从 ThrottleFactory
创建节流。让我们为资源 "something" 创建一个允许每 10 秒 5 次击中的节流
use Zenstruck\Governator\Store; use Zenstruck\Governator\ThrottleFactory; /** @var Store $store */ $throttle = (new ThrottleFactory($store))->create('something', 5, 10); // instance of Zenstruck\Governator\Throttle
击中节流返回一个包含关于节流当前状态的详细信息的 Quota
对象
use Zenstruck\Governator\Throttle; /** @var Throttle $throttle */ $quota = $throttle->hit(); // instance of Zenstruck\Governator\Quota $quota->hits(); // 1 $quota->remaining(); // 4 $quota->resetsIn(); // 10 (seconds) $quota->resetsAt(); // \DateTimeInterface (+10 seconds) $quota->hasBeenExceeded(); // false sleep(3); $quota = $throttle->hit(); $quota->hits(); // 2 $quota->remaining(); // 3 $quota->resetsIn(); // 7 (seconds) $quota->resetsAt(); // \DateTimeInterface (+7 seconds)
如果击中节流在 窗口 内超过 限制,则仍然返回一个 Quota
。如果超过,则抛出 QuotaExceeded
异常
use Zenstruck\Governator\Exception\QuotaExceeded; use Zenstruck\Governator\Throttle; /** @var Throttle $throttle */ try { $throttle->hit()->check(); // instance of Quota $throttle->hit()->check(); // instance of Quota $throttle->hit()->check(); // instance of Quota // Continuing from the example above, this "hit" throws the exception as it will cause the limit of // 5 to be exceeded (within the 10 second window). $throttle->hit()->check(); } catch (QuotaExceeded $e) { $e->resetsIn(); // 7 (seconds) $e->resetsAt(); // \DateTimeInterface (+7 seconds) $e->hits(); // 6 $e->remaining(); // 0 }
您可以使用 ->aquire()
方法始终抛出 QuotaExceeded
异常而不调用检查
use Zenstruck\Governator\Throttle; /** @var Throttle $throttle */ // Continuing from our example above, this will throw a QuotaExceeded exception $throttle->acquire(); // equivalent to $throttle->hit()->check()
->acquire()
方法可以可选地接受一个 阻塞 参数,单位为秒。如果击中节流时超过,这是在等待节流重置时最大 阻塞 的秒数。如果重置时间大于传递的秒数,则不会发生阻塞,并立即抛出 QuotaExceeded
异常。
use Zenstruck\Governator\Throttle; /** @var Throttle $throttle */ // Continuing from our example above, this will throw a QuotaExceeded exception immediately // because the passed block for time is less than the window's TTL of 7 seconds. $throttle->acquire(5); // throws QuotaExceeded exception // This will block the process for 7 seconds (the time until the throttle resets) before // returning a Quota object. $throttle->acquire(10); // returns Quota (no exception)
您可以使用 ->status()
方法获取节流的状态(而不增加其 "击中")。
use Zenstruck\Governator\Throttle; /** @var Throttle $throttle */ $quota = $throttle->status(); // assumes the throttle is empty $quota->hits(); // 0 $throttle->hit(); $throttle->hit(); $throttle->status()->hits(); // 2
可以提前重置节流
use Zenstruck\Governator\Throttle; /** @var Throttle $throttle */ $throttle->reset();
流畅的节流构建器
节流还可以通过流畅接口创建
use Zenstruck\Governator\ThrottleFactory; /** @var ThrottleFactory $factory */ $factory->throttle('something')->allow(5)->every(10)->create(); // instance of Zenstruck\Governator\ThrottleFactory // easily build resources $factory->throttle('a', 'b')->with('c', 'd')->allow(5)->every(10)->create(); // resource = "abcd" // call throttle methods directly $factory->throttle('something')->allow(5)->every(10)->hit(); $factory->throttle('something')->allow(5)->every(10)->acquire(); $factory->throttle('something')->allow(5)->every(10)->acquire(10); $factory->throttle('something')->allow(5)->every(10)->status(); $factory->throttle('something')->allow(5)->every(10)->reset();
节流工厂 工厂
可以使用::for()
命名构造函数创建节流工厂。你可以传递一个对象或字符串(DSN),如果支持,它将根据这个创建工厂。
use Zenstruck\Governator\ThrottleFactory; $factory = ThrottleFactory::for($objectOrDsn);
请参阅下方的可用存储部分,了解可用的对象/DSN。
资源前缀
你可以自定义所有节流资源的前缀(默认为throtte_
)。前缀可以防止与使用相同后端的其他服务发生冲突。它还有助于审计你存储的后端。
use Zenstruck\Governator\Store; use Zenstruck\Governator\ThrottleFactory; /** @var Store $store */ $factory = new ThrottleFactory($store, 'my-prefix_'); // alternative $factory = ThrottleFactory::for($objectOrDsn, 'my-prefix-');
可用存储
Psr6 Cache Store
use Zenstruck\Governator\Store\Psr6CacheStore; use Zenstruck\Governator\ThrottleFactory; /** @var \Psr\Cache\CacheItemPoolInterface $cache */ $factory = new ThrottleFactory(new Psr6CacheStore($cache)); // alternative $factory = ThrottleFactory::for($cache);
Psr16 Cache Store
use Zenstruck\Governator\Store\Psr16CacheStore; use Zenstruck\Governator\ThrottleFactory; /** @var \Psr\SimpleCache\CacheInterface $cache */ $factory = new ThrottleFactory(new Psr16CacheStore($cache)); // alternative $factory = ThrottleFactory::for($cache);
Redis Store
use Zenstruck\Governator\Store\RedisStore; use Zenstruck\Governator\ThrottleFactory; /** @var \Redis|\RedisArray|\RedisCluser|Predis\ClientInterface $redis */ $factory = new ThrottleFactory(new RedisStore($redis)); // alternatives $factory = ThrottleFactory::for($redis); // this requires symfony/cache - see: https://symfony.ac.cn/doc/current/components/cache/adapters/redis_adapter.html#configure-the-connection // for all DSN options $factory = ThrottleFactory::for('redis://localhost');
Memory Store
此存储在内存中维护节流,并在当前PHP进程结束时重置。
use Zenstruck\Governator\Store\MemoryStore; use Zenstruck\Governator\ThrottleFactory; $factory = new ThrottleFactory(new MemoryStore()); // alternative $factory = ThrottleFactory::for('memory');
无限存储
此存储永远不会允许节流超出其配额,无论节流是如何创建的(对测试很有用)。
use Zenstruck\Governator\Store\UnlimitedStore; use Zenstruck\Governator\ThrottleFactory; $factory = new ThrottleFactory(new UnlimitedStore()); // alternative $factory = ThrottleFactory::for('unlimited');
食谱
Symfony集成
要在Symfony中使用Governator,将ThrottleFactory
注册为服务
# config/services.yaml # create with a PSR-6 Store Zenstruck\Governator\ThrottleFactory: arguments: ['@cache.app'] factory: [Zenstruck\Governator\ThrottleFactory, 'for'] # create with a redis store Zenstruck\Governator\ThrottleFactory: arguments: ['@my_redis_client'] # \Redis|\RedisArray|\RedisCluser|Predis\ClientInterface (registered elsewhere) factory: [Zenstruck\Governator\ThrottleFactory, 'for'] # create with a redis DSN (see https://symfony.ac.cn/doc/current/components/cache/adapters/redis_adapter.html#configure-the-connection) Zenstruck\Governator\ThrottleFactory: arguments: ['%env(REDIS_THROTTLE_DSN)%'] # REDIS_THROTTLE_DSN=redis://localhost (in your .env) factory: [Zenstruck\Governator\ThrottleFactory, 'for'] # customize the prefix Zenstruck\Governator\ThrottleFactory: arguments: ['@cache.app', 'my-prefix-'] factory: [Zenstruck\Governator\ThrottleFactory, 'for']
节流控制器
一个场景是对特定的控制器进行速率限制(假设ThrottleFactory
已被注册为服务)
use Symfony\Component\HttpFoundation\Request; use Zenstruck\Governator\ThrottleFactory; /** * @Route("/page", name="page") */ public function index(Request $request, ThrottleFactory $factory) { // only allow 5 requests every 10 seconds per IP $factory->throttle('page', $request->getClientIp())->allow(5)->every(10)->acquire(); // the above line with throw a QuotaExceeded exception if the limit has been exceeded // ...your controller's code as normal... }
如果你的控制器需要身份验证,则基于用户名进行速率限制
use Symfony\Component\Security\Core\User\UserInterface; use Zenstruck\Governator\ThrottleFactory; /** * @Route("/page", name="page") */ public function index(UserInterface $user, ThrottleFactory $factory) { // only allow 5 requests every 10 seconds per username $factory->throttle('page', $user->getUsername())->allow(5)->every(10)->acquire(); // the above line with throw a QuotaExceeded exception if the limit has been exceeded // ...your controller's code as normal... }
结合上述两点(如果你的控制器允许匿名和认证用户)
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Security\Core\User\UserInterface; use Zenstruck\Governator\ThrottleFactory; /** * @Route("/page", name="page") */ public function index(Request $request, ThrottleFactory $factory, UserInterface $user = null) { if ($user) { // authenticated // allow 100 requests every 60 seconds per username (authenticated users have a higher rate limit) $factory->throttle('page', $user->getUsername())->allow(100)->every(60)->acquire(); } else { // anonymous // allow 5 requests every 10 seconds per IP $factory->throttle('page', $request->getClientIp())->allow(5)->every(10)->acquire(); } // ...your controller's code as normal... }
具有多个配额的更高级的设置
use Symfony\Component\Security\Core\User\UserInterface; use Zenstruck\Governator\ThrottleFactory; /** * @Route("/page", name="page") */ public function index(UserInterface $user, ThrottleFactory $factory) { // only allow 10 requests every 10 seconds $factory->throttle('page', 'short', $user->getUsername())->allow(10)->every(10)->acquire(); // additionally, only allow 20 requests every 60 seconds $factory->throttle('page', 'long', $user->getUsername())->allow(20)->every(60)->acquire(); // ...your controller's code as normal... }
配额超过异常订阅者
当Web请求抛出QuotaExceeded
异常时,Symfony将其转换为500状态码。我们可以创建一个异常订阅者来更改此行为
// src/EventSubscriber/QuotaExceededSubscriber.php namespace App\EventSubscriber; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpKernel\Event\ExceptionEvent; use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException; use Zenstruck\Governator\Exception\QuotaExceeded; class QuotaExceededSubscriber implements EventSubscriberInterface { public function onKernelException(ExceptionEvent $event): void { $exception = $event->getThrowable(); if (!$exception instanceof QuotaExceeded) { return; } // convert QuotaExceeded exception to TooManyRequestsHttpException (429 status code) // and add some helpful response headers for the user $event->setThrowable(new TooManyRequestsHttpException($exception->resetsIn(), null, $exception, 0, [ 'X-RateLimit-Limit' => $exception->limit(), 'X-RateLimit-Remaining' => $exception->remaining(), 'X-RateLimit-Reset' => $exception->resetsAt()->getTimestamp(), ])); } public static function getSubscribedEvents(): array { return [ 'kernel.exception' => 'onKernelException', ]; } }
你可以选择自定义生产错误页面。
登录节流
对于Web应用来说,限制登录尝试以防止滥用是一个常见的需求。Governator在这方面可以帮助几个方面。在这个例子中,我们将限制你Guard Authenticator的getCredentials()
方法中的尝试
// your Symfony\Component\Security\Guard\AuthenticatorInterface // assumes ThrottleFactory was injected to the service and is available as $this->throttleFactory public function getCredentials(Request $request) { $credentials = [ 'email' => $request->request->get('email'), 'password' => $request->request->get('password'), 'csrf_token' => $request->request->get('_csrf_token'), ]; // only allow 5 attempts per email/ip a minute $this->throttleFactory ->throttle('login', $request->getClientIp(), $credentials['email']) ->allow(5) ->every(60) ->acquire() // throws QuotaExceeded if exceeded ; // ... }
API请求节流
在这个例子中,我们将限制你站点的整个部分(/api/*
)。此外,我们还将提供有关用户当前配额状态的响应头信息,供消费者使用
// src/App/EventSubscriber/ApiThrottleSubscriber.php namespace App\EventSubscriber; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\HttpKernel\Event\ResponseEvent; use Zenstruck\Governator\Quota; use Zenstruck\Governator\ThrottleFactory; use function Symfony\Component\String\s; class ApiThrottleSubscriber implements EventSubscriberInterface { private ThrottleFactory $factory; private ?Quota $quota = null; public function __construct(ThrottleFactory $factory) { $this->factory = $factory; } public function onKernelRequest(RequestEvent $event): void { if (!$event->isMasterRequest()) { // not a "master" request return; } if (!s($event->getRequest()->getPathInfo())->startsWith('/api')) { // not an api request return; } $ip = $event->getRequest()->getClientIp(); // only allow 5 api requests every 20 seconds // and set the returned quota for use in the response listener below // let QuotaExceeded exceptions bubble up (to the QuotaExceededSubscriber above) $this->quota = $this->factory->throttle('api', $ip)->allow(5)->every(20)->acquire(); } public function onKernelResponse(ResponseEvent $event): void { if (!$this->quota) { // quota was not set for this request return; } // add helpful response headers for the consumer $event->getResponse()->headers->add([ 'X-RateLimit-Limit' => $this->quota->limit(), 'X-RateLimit-Remaining' => $this->quota->remaining(), 'X-RateLimit-Reset' => $this->quota->resetsAt()->getTimestamp(), ]); $this->quota = null; } public static function getSubscribedEvents(): array { return [ 'kernel.request' => 'onKernelRequest', 'kernel.response' => 'onKernelResponse', ]; } }
消息处理器“漏斗”节流
想象一个场景,当用户登录时,您想使用第三方服务对用户的IP进行地理编码。您可以使用Symfony Messenger来处理异步的Login
事件。问题是,这个服务每5秒钟只允许1个请求,您可以使用Governator来限制这些请求,使其符合服务的速率限制。超过这个限制的事件将被重新排队
// src/App/MessageHandler/GeocodeLoginHandler.php namespace App\MessageHandler; use App\Message\Login; use Symfony\Component\Messenger\Handler\MessageHandlerInterface; use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Messenger\Stamp\DelayStamp; use Zenstruck\Governator\Exception\QuotaExceeded; use Zenstruck\Governator\ThrottleFactory; final class GeocodeIpHandler implements MessageHandlerInterface { private ThrottleFactory $throttleFactory; private MessageBusInterface $bus; public function __construct(ThrottleFactory $throttleFactory, MessageBusInterface $bus) { $this->throttleFactory = $throttleFactory; $this->bus = $bus; } public function __invoke(Login $message): void { try { // only allow this job to be executed once every 5 seconds $this->throttleFactory ->throttle('geocoding-service') ->allow(1) ->every(5) ->acquire(2) // block for up to 2 seconds to wait for throttle to be available ; } catch (QuotaExceeded $e) { // rate limit of service exceeded // re-queue with delay $this->bus->dispatch($message, [new DelayStamp($e->resetsIn() * 1000)]); return; } // the geocoding service is available, do something... } }
因为处理器在后台运行,我们会阻塞最多2秒的速率限制命中,等待服务变得可用。
致谢
这个库API的灵感来自Laravel和Symfony的锁组件。