SinatraとOmniAuthで学ぶRack middleware
なんかどうも OmniAuth を使おうと思ったらまず Session を有効にしろやと言われたり言われなかったりするので久しぶりにがっつりコードリーディングしてみた。
以下のバージョンで確認した。
- Sinatra 1.4.7
- Rack 1.6.4
- OmniAuth 1.3.1
まとめ
- rack middlewareは記述順に依存する
- 具体的には OmniAuth の前に Rack::Session を use しておかないとダメ
- なぜなら OmniAuth は session を利用できる前提で書かれているから
- rack middlewareの組み立てられ方
- より正確にはアプリケーション本体をいちばん内側に包むたまねぎ構造
- sinatraのset :sessions, trueはどこに書いてもよい
- set :sessions, true は書く位置を自由にできるが、use Rack::Session::Cookie は明らかに上の方に書かないとダメな理由
1. rack middlewareは記述順に依存する
具体的には OmniAuth の前に Rack::Session を use しておかないとダメ。やっておかないと以下のようなエラーになる。
OmniAuth::NoSessionError at /
You must provide a session to use OmniAuth.
エラーを吐いてるのはここ。
def call!(env)
unless env['rack.session']
error = OmniAuth::NoSessionError.new('You must provide a session to use OmniAuth.')
fail(error)
end
なぜなら OmniAuth は session を利用できる前提で書かれているから。まぁそりゃそうかという気がするけど、そもそも rack middleware の順番てどういうことなのか?
2. rack middlewareの組み立てられ方
rack middleware の組み立て方の正解は Rack 付属のサンプルアプリである Lobster の中身を見てみるとよく分かる。
lobster.ru
require 'rack/lobster'
use Rack::ShowExceptions
run Rack::Lobster.new
この rack/lobster の中身はどうでもよく、いちばん最後、
if $0 == __FILE__
require 'rack'
require 'rack/showexceptions'
Rack::Server.start(
:app => Rack::ShowExceptions.new(Rack::Lint.new(Rack::Lobster.new)),
:Port => 9292
)
end
こういう記述がある。lobster.ru の
use Rack::ShowExceptions
run Rack::Lobster.new
が
Rack::ShowExceptions.new(Rack::Lobster.new)
と等価であることが分かる。つまり、middleware がアプリケーション本体をくるんでいる状態。
分かる。って書いたけど、じゃあ実際に自分で書いたアプリがどう解釈されるのかも見ておこう。ちょっと長いけど、良い子は我慢してつきあってくれ。本当は別にそれっぽい知識はすでにあるのでまったく読み込む必要はなかったんだけど、改めて追いかけてみようかなと
Rack のソースコード読んでる - 大学生からの Web 開発
を見て「そういや読もうと思えば読めるじゃん」と思ったので、use と run がどのように処理されるかを辿ってみた。
実際にはこの辺りは Rack::Server#start の中の wrapped_app から build_app app を呼んでる辺り、
def wrapped_app
@wrapped_app ||= build_app app
end
def build_app(app)
middleware[options[:environment]].reverse_each do |middleware|
middleware = middleware.call(self) if middleware.respond_to?(:call)
next unless middleware
klass, *args = middleware
app = klass.new(app, *args)
end
app
end
この app が実引数の名前もメソッドの名前も同じなのが混乱してしまうが、
def app
@app ||= options[:builder] ? build_app_from_string : build_app_and_options_from_config
end
で、rackup 起動時に何も与えなかった場合はこっちが呼ばれる。
def build_app_and_options_from_config
if !::File.exist? options[:config]
abort "configuration #{options[:config]} not found"
end
app, options = Rack::Builder.parse_file(self.options[:config], opt_parser)
self.options.merge! options
app
end
Rack::Builder.parse_file は最後
def self.new_from_string(builder_script, file="(rackup)")
eval "Rack::Builder.new {\n" + builder_script + "\n}.to_app",
TOPLEVEL_BINDING, file, 0
end
を呼んで、豪快に eval の結果を Rack::Builder.new
def initialize(default_app = nil,&block)
@use, @map, @run, @warmup = [], nil, default_app, nil
instance_eval(&block) if block_given?
end
に渡す手法を見せ、ここで Rack::Builder#use, Rack::Builder#run が呼ばれる。
def use(middleware, *args, &block)
if @map
mapping, @map = @map, nil
@use << proc { |app| generate_map app, mapping }
end
@use << proc { |app| middleware.new(app, *args, &block) }
end
use の中では先ほどの initialize で初期化した @use という Array に middleware が順番に突っ込まれているのが分かる。run はこれだけ。
def run(app)
@run = app
end
さて、Rack の仕様で、 とにかく実行時には call が呼ばれる のは知ってるよね? call を見てみよう。
def call(env)
to_app.call(env)
end
to_app はこう。
def to_app
app = @map ? generate_map(@run, @map) : @run
fail "missing run or map statement" unless app
app = @use.reverse.inject(app) { |a,e| e[a] }
@warmup.call(app) if @warmup
app
end
ここに先ほどの @use も @run も出てくる。ポイントは実にあっさり書かれてるここ。
app = @use.reverse.inject(app) { |a,e| e[a] }
ここで use したものが逆順に wrap されていってる。わーあっさり。
確認していこう。
- e は use のところで proc {} で生成されている Proc オブジェクト
- a は予想通り middleware オブジェクト
e[a]
で Proc オブジェクトが実行される1んだけど、最初の app は run されるもの、例えば Sinatra の classic style なら Sinatra::Application で、inject なので e に渡るのはすべて実行結果の app になる。つまり
2回目の e[a] は最後に use された middleware に本体の app を与えた結果のアプリ
であり、順番に遡っていくので、結果
Rack::ShowExceptions.new(Rack::Lobster.new)
こういうことになる。
これで call というメソッドを持つ middleware オブジェクトで wrap されるたまねぎ構造が実現される。
実に面倒な処理だが、これは先ほども見たように Rack::Server の app にメモ化されているので、最初の一回だけ実行される。
3. Sinatraのset :sessions, trueは記述順を緩和してくれる
Sinatra::Baseの中にこんな記述があって、
def build(app)
builder = Rack::Builder.new
setup_default_middleware builder
setup_middleware builder
builder.run app
builder
end
- setup_default_middleware
- setup_middleware
の順番がキモっぽいなというのが分かる。
def setup_default_middleware(builder)
builder.use ExtendedRack
builder.use ShowExceptions if show_exceptions?
builder.use Rack::MethodOverride if method_override?
builder.use Rack::Head
setup_logging builder
setup_sessions builder
setup_protection builder
end
def setup_middleware(builder)
middleware.each { |c,a,b| builder.use(c, *a, &b) }
end
setup_middleware の方は中で use を呼んでいるので例のアレ。
setup_default_middleware の中の setup_sessions がそれっぽいので読むと
def setup_sessions(builder)
return unless sessions?
options = {}
options[:secret] = session_secret if session_secret?
options.merge! sessions.to_hash if sessions.respond_to? :to_hash
builder.use Rack::Session::Cookie, options
end
まさにここで use OmniAuth の前に use Rack::Session::Cookie が呼ばれている。
ちなみに sessions? って何?って感じだけど、これは set :sessions, true に関係している。
def set(option, value = (not_set = true), ignore_setter = false, &block)
(snip)
define_singleton("#{option}=", setter) if setter
define_singleton(option, getter) if getter
define_singleton("#{option}?", "!!#{option}") unless method_defined? "#{option}?"
self
end
どうもここで sessions? っていうメソッドが定義されてるっぽい? define_singleton を読むと、
def define_singleton(name, content = Proc.new)
# replace with call to singleton_class once we're 1.9 only
(class << self; self; end).class_eval do
undef_method(name) if method_defined? name
String === content ? class_eval("def #{name}() #{content}; end") : define_method(name, &content)
end
end
わーい黒い黒ーい。値を返すメソッドが定義されてる。つまり
set :sessions, true
は
def sessions
true
end
def sessions?
true
end
が定義される。 setup_sessions にもう一度戻ると set :sessions, true が実行されていれば use Rack::Session::Cookie が OmniAuth など通常の middleware よりも先に実行される。なぜなら default middleware だから。
です。
なるほどね!
おまけ - OmniAuthのproviderをuseしてないのでは?
ちなみに
Sinatra Recipes - Middleware - Twitter Authentication With Omniauth
にある
use OmniAuth::Builder do
provider :twitter, ENV['CONSUMER_KEY'], ENV['CONSUMER_SECRET']
end
この書き方って OmniAuth::Builder は use してるけど provider は use してなくね? って気がしたんだけど、
def provider(klass, *args, &block)
if klass.is_a?(Class)
middleware = klass
else
begin
middleware = OmniAuth::Strategies.const_get("#{OmniAuth::Utils.camelize(klass.to_s)}")
rescue NameError
raise(LoadError.new("Could not find matching strategy for #{klass.inspect}. You may need to install an additional gem (such as omniauth-#{klass})."))
end
end
args.last.is_a?(Hash) ? args.push(options.merge(args.pop)) : args.push(options)
use middleware, *args, &block
end
てことでちゃんと最後に OmniAuth::Strategies::Twitter を普通に use してました。名前空間を提供しつつ use するだけの人は楽をできる DSL ステキ。ステキだけど、分からないとこわい。そんな感じ。
ただ、読んでみて思ったけど Sinatra も Rack も面倒な処理してて遅そうだなーという気がするね…。
参考
- GitHub - rack/rack: a modular Ruby webserver interface
- GitHub - omniauth/omniauth: OmniAuth is a flexible authentication system utilizing Rack middleware.
- Sinatra
- File: SPEC — Documentation for rack/rack (master)
- Rack のソースコード読んでる - 大学生からの Web 開発
- Sinatra Recipes - Middleware - Twitter Authentication With Omniauth
- CGI を rackup してみた - あーありがち(2008-12-06)
これは Rack の機能ではなく単に Ruby の Proc オブジェクトの [] メソッドを呼んでるだけ ↩