zenstruck/governator

此包已被废弃,不再维护。作者建议使用 symfony/rate-limiter 包。

一个通用的固定窗口速率限制节流,具有直观且流畅的API,支持多种后端。

dev-master / 1.x-dev 2020-11-12 12:55 UTC

This package is auto-updated.

Last update: 2020-11-12 12:55:46 UTC


README

CI Status Scrutinizer Code Quality Code Coverage

一个通用的 固定窗口 速率限制节流,具有直观且流畅的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
}

文档

  1. 安装
  2. 使用
    1. 流畅的节流构建器
    2. 节流工厂 工厂
    3. 资源前缀
  3. 可用存储
    1. Psr6 缓存存储
    2. Psr16 缓存存储
    3. Redis 存储
    4. 内存存储
    5. 无限存储
  4. 食谱
    1. Symfony 集成
      1. 节流控制器
      2. 配额超限异常订阅者
      3. 登录节流
      4. API 请求节流
      5. 消息处理程序 "Funnel" 节流
  5. 信用

安装

$ composer require zenstruck/governator

使用

有几种不同的 速率限制节流策略。此库使用 固定窗口 策略。窗口由 限制持续时间 定义。在 持续时间 内跟踪 击中。如果在 持续时间 内击中超过 限制,则被拒绝(抛出异常)。

节流有以下部分

  1. 存储:这是 持久化 节流的后端(见 可用存储)。
  2. 资源:您希望节流的资源的 string 表示。通常,一个名称以及用于区分每个用户的不同的 节流,如请求 IP/用户名(例如 login-$ip-$username)。
  3. 限制:在 窗口 内允许的 击中 数量。
  4. TTL:窗口的持续时间(以秒为单位)。
  5. 配额:节流的当前 状态

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的灵感来自LaravelSymfony的锁组件