Sinatra向けにRailsのbefore_filterみたいなのを作ってみた

 一つ前のエントリでは「また来週」とか書いちゃったけど、実装始めると意外と楽しかったので作っちゃった。
 まずは実際のコードから。これがbefore_filter.rb。これをrequireするとbefore_fitlerというメソッドが使えるようになる。

def before_filter path, &filter_proc
  BeforeFilter.before_filter(path, &filter_proc)
end

module BeforeFilter
  @@filters = []
  def self.before_filter path, &filter_proc
    pattern, keys = Sinatra::Base.send(:compile, path)

    Sinatra::Base::send(:define_method, "filter_method #{path}", &filter_proc)
    unbound_method = Sinatra::Base::send(:instance_method, "filter_method #{path}")

    @@filters << [pattern, keys, unbound_method]
  end

  def self.filters
    @@filters
  end
end

before do
  if BeforeFilter.filters.length > 0
    BeforeFilter.filters.each{|pattern, keys, proc|
      path = unescape(@request.path_info)
      if match = pattern.match(path)
        catch(:pass) do
          throw :halt, proc.bind(self).call
        end
      end
    }
  end
end

 こっちがテスト用のSinatraアプリ。login_as_admin?を適当にtrueにしてみたりfalseにしてみると動く。http://localhost:4567/admin/あたり。

require "rubygems"
require "sinatra"
require "before_filter"

def login_as_admin?
  true
end

get '/admin/*' do
  "hello admin"
end

before_filter '/admin/*' do
  if login_as_admin?
    pass
  else
    return "required admin privilege."
  end
end

 before_filter.rbは小さいながらもいろいろとSinatra本体から得られた知見を使っており、30行ちょっとしかない割には読みづらいコードになってしまった。読みづらさの原因はほぼ全部がスコープや環境に対するハックであり、やってることその物は大したことはない。(単なるフィルタだしね。)
 以下、ちょっとコードを解説する。
 まず、before_filterというメソッドはBeforeFilterモジュールのbefore_filterメソッドを呼んでいるだけである。別に、BeforeFilter.before_filterと書くのがめんどくさくないなら、このメソッドはいらない。
 BeforeFilterモジュールは実際にフィルタを登録するためのコードが入っているモジュールだ。BeforeFilter::before_filterがその登録処理を行うメソッドである。Sinatra::Base::sendを3回も呼んでいるが、それぞれなにをしているか、解説してみる。
 pattern, keys = Sinatra::Base.send(:compile, path) は、pathをマッチング用の正規表現に変換するメソッドを呼んでいる。これはcompileメソッドがprivateになっていたのでsendが必要だった。もしcompileメソッドがpublicなら、このsendは必要なかった。
 その次、Sinatra::Base::send(:define_method, "filter_method #{path}", &filter_proc) とその次の行は、引数として受け取ったブロックをメソッド化している。これはブロック内でreturnが使えるようにするためである。こんな方法があるのか、とSinatraのコードを読んだときには感心した。が、あまり濫用するとまずそうではある…。単純にdefine_methodを呼んだのでは、Sinatra::Baseに定義されている色々なメソッドを呼ぶことができないので、sendを使って無理やりSinatra::Base内でdefine_methodをさせている。
 beforeメソッドに渡したブロックのコードに書かれている部分が、実際に登録したフィルタを呼び出して処理を行っている。具体的には、リクエストパス(URLのホスト名より下の部分)とフィルタ用正規表現がマッチするかを確認し、マッチした場合はフィルタを呼び出している。throw :halt, proc.bind(self).call がフィルタの呼び出しである。procはbefore_filterでメソッド化されたブロックであり、メソッドであるからにはインスタンスとくっつけないと呼び出せないので、bind(self)でSinatra::Applicationのインスタンスとくっつけている。(Sinatra::ApplicationクラスはSinatra::Baseクラスを継承しているのでこれで大丈夫。)ブロック内でpassが呼び出されるとthrow :passが実行されるが、catch(:pass)でキャッチされるので、結果としてフィルタ呼び出しはなかったことにされる。passを呼び出さずに普通に結果を返すと、throw :haltによってそれ以降のリクエスト処理は全部スキップされる。
 メソッド化しているので、before_fiterで定義したブロックの中ではreturnも使える。コードがこれでいいのかは置いといて、これで一応、機能上はまともに動くbefore_filterが作れた。
 前のエントリに対してはyharaさんから本家に仕様を提案すべき、というぶくまコメントをいただいたけど、どうだろう、他の人もあったらうれしいのかなぁ。とりあえず自分はあったら使うけども。beforeメソッドの中でhaltを使うことで、気をつけてさえいればフィルタっぽいものは簡単に実現できるので、本体を複雑にするほどうれしいのかなぁという気もしてきた。
 次はbeforeメソッドの中でフィルタっぽいものを書いてみて、どれだけうれしくなるか(もしくは特にうれしくならないか)を考えてみます。