2016-08-07

SinatraとOmniAuthで学ぶRack middleware

なんかどうも OmniAuth を使おうと思ったらまず Session を有効にしろやと言われたり言われなかったりするので久しぶりにがっつりコードリーディングしてみた。

以下のバージョンで確認した。

  • Sinatra 1.4.7
  • Rack 1.6.4
  • OmniAuth 1.3.1

まとめ

  1. rack middlewareは記述順に依存する
    • 具体的には OmniAuth の前に Rack::Session を use しておかないとダメ
    • なぜなら OmniAuth は session を利用できる前提で書かれているから
  2. rack middlewareの組み立てられ方
    • より正確にはアプリケーション本体をいちばん内側に包むたまねぎ構造
  3. 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 も面倒な処理してて遅そうだなーという気がするね…。

参考

  1. これは Rack の機能ではなく単に Ruby の Proc オブジェクトの [] メソッドを呼んでるだけ 

About

例によって個人のなんちゃらです