Волторнистый Артём
увлечённый php-разработчик

Yii3 Rate limiter

8 марта 2024 г.

Недавно я писал про rate limiter для yii2 и решил сделать то же самое только на yii3. Время идет и пора выходить из зоны комфорта.
С учетом, что я 3-ей версией еще не пользовался, я решил просто попробовать. Да, возможно, я не буду сразу изучать всю документацию и часть оставлю на исследовательский энтузиазм. Сердцу не прикажешь : )
Первым делом я склонировал репозиторий докера для yii, настроил, залез в контейнер и установил фреймворк.

Сперва я посмотрел пример его использования:

use Psr\Http\Message\ServerRequestInterface;
use Yiisoft\Yii\RateLimiter\LimitRequestsMiddleware;
use Yiisoft\Yii\RateLimiter\Counter;
use Nyholm\Psr7\Factory\Psr17Factory;
use Yiisoft\Yii\RateLimiter\Policy\LimitAlways;
use Yiisoft\Yii\RateLimiter\Policy\LimitPerIp;
use Yiisoft\Yii\RateLimiter\Policy\LimitCallback;
use Yiisoft\Yii\RateLimiter\Storage\StorageInterface;
use Yiisoft\Yii\RateLimiter\Storage\SimpleCacheStorage;

/** @var StorageInterface $storage */
$storage = new SimpleCacheStorage($cache);

$counter = new Counter($storage, 2, 5);
$responseFactory = new Psr17Factory();

$middleware = new LimitRequestsMiddleware($counter, $responseFactory); // LimitPerIp by default

С учетом того, что обычно зависимости добавляются через di-контейнер, то я собственно полез в него config/web/di/application.php и добавил следующее:

<?php

declare(strict_types=1);

use App\Handler\NotFoundHandler;
use Nyholm\Psr7\Factory\Psr17Factory;
use Psr\Container\ContainerInterface;
use Psr\SimpleCache\CacheInterface;
use Yiisoft\Cache\File\FileCache;
use Yiisoft\Definitions\DynamicReference;
use Yiisoft\Definitions\Reference;
use Yiisoft\Injector\Injector;
use Yiisoft\Middleware\Dispatcher\MiddlewareDispatcher;
use Yiisoft\Yii\RateLimiter\Counter;
use Yiisoft\Yii\RateLimiter\LimitRequestsMiddleware;
use Yiisoft\Yii\RateLimiter\Storage\SimpleCacheStorage;
use Yiisoft\Yii\RateLimiter\Storage\StorageInterface;

/** @var array $params */

return [
    Yiisoft\Yii\Http\Application::class => [
        '__construct()' => [
            'dispatcher' => DynamicReference::to(static function (Injector $injector) use ($params) {
                return $injector->make(MiddlewareDispatcher::class)
                    ->withMiddlewares($params['middlewares']);
            }),
            'fallbackHandler' => Reference::to(NotFoundHandler::class),
        ],
    ],
    \Yiisoft\Yii\Middleware\Locale::class => [
        '__construct()' => [
            'supportedLocales' => $params['locale']['locales'],
            'ignoredRequestUrlPatterns' => $params['locale']['ignoredRequests'],
        ],
    ],
    LimitRequestsMiddleware::class => function (ContainerInterface $container) {
        $cache = $container->get(CacheInterface::class);
        $storage = new SimpleCacheStorage($cache);
        $counter = new Counter(storage: $storage, limit: 2, periodInSeconds: 60);
        $responseFactory = $container->get(Psr17Factory::class);
        return new LimitRequestsMiddleware($counter, $responseFactory);
    },
];

Разница с yii2 в том, что здесь rate limiter реализован как независимая библиотека, совместимая с PSR-интерфейсами, то есть мы можем его подключить в любой другой проект вне зависимости от способа его реализации.

В документации есть примеры более детальной его настройки, но я остановился пока на типовом использовании. После добавления настройки в di, я иду в config/common/routes.php и изменяю готовые пример из:

Route::get('/')
    ->action([SiteController::class, 'index'])
    ->name('home')

на

Route::get('/')
    ->action([SiteController::class, 'index'])
    ->prependMiddleware(LimitRequestsMiddleware::class)
    ->name('home')

Обновляю главную страницу несколько раз подряд и ничего не происходит. Интересно, почему. Я делаю дамп переменной (xdebug я настрою позднее : ):

...
LimitRequestsMiddleware::class => function (ContainerInterface $container) {
        $cache = $container->get(CacheInterface::class);
        dump($cache);
        ....

Вижу, что фреймворк уже подставляет Yiisoft\Cache\ArrayCache для интерфейса Psr\SimpleCache\CacheInterface. ArrayCache кеш хранит кол-во в памяти, поэтому при каждой перезагрузке кеш обнуляется.

Я хочу, чтобы запоминалось и интуитивно в PhpStorm’е начинаю вводить что-то FileCa.. и получаю Yiisoft\Cache\File\FileCache. Отлично, но я хочу, чтобы теперь для любой библиотеки у меня использовался файловых кеш по-умолчанию.

Добавляю следующую строчку в config/web/di/application.php:

<?php

declare(strict_types=1);

use App\Handler\NotFoundHandler;
use Nyholm\Psr7\Factory\Psr17Factory;
use Psr\Container\ContainerInterface;
use Psr\SimpleCache\CacheInterface;
use Yiisoft\Cache\File\FileCache;
use Yiisoft\Definitions\DynamicReference;
use Yiisoft\Definitions\Reference;
use Yiisoft\Injector\Injector;
use Yiisoft\Middleware\Dispatcher\MiddlewareDispatcher;
use Yiisoft\Yii\RateLimiter\Counter;
use Yiisoft\Yii\RateLimiter\LimitRequestsMiddleware;
use Yiisoft\Yii\RateLimiter\Storage\SimpleCacheStorage;
use Yiisoft\Yii\RateLimiter\Storage\StorageInterface;

/** @var array $params */

return [
    ...
    CacheInterface::class => function (ContainerInterface $container) {
        return $container->get(FileCache::class);
    },
    LimitRequestsMiddleware::class => function (ContainerInterface $container) {
        $cache = $container->get(CacheInterface::class);
        $storage = new SimpleCacheStorage($cache);
        $counter = new Counter(storage: $storage, limit: 2, periodInSeconds: 60);
        $responseFactory = $container->get(Psr17Factory::class);
        return new LimitRequestsMiddleware($counter, $responseFactory);
    },
];

Вот, теперь после нескольких попыток обновить страницу я вижу сообщение Too Many Requests. Отлично, работает! Но у меня возник вопрос, с какими настройками вернулся FileCache. То есть у файла FileCache есть параметры в конструкторе:

    public function __construct(
        private string $cachePath,
        private int $directoryMode = 0775,
    ) {
        if (!$this->createDirectoryIfNotExists($cachePath)) {
            throw new CacheException("Failed to create cache directory \"$cachePath\".");
        }
    }

Если бы класса FileCache не было бы в di до моего кода, сейчас я бы получил ошибку. Сделав дамп переменной:

CacheInterface::class => function (ContainerInterface $container) {
   $cache = $container->get(FileCache::class);
   dump($cache);
   return $cache;
},

Вижу следующее:

Yiisoft\Cache\File\FileCache#560
(
    [Yiisoft\Cache\File\FileCache:fileSuffix] => '.bin'
    [Yiisoft\Cache\File\FileCache:fileMode] => null
    [Yiisoft\Cache\File\FileCache:directoryLevel] => 1
    [Yiisoft\Cache\File\FileCache:gcProbability] => 10
    [Yiisoft\Cache\File\FileCache:cachePath] => '/app/runtime/cache'
    [Yiisoft\Cache\File\FileCache:directoryMode] => 509
)

Как видно, cachePath уже удачно предустановлен. В конфигах установленного приложения я не нашел настройки для FileCache. У yii3 в описании настройки конфигурации говорится про config/packages/merge_plan.php.

В самом файле я вижу:

<?php

declare(strict_types=1);

// Do not edit. Content will be replaced.
return [
    '/' => [
        'di' => [
            'yiisoft/cache-file' => [
                'config/di.php',
            ],
        // ... остальной код

Если я закомментирую строчку с ‘config/di.php’, то получу ошибку на сайте:

No definition or class found or resolvable for "Yiisoft\Cache\File\FileCache" while building "Yiisoft\Cache\File\FileCache".

Иду в папку, где лежит наш класс FileCache, а именно в app/vendor/yiisoft/cache-file и нахожу config/di.php:

<?php

declare(strict_types=1);

use Yiisoft\Aliases\Aliases;
use Yiisoft\Cache\File\FileCache;

/* @var $params array */

return [
    FileCache::class => static fn (Aliases $aliases) => new FileCache(
        $aliases->get($params['yiisoft/cache-file']['fileCache']['path'])
    ),
];

Собственно, вот и ответ откуда берется предустановленный кеш. Довольно удобный инструмент с учетом того, что разрабатывая пакет для приложения, можно подгружать дефолтные конфиги. Это можно так же встретить в laravel и symfony.

В конечном итоге мой файл config/web/di/application.php:

<?php

declare(strict_types=1);

use App\Handler\NotFoundHandler;
use Nyholm\Psr7\Factory\Psr17Factory;
use Psr\Container\ContainerInterface;
use Psr\SimpleCache\CacheInterface;
use Yiisoft\Cache\File\FileCache;
use Yiisoft\Definitions\DynamicReference;
use Yiisoft\Definitions\Reference;
use Yiisoft\Injector\Injector;
use Yiisoft\Middleware\Dispatcher\MiddlewareDispatcher;
use Yiisoft\Yii\RateLimiter\Counter;
use Yiisoft\Yii\RateLimiter\LimitRequestsMiddleware;
use Yiisoft\Yii\RateLimiter\Storage\SimpleCacheStorage;
use Yiisoft\Yii\RateLimiter\Storage\StorageInterface;

/** @var array $params */

return [
    Yiisoft\Yii\Http\Application::class => [
        '__construct()' => [
            'dispatcher' => DynamicReference::to(static function (Injector $injector) use ($params) {
                return $injector->make(MiddlewareDispatcher::class)
                    ->withMiddlewares($params['middlewares']);
            }),
            'fallbackHandler' => Reference::to(NotFoundHandler::class),
        ],
    ],
    \Yiisoft\Yii\Middleware\Locale::class => [
        '__construct()' => [
            'supportedLocales' => $params['locale']['locales'],
            'ignoredRequestUrlPatterns' => $params['locale']['ignoredRequests'],
        ],
    ],
    CacheInterface::class => function (ContainerInterface $container) {
        return $container->get(FileCache::class);
    },
    LimitRequestsMiddleware::class => function (ContainerInterface $container) {
        $cache = $container->get(CacheInterface::class);
        $storage = new SimpleCacheStorage($cache);
        $counter = new Counter(storage: $storage, limit: 2, periodInSeconds: 60);
        $responseFactory = $container->get(Psr17Factory::class);
        return new LimitRequestsMiddleware($counter, $responseFactory);
    },
];

В документации сказано, что так же можно использовать сервис провайдеры, но с этим я ознакомлюсь позднее : )