Railsのbefore_filterみたいなのをSinatraで実現できるか

 Railsは便利だとは思ったがフレームワークとしてとくに好きではなかった(別に嫌いでもない)。それに対してSinatraはなんだかよくできてる感があって気に入っている。この差がどこからくるのかはよくわからないが。
 そんなSinatraRailsであるが、比較してみると、フィルタまわりは圧倒的にRailsの方が高機能である。Sinatraのフィルタはbeforeという一種類で、しかも共通処理を書けるだけである。こんな感じ。

before do
  if not logged_in?
    redirect '/'
  end
end

Railsと比べると

  • 呼ぶタイミングがbeforeしかない。Railsだとbefore_filter, after_filter, around_filterの3種類のフィルタがある。
  • どのメソッドに対してフィルタ処理を行うかを制御できない。Railsはbefore_filter :filter1, except => :show みたいな感じで、このメソッド以外に対してはこのフィルターを呼ぶ、みたいなカスタマイズができる。
  • フィルタでHTMLを出力して処理を終わらせることができない。(これはできるかもしれない。けど、普通に文字列を返すだけではダメだった。)

 というような点で不満がある。実際のところbeforeフィルタだけあればほとんどの用は足りるのでafter_filterがないのは別に構わないのだが、残り2つの問題は結構厄介だし深刻だ。まぁ、こういう処理が複雑で耐えきれないような規模のウェブアプリはたぶんSinatraよりもRailsやMerbなどの方がふさわしいのだろう、という気もするが、Sinatraでもやれないか、ちょっと考えてみたい。
 どのメソッドに対してフィルタ処理を行うか、もしくは行わないか、という指定はSinatraでは不可能である。なぜならそもそもコントローラクラスを実装してメソッドを定義して…、みたいな実装の仕方ではなく、

get '/' do
  "hello world"
end

みたいに、実際に処理をするコードをブロックとして渡しているからだ。メソッドとして名前をつけていないのでメソッド名では指定できない。まぁ、ここはURLに対する指定で我慢すれば良かろう。リクエストURLはrequest.urlで取れるので、それに対して正規表現か何かで指定をすれば良い。

before do
  if request.url =~ /$\/admin/ not logged_in?
    redirect '/'
  end
end

 みたいな感じ。
 残るのは、フィルタの中からHTMLを出力して終了、という処理が書けないところである。この問題はどうしようもない。
 ここで発想を少し変えてみる。beforeを使わずにgetでフィルタを書いてやれば良いのではないか?

get '*' do
  if request.url =~ /$\/admin/ not logged_in?
    redirect '/'
  end
 ここら辺で適当にrequest.urlにしたがってredirect
end

 しかしこのアイデアには問題があって、redirectをすると実際にその時点でブラウザに302でリダイレクトを送るのである。リダイレクトの濫用は好ましくないし、そもそもpostには使えないだろう。
 ここでなんかいいメソッドないかな、とAPI Referenceを眺めていたら、passというメソッドが見つかった。
 なんだか名前的にpythonを思い出すメソッド名だが、それはさておき、passはそのリクエストハンドラでの処理を取りやめて次のハンドラへ処理を移すメソッドである。(みつからなかったら404になる)
 これを使うことで、Railsのbefore_filterっぽいものを実装してみた。

def before_filter filter_url, &filter_proc
  get filter_url do
    filter_proc.call
  end
  post filter_url do
    filter_proc.call
  end
end

before_filter '/admin*' do
  if login_as_admin?
    throw :pass
  else
    "required login as admin"
  end
end

get '/admin' do
  return "admin page"
end

 login_as_admin?を適当にtrueにしてみたりfalseにしてみたりすると、before_filterが動いていることが確認できる。passじゃなくてthrow :passとなっているのは、before_filterからpassを投げようとすると、NoMethodErrorになるからだ。なぜそうなるかは後述するが、どうやって解決すればいいのかは分からない。
 さて、この一見便利そうに見えるbefore_filterだが、実装を見て分かるかもしれないが、実用するにはちょっと難がある。具体的には、before_filterの方がgetやpostなどのメソッドよりも先に呼ばれないといけない、という制限がある。
 もちろん、普通に書くとフィルタの方が上に書かれるだろうから問題は起きないはずだが、そういう穴を残しておくと、はまる人は絶対に出てくるものなので、このやり方は気にくわない。また、before_filter内でreturnができなかったりする。こういう、似たように書けて細かい違いがある、というのは腹立たしい。
 returnができないのはトップレベルだから当たり前で、逆にgetに渡したブロックの中からはなぜreturnができるのか、できているそちらの方が不思議である。
 コードを読んで見たところ、getメソッドで渡されたブロックは最終的にはdefine_methodで名前をつけてメソッド化されていた。メソッドなのでreturnしても構わないし、同じSinatra::Baseに定義されているpassのようなメソッドも呼べる、というわけだ。
 つまり、getメソッドがやっているのと同じようにdefine_methodを使えば、return問題とpass問題は解決できる。getやpostメソッドはrouteというメソッドを呼び出しているだけで、routeメソッドがやっている主な処理は

  • get/postに渡したpath文字列を正規表現に変換してその名前でブロックをメソッド化する(ここでdefine_methodが使われる)
  • routes['GET']という名前の配列にpath文字列を正規表現化したものや呼び出される条件などをpushする

 の2つである。ここで重要なのはpushを使っている、つまり末尾に追加しているという事で、ここにunshiftを使う…とフィルタの登録順序が逆転してしまうのであまりよろしくないが、とにかく単にpushするのではなくフィルタ処理の方を先に呼ばれるように登録してやれば、実現は可能そうだという事が分かった。
 ということで、Railsのbefore_filterみたいなのをSinatraで実現できるか、という問いに対しては、頑張ればできそうだ、という感触である。ただ、Sinatra本体のコードをいじらずに実現できるかはまだちょっと微妙である。また、事前に呼ばれるべきフィルタが先に呼ばれるということをpushとかunshiftとかでゴニョゴニョやって保証しようというのはコードとして明らかに筋が悪いので、その点に関してもまだ検討が必要だ。
 と、ここで、haltというメソッドをみつけた。これを使えばSinatra本体はいじらずに完全なbefore_filterが実現できそうだ。が、もう3時間ぐらいSinatraのコードばっかり眺めててそろそろ飽きてきたのでここで終わる。続きはまた来週。