5分でRailsでRackミドルウェアを試した備忘録。
こんな感じなミドルウェアなクラスを作る。
# lib/hoge.rb
class Hoge
def initialize(app)
@app = app
end
def call(env)
Rails.logger.info("-- start -- hogehoge")
res = @app.call(env)
Rails.logger.info("-- end -- hogehoge")
res
end
end
# lib/fuga.rb
class Fuga
def initialize(app)
@app = app
end
def call(env)
Rails.logger.info("-- start -- fugafuga")
res = @app.call(env)
Rails.logger.info("-- end -- fugafuga")
res
end
end
initializeの第一引数でappをインスタンス変数に保持して、callメソッドを定義するだけ。callメソッドは @app.call(env)
を呼び出しつつ、戻り値をcallメソッドの戻り値として使うことに注意。
config.middleware.use でミドルウェアを登録する。
# config/application.rb
# snip...
require './lib/hoge'
require './lib/fuga'
# snip...
module Foo
class Application < Rails::Application
# snip...
config.middleware.use ::Hoge
config.middleware.use ::Fuga
end
end
この状態でrailsを起動してアクセスすると、こんな感じで作ったミドルウェアの処理が差し込まれる。
-- start -- hogehoge
-- start -- fugafuga
Processing by Rails::WelcomeController#index as */*
Rendering vendor/bundle/ruby/2.4.0/gems/railties-5.1.4/lib/rails/templates/rails/welcome/index.html.erb
Rendered vendor/bundle/ruby/2.4.0/gems/railties-5.1.4/lib/rails/templates/rails/welcome/index.html.erb (3.6ms)
Completed 200 OK in 199ms (Views: 31.9ms)
-- end -- fugafuga
-- end -- hogehoge
仕組みについてざっくり
Rails::Serverはconfig.ruからRack::Builderのインスタンスを作ってRackアプリを起動している。Rack::Builderのコードはこんな感じ↓module Rack
class Builder
def self.parse_file(config, opts = Server::Options.new)
options = {}
if config =~ /\.ru$/
cfgfile = ::File.read(config)
if cfgfile[/^#\\(.*)/] && opts
options = opts.parse! $1.split(/\s+/)
end
cfgfile.sub!(/^__END__\n.*\Z/m, '')
app = new_from_string cfgfile, config
else
require config
app = Object.const_get(::File.basename(config, '.rb').split('_').map(&:capitalize).join(''))
end
return app, options
end
def self.new_from_string(builder_script, file="(rackup)")
eval "Rack::Builder.new {\n" + builder_script + "\n}.to_app",
TOPLEVEL_BINDING, file, 0
end
def initialize(default_app = nil, &block)
@use, @map, @run, @warmup, @freeze_app = [], nil, default_app, nil, false
instance_eval(&block) if block_given?
end
# snip...
initializeでブロックが渡されていると、instance_evalでブロックが評価される。つまり、インスタンスのコンテキストでブロックが評価される。このブロックはconfig.ruの中身が入る。config.ru内のuseやrunはRack::Builderのメソッドを呼び出していることになる。
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
def run(app)
@run = app
end
def to_app
app = @map ? generate_map(@run, @map) : @run
fail "missing run or map statement" unless app
app.freeze if @freeze_app
app = @use.reverse.inject(app) { |a,e| e[a].tap { |x| x.freeze if @freeze_app } }
@warmup.call(app) if @warmup
app
end
useメソッドは@useを配列として、 Procで包んだミドルウェアのインスタンスを入れている。runは@runに起動するメインのRackアプリを入れている。callメソッドはto_app.callを呼び出しており、to_appは各オブジェクトをフリーズしつつミドルウェアでアプリを包んでいる。
キモは app = @use.reverse.inject(app) { |a,e| e[a].tap { |x| x.freeze if @freeze_app } }
の部分。
@useにはProcの配列が入っており、これに対してinjectのループを回している。
injectの初期値はapp、つまりメインのRackアプリである。ループの一周目はaにはapp、eは@useの最後に格納したミドルウェアをラップしたProcが入る。Procは[]で引数付きの呼び出しとなるのでe[a] は Procの第一引数をappにした呼び出しとなる。引数のappはmiddleware.newの第一引数としてセットされる。次のループではこのappをラップしたミドルウェアが変数a、次のミドルウェアが変数eに入るので、次のミドルウェアの第一引数にはappをラップしたミドルウェア(変数a)が入ることになる。
このようにしてappとミドルウェアが次々に入れ子になっていく。こんなイメージ
middleware1.new(app: middleware2.new(app: middleware3.new(app: Rails.application)))
このラップされたappをcallすると、最初に定義したミドルウェアから順にapp.call前の処理が評価されていき、appのcallが呼ばれ、app.callの後の処理が最後に定義したミドルウェアの順に処理されることになる。