開発室ブログ

Laravel PHP Redis

Laravel / ThrottleRequests の云々とかオーバーライドとか

ちょっと前ですが。予約してた Nintendo Labo が発売日あたりに届きました。

もうすぐ3才ってくらいの息子といっしょに作ったので、まー大変。自分で作りたがるけど、幼児にはかなり難しい。洗車できるようになった人がタペット調整始めるくらいキツい。しかも段ボールなので、組む前にヘし折っちゃうかも(折り方や手順が大事なんですコレ)。
なので、ゴミのまとめ・お片付けに、説明動画の操作、簡単なパーツ組み等できる部分をローテーションしてお願い。1時間以上はかかるので、飽きてきたら適宜おやつと飲み物を供給できる体制を。って感じで進めてました。

Labo

↑ 作ってしまえば一人で組み立てて遊べるかと。まー色々聞かれるけど、教えた分だけどんどん覚えていく…若さに負けそう。

そんな状況なので「つくる」 「あそぶ」はやったけど、肝心の「わかる」がクソもできてない…。Labo でなんか自作したらネタになるかと思ったらこのザマ。なので今回は Laravel のお話です。

環境

  • CentOS 7(Vagrant / AWS EC2)
  • PHP 7.2
  • Laravel 5.5
  • Redis 3.2.10 (AWS ElastiCache for Redis 3.2.6)

Laravel環境に関しては以前書いたものも。よろしければ。

Vagrant * Ansible Local で Laravel環境を作る ElastiCache(Redis) の認証機能をLaravel 5.5で

やりたかったこと

ユーザーID や IPアドレスではなく、何か特定の値(キー)で リクエスト数/時間 を制限したい。

  • 特定のユーザーエージェントとか
  • リクエストに埋め込まれた特定のキーとかIDとか
  • アクセスされたAPIのパスとか
  • こういう値を見て 600リクエスト/10分間まで という制限をする

例えばで言うと、こんな感じでしょうか?
案件によって、いろいろ考えられそう。

Laravel標準の ThrottleRequests

Laravelには ThrottleRequests としてアクセス回数制限が実装されています。自分で書いてもいいんですが、できれば(自分よりコーディングが上手な人が書いた)ありモノを使いたいので、調べてみます。

スロットルリクエストは何をキーにカウントしてるの?

コードみる
\vendor\laravel\framework\src\Illuminate\Routing\Middleware\ThrottleRequests.php

    public function handle($request, Closure $next, $maxAttempts = 60, $decayMinutes = 1)
    {
        $key = $this->resolveRequestSignature($request);
        ...
        ..


    protected function resolveRequestSignature($request)
    {
        if ($user = $request->user()) {
            return sha1($user->getAuthIdentifier());
        }

        if ($route = $request->route()) {
            return sha1($route->getDomain().'|'.$request->ip());
        }
        ...
        ..

ユーザーID、もしくは IPアドレス のSHA1ハッシュ…。
固定されてそうな雰囲気。 $route->getDomain() てなんのドメインだ? とりあえず次。

どこでスロットル使う設定かいてるの?

デフォルトで \app\Http\Kernel.php にて設定されています。

    protected $middlewareGroups = [
        ...
        ..
        'api' => [
            'throttle:60,1',   // ← こいつ
            'bindings',
        ],
        ...

api なので ルーター \routes\api.php に効く。60,1 になってるので 1分間に60回 まで許可。
オーバーすると '429 Too Many Requests' を返してくれます。

つまり、api.phpのルートは ドメイン+IPアドレス からのアクセスを 1分間に60回までに制限 ということですかね。

レスポンスヘッダもつけてくれる

レスポンスヘッダに追加情報をオマケしてくれます。

X-RateLimit-Limit → 60
X-RateLimit-Remaining → 58

これだと 残58回 / 全60回 ですね。

“429 Too Many Attempts” のときは追加ヘッダも

X-RateLimit-Remaining = 0 でアクセスされると、さらにヘッダが付加されます。スゲェ。

Retry-After → 241
X-Ratelimit-Reset → 1526522760

Retry-After が、あと何秒でアクセスできるか。X-Ratelimit-Reset はカウントリセットされる時間ですかね。
フレームワークはこういうの当たり前にやってくれるのが良いなぁ。ふとしたところで勉強になったり。

Exceptionでもこのヘッダを使える

\Exception\Handler.php とかでステータス429のときに勝手にやる場合とか。
$exception->getHeaders() とすることで、セットされたヘッダを使うことができます。

ThrottleRequests を継承してオーバーライド

でもなぁーIPじゃないんだよなーどうしようかなーと Laravel を漁っていたところ…。

\vendor\laravel\framework\src\Illuminate\Routing\Middleware\ThrottleRequests.php
\vendor\laravel\framework\src\Illuminate\Routing\Middleware\ThrottleRequestsWithRedis.php

WithRedisっていうのがある。中を見てみると

namespace Illuminate\Routing\Middleware;

use Closure;
use Illuminate\Redis\Limiters\DurationLimiter;
use Illuminate\Contracts\Redis\Factory as Redis;

class ThrottleRequestsWithRedis extends ThrottleRequests
{
...
..

む。ThrottleRequestsクラスを継承してる。
ってことは、コイツを参考にすれば好みのスロットルリクエストが作れるのでは…!

その前に ThrottleRequestsWithRedis を使ってみる

いろいろ全文検索をかけてみるも、誰も呼び出していない雰囲気です。何だコイツ?
わかんないので使ってみましょう。
(LaravelからRedisを使えるようにしておく必要があります 念のため)

app\Http\Kernel.php を開いて

protected $routeMiddleware = [
        ...
        ..
        //'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
        'throttle' => \Illuminate\Routing\Middleware\ThrottleRequestsWithRedis::class,
    ];

雑。差し替えたら、Redis(認証アリ)を観察する。

[root@foo foo]# redis-cli
127.0.0.1:6379> auth [パスワード]
OK
127.0.0.1:6379> monitor
OK

((ここで Laravel から ThrottleRequestsWithRedis を叩く))

1526434205.990763 [0 127.0.0.1:57350] "AUTH" "[パスワード]"
1526434205.994391 [0 127.0.0.1:57350] "SELECT" "0"
1526434205.994646 [0 127.0.0.1:57350] "EVAL" "local function reset()\n    redis.call('HMSET', KEYS[1], 'start', ARGV[2], 'end', ARGV[2] + ARGV[3], 'count', 1)\n    return redis.call('EXPIRE', KEYS[1], ARGV[3] * 2)\nend\n\nif redis.call('EXISTS', KEYS[1]) == 0 then\n    return {reset(), ARGV[2] + ARGV[3], ARGV[4] - 1}\nend\n\nif ARGV[1] >= redis.call('HGET', KEYS[1], 'start') and ARGV[1] <= redis.call('HGET', KEYS[1], 'end') then\n    return {\n        tonumber(redis.call('HINCRBY', KEYS[1], 'count', 1)) <= tonumber(ARGV[4]),\n        redis.call('HGET', KEYS[1], 'end'),\n        ARGV[4] - redis.call('HGET', KEYS[1], 'count')\n    }\nend\n\nreturn {reset(), ARGV[2] + ARGV[3], ARGV[4] - 1}" "1" "981f7afa3e31ed756f98e292d9820a622ad2ae0e" "1526434205.9549" "1526434205" "60" "60"
1526434205.994702 [0 lua] "EXISTS" "981f7afa3e31ed756f98e292d9820a622ad2ae0e"
1526434205.994715 [0 lua] "HMSET" "981f7afa3e31ed756f98e292d9820a622ad2ae0e" "start" "1526434205" "end" "1526434265" "count" "1"
1526434205.994727 [0 lua] "EXPIRE" "981f7afa3e31ed756f98e292d9820a622ad2ae0e" "120"

なんかやってる。EVALでLUAスクリプト投げて処理してる?
HMSET だからハッシュか。HINCRBYcount をインクリメントしてるのかな? EXPIRE も設定されてるようですね。

こんなんでもいいです。

((てきとうに Laravel から ThrottleRequestsWithRedis を叩きつつ))

[root@foo foo]# redis-cli
127.0.0.1:6379> auth [パスワード]
OK
127.0.0.1:6379> keys *
1) "981f7afa3e31ed756f98e292d9820a622ad2ae0e"
127.0.0.1:6379> hgetall 981f7afa3e31ed756f98e292d9820a622ad2ae0e
1) "start"
2) "1526435075"
3) "end"
4) "1526435135"
5) "count"
6) "10"

値はこっちのほうが見やすいかな。
count が X-RateLimit-Remaining レスポンスヘッダと連動してるはず。

ThrottleRequests はどうなのか

比較として、通常の ThrottleRequests に切り戻して同じことをする。
(今回の環境は Laravelキャッシュドライバを Redis にしているので、また Redis を観察)

((ThrottleRequests を叩く))

127.0.0.1:6379> keys *
1) "hogehoge_api_cache:981f7afa3e31ed756f98e292d9820a622ad2ae0e:timer"
2) "hogehoge_api_cache:981f7afa3e31ed756f98e292d9820a622ad2ae0e"
127.0.0.1:6379> type hogehoge_api_cache:981f7afa3e31ed756f98e292d9820a622ad2ae0e
string

((ThrottleRequests を叩いていく))

127.0.0.1:6379> get hogehoge_api_cache:981f7afa3e31ed756f98e292d9820a622ad2ae0e
"2"
127.0.0.1:6379> get hogehoge_api_cache:981f7afa3e31ed756f98e292d9820a622ad2ae0e
"4"
127.0.0.1:6379> get hogehoge_api_cache:981f7afa3e31ed756f98e292d9820a622ad2ae0e
"5"

通常のThrottleRequestsは、String型のようです。処理が違う。
つっても、キャッシュドライバがRedisなら、どのみちRedisに書かれるんだけど…どっちがいいのかな?
(わざわざ別途作ってくれてるんだから、Redisの場合はWithRedis使うべき?)

ThrottleRequestsWithRedis を参考に作ってみる

閑話休題。オーバーライドするんだった。

WithRedisがあっさり動いたようなので、マネして作る。
場所は \app\Http\Middleware としまして \app\Http\Middleware\ThrottleRequestsMod.php を書きます。

namespace App\Http\Middleware;

use Closure;
use Illuminate\Routing\Middleware\ThrottleRequests;
use Illuminate\Redis\Limiters\DurationLimiter;
use Illuminate\Contracts\Redis\Factory as Redis;

Class ThrottleRequestsMod extends ThrottleRequests
{
    /**
     * 例えば、キーを "IPアドレス|ユーザーエージェント" にする
     * ハッシュすると意味不明なので、検証用に平文で返す
     */
    protected function resolveRequestSignature($request)
    {
        if ($route = $request->route()) {
            return $request->ip().'|'.$request->header('User-Agent');
        }

        throw new RuntimeException(
            'Unable to generate the request signature. Route unavailable.'
        );
    }
}

とりあえず resolveRequestSignature() だけ変更できればいいってことで、これだけ。
ネームスペースが違うので、extendsする ThrottleRequests を use しておく。
Redis関連の use は不要と思うけど、一応。

で、作ったやつをカーネルで指定する。

protected $routeMiddleware = [
    ...
    ..
    //'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
    //'throttle' => \Illuminate\Routing\Middleware\ThrottleRequestsWithRedis::class,
    'throttle' => \App\Http\Middleware\ThrottleRequestsMod::class,
];

オーバーライドの動作確認

[root@foo foo]# redis-cli
127.0.0.1:6379> auth [パスワード]
OK

((ThrottleRequestsMod を叩いておく))

127.0.0.1:6379> keys *
1) "hogehoge_api_cache:192.168.56.1|PostmanRuntime/7.1.1:timer"
2) "hogehoge_api_cache:192.168.56.1|PostmanRuntime/7.1.1"

キーの値が変わった! いいじゃない。

X-RateLimit-Remaining もちゃんと減算されているか、時間たったら戻るかを確認できればOKか。
あ。 RateLimit 超えたときの挙動もチェックですね。

ひとまずこれで実現できそうかなぁ。よかったよかった。
(ちなみに WithRedis を継承してもOKでした)

重複しないようにシグネチャ変えたヤツを何個か作って別名でアサインすれば、複数ルートの制御も可能かなーとか思ったり。いろいろできそう。

とりあえず Labo やらないと。

RecentPost