2017-10-05

5分で試すRackミドルウェア

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の後の処理が最後に定義したミドルウェアの順に処理されることになる。

参考URL

このエントリーをはてなブックマークに追加