rack-attack の throttle の実装がどうなっているかコード読んでみた
こんにちは。中華スープに入っている揚げ卵が好きです、きたけーです。
先日、ブログで紹介したrack-attack ( rack-attack middleware を使って IPアドレスでアクセスを制限したり、同一IPアドレスからの大量のリクエストを防ぐ術 - きたけーTechブログ) ですが、実装どうなってんのかなぁと思ってコードを読んでみました(特にthrottleを中心に)。
設定
大本はここ rack-attack/attack.rb at 25739e24dbc820898cf45d120b1e794e8f6f4df1 · kickstarter/rack-attack · GitHub
前回、ブログで紹介した以下の様な記述では、
Rack::Attack.throttle('reports limit', limit: 1, period: 60.seconds) do |req| req.ip if req.post? && req.path.match(%r{^/reports}) end
以下のように Throttle のオブジェクトを生成してハッシュに格納しているのが分かる (https://github.com/kickstarter/rack-attack/blob/25739e24dbc820898cf45d120b1e794e8f6f4df1/lib/rack/attack.rb#L30)
def throttle(name, options, &block) self.throttles[name] = Throttle.new(name, options, block) end
リクエストがきたら
リクエストがきたら、throttlesハッシュに格納されているオブジェクトひとつひとつに対して[]
メソッドをリクエストオブジェクトを引数にして呼び出す(https://github.com/kickstarter/rack-attack/blob/25739e24dbc820898cf45d120b1e794e8f6f4df1/lib/rack/attack.rb#L55)。
def throttled?(req) throttles.any? do |name, throttle| throttle[req] end end
ここでもし、ひとつでも真を返すものがあれば、ステータスコード「429 Too Many Requests」のレスポンスを返す(https://github.com/kickstarter/rack-attack/blob/25739e24dbc820898cf45d120b1e794e8f6f4df1/lib/rack/attack.rb#L84)。
@throttled_response = lambda {|env| retry_after = (env['rack.attack.match_data'] || {})[:period] [429, {'Content-Type' => 'text/plain', 'Retry-After' => retry_after.to_s}, ["Retry later\n"]] }
Rack::Attack::Throttle#[]に潜る
def [](req) discriminator = block[req] return false unless discriminator current_period = period.respond_to?(:call) ? period.call(req) : period current_limit = limit.respond_to?(:call) ? limit.call(req) : limit key = "#{name}:#{discriminator}" count = cache.count(key, current_period) data = { :count => count, :period => current_period, :limit => current_limit } (req.env['rack.attack.throttle_data'] ||= {})[name] = data (count > current_limit).tap do |throttled| if throttled req.env['rack.attack.matched'] = name req.env['rack.attack.match_discriminator'] = discriminator req.env['rack.attack.match_type'] = type req.env['rack.attack.match_data'] = data Rack::Attack.instrument(req) end end end
discriminatorには、記事の最初で記述した設定のブロックをProcオブジェクトとして評価している。対象のリクエストであればIPアドレスがくる。対象のリクエストでなければ、nilがくるのでメソッドとしてはfalseを返す。
その後は、periodとlimitがProcオブジェクトらしかったら評価して(ダックタイピングだ!)、らしくなかったら、そのまま返す。periodとlimitはProcオブジェクトが指定できるんですね。(勉強になった)
Rack::Attack::Cache
cacheは Rack::Attack::Cacheというクラスのインスタンスで、こいつはデフォルトだとRails Cacheをラップして、リクエストの回数をカウントしたりしている。ここが肝っぽい(https://github.com/kickstarter/rack-attack/blob/25739e24dbc820898cf45d120b1e794e8f6f4df1/lib/rack/attack/cache.rb#L17)
def count(unprefixed_key, period) epoch_time = Time.now.to_i # Add 1 to expires_in to avoid timing error: http://git.io/i1PHXA expires_in = period - (epoch_time % period) + 1 key = "#{prefix}:#{(epoch_time/period).to_i}:#{unprefixed_key}" do_count(key, expires_in) end
epoch時間と設定した時間間隔の商をつかってキャッシュキーを生成したり、余りから有効期限を設定している。
Rack::Attack::Throttle#[]に戻る
最後はenvに色々な値を設定したりなど。tap
つかう必要あるのかな、と思ったんですけど、メソッドとしてリミットを超えたかどうか真偽値を返す必要があるからでした。
まとめ
何気なく読み始めたんですけど、素直に実装されていて、かつ、コードもきれいなので読みやすかったです。 1時間くらいの社内勉強会で、良質なrack middlewareのコードリーディング会をやるといいかもしれないですね。