LoginSignup
3
3

More than 3 years have passed since last update.

Sinatraを支えるRuby記法 - 処理編

Posted at

はじめに

ここまで、パスへのリクエストに対する処理の登録サーバ起動のからくりについて見てきました。最後に実際にリクエストが来たときにどのような処理が行われるのかについて見ていきましょう。

Sinatra::Base.call(クラスメソッド)

前回も少し説明しましたが、Rackハンドラに渡されているのはApplicationクラスオブジェクトです。Rubyはクラスもオブジェクトなのでcall(env)というメソッド(クラスなのでクラスメソッド=特異メソッド)さえあればRackハンドラは文句は言いません。
というわけで、読解の起点となるのはこのcallです。

sinatra/base.rbより抜粋
      def call(env)
        synchronize { prototype.call(env) }
      end

prototypeは前回見たようにインスタンスを作っておくメソッドです。というか名前から作っておいたインスタンスをテンプレに処理を行ってそうです。
synchronizeは想像通りなものなので省略。ちなみにデフォルトは排他制御オフのようです。

Sinatra::Base#call(インスタンスメソッド)

prototypeで作られるのはミドルウェアが設定されたアプリケーションオブジェクトをさらにくるんだWrapperオブジェクトですが、起動編で前置きしたようにそこは無視してApplicationオブジェクトのcallインスタンスメソッドに進みます。

sinatra/base.rbより抜粋
    # Rack call interface.
    def call(env)
      dup.call!(env)
    end

dupした上でcall!メソッドが呼ばれています。つまり、テンプレをコピーして個々のリクエストを処理しているようです。

call!に進む。invoke { dispatch! }が処理本体のようです。
なお登録編でもコメントしましたがSinatraでは「!」をinternal的な意味で使っているように思われます。

sinatra/base.rbより抜粋
    def call!(env) # :nodoc:
      @env      = env
      @params   = IndifferentHash.new
      @request  = Request.new(env)
      @response = Response.new
      template_cache.clear if settings.reload_templates

      @response['Content-Type'] = nil
      invoke { dispatch! }
      invoke { error_block!(response.status) } unless @env['sinatra.error']

      unless @response['Content-Type']
        if Array === body and body[0].respond_to? :content_type
          content_type body[0].content_type
        else
          content_type :html
        end
      end

      @response.finish
    end

invokeメソッド

invokeメソッドに進む。

sinatra/base.rbより抜粋
    # Run the block with 'throw :halt' support and apply result to the response.
    def invoke
      res = catch(:halt) { yield }

      res = [res] if Integer === res or String === res
      if Array === res and Integer === res.first
        res = res.dup
        status(res.shift)
        body(res.pop)
        headers(*res)
      elsif res.respond_to? :each
        body res
      end
      nil # avoid double setting the same response tuple twice
    end

ここでポイントとなるのは次の二点です。

  • Sinatraはthrow-catchの仕組みを使っているようだ。なおJavaとかに慣れてる人には誤解を与えると思いますがRubyのthrow-catchは例外処理ではなく大域脱出を行う仕組みです。
  • というわけで大域脱出して返された値をもとにレスポンスを設定している様子。ここで使われているstatusやbodyについては後ほど触れます。

dispatch!メソッド

invokeメソッドでyieldされているものは「invokeメソッドにブロックとして渡されたもの」、つまり、dispatch!メソッド呼び出しです。

sinatra/base.rbより抜粋
    # Dispatch a request with error handling.
    def dispatch!
      @params.merge!(@request.params).each { |key, val| @params[key] = force_encoding(val.dup) }

      invoke do
        static! if settings.static? && (request.get? || request.head?)
        filter! :before
        route!
      end
    rescue ::Exception => boom
      invoke { handle_exception!(boom) }
    ensure
      begin
        filter! :after unless env['sinatra.static_file']
      rescue ::Exception => boom
        invoke { handle_exception!(boom) } unless @env['sinatra.error']
      end
    end

なんとまたinvokeメソッドが呼ばれています。ここがどう動いているのかについては後で詳しく説明するとしてroute!に進みます(static!やfilter!は名前通りのことをやっているので飛ばします)

route!メソッド

登録編で見た「routeクラスメソッド」は登録処理で、「route!インスタンスメソッド」は実際のリクエストの処理というのはなかなか名前付けがいまいちに思うわけですがroute!の定義です。

sinatra/base.rbより抜粋
    # Run routes defined on the class and all superclasses.
    def route!(base = settings, pass_block = nil)
      if routes = base.routes[@request.request_method]
        routes.each do |pattern, conditions, block|
          returned_pass_block = process_route(pattern, conditions) do |*args|
            env['sinatra.route'] = "#{@request.request_method} #{pattern}"
            route_eval { block[*args] }
          end

          # don't wipe out pass_block in superclass
          pass_block = returned_pass_block if returned_pass_block
        end
      end

      # Run routes defined in superclass.
      if base.superclass.respond_to?(:routes)
        return route!(base.superclass, pass_block)
      end

      route_eval(&pass_block) if pass_block
      route_missing
    end

今までに比べると少し複雑ですが、雰囲気で読むと以下のようになっていると思われます。

  1. baseで示されるオブジェクト(デフォルトはsettings)から登録されてる処理の一覧を取り出して、
  2. process_routeメソッド呼び出してリクエストパスに対応する処理かチェック、
  3. 対応する処理ならroute_evalが呼び出される。

一つずつちゃんと確かめていきましょう。

settings

まずはsettingsです。「デフォルト値として設定されているsettingsってなんだ?」と思われるかもしれませんが実はメソッドです。Rubyはデフォルト引数としてメソッド呼び出しが書ける言語となっています。

というわけで「settingsメソッド」を探すと以下のようになっています。要するにApplicationクラスオブジェクトですね。登録編で処理が「Applicationクラスの@routes」に登録されていたのを覚えているでしょうか。そこから取り出してマッチングが行われているわけです。

sinatra/base.rbより抜粋
    # Access settings defined with Base.set.
    def settings
      self.class.settings
    end

    # Access settings defined with Base.set.
    def self.settings
      self
    end

process_route

次はprocess_routeです。

sinatra/base.rbより抜粋
    # If the current request matches pattern and conditions, fill params
    # with keys and call the given block.
    # Revert params afterwards.
    #
    # Returns pass block.
    def process_route(pattern, conditions, block = nil, values = [])
      route = @request.path_info
      route = '/' if route.empty? and not settings.empty_path_info?
      route = route[0..-2] if !settings.strict_paths? && route != '/' && route.end_with?('/')
      return unless params = pattern.params(route)

      params.delete("ignore") # TODO: better params handling, maybe turn it into "smart" object or detect changes
      force_encoding(params)
      original, @params = @params, @params.merge(params) if params.any?

      regexp_exists = pattern.is_a?(Mustermann::Regular) || (pattern.respond_to?(:patterns) && pattern.patterns.any? {|subpattern| subpattern.is_a?(Mustermann::Regular)} )
      if regexp_exists
        captures           = pattern.match(route).captures.map { |c| URI_INSTANCE.unescape(c) if c }
        values            += captures
        @params[:captures] = force_encoding(captures) unless captures.nil? || captures.empty?
      else
        values += params.values.flatten
      end

      catch(:pass) do
        conditions.each { |c| throw :pass if c.bind(self).call == false }
        block ? block[self, values] : yield(self, values)
      end
    rescue
      @env['sinatra.error.params'] = @params
      raise
    ensure
      @params = original if original
    end

これも少し長いですが、http://localhost:4567 にアクセスされたとすると結局実行されるのは以下となります。

process_route超抜粋
    def process_route(pattern, conditions, block = nil, values = [])
      yield(self, values)
    end

route!に戻って、process_routeからyieldされるブロックを改めて見てみましょう。

route!の一部
        routes.each do |pattern, conditions, block|
          returned_pass_block = process_route(pattern, conditions) do |*args|
            env['sinatra.route'] = "#{@request.request_method} #{pattern}"
            route_eval { block[*args] }
          end

*argsとして渡されるのは、selfとvaluesです。これを登録されているblockに渡す・・・、[]で囲んでいるのがこれは、block([*args])と解釈されるようです。つまり、*argsと受け取った残余引数を展開し、改めて配列として渡す、意味あるのかな。昔はこう書かないと動かなかったとか?

さて、このblockが何者かというのは登録編の復習になりますが以下となります。

compile!の一部
        wrapper                 = block.arity != 0 ?
          proc { |a, p| unbound_method.bind(a).call(*p) } :
          proc { |a, p| unbound_method.bind(a).call }

つまり、self(Applicationオブジェクト)がunboundなメソッド(オブジェクトに結び付いてないメソッド。ここでは登録編でインスタンスメソッドとして登録されたパスに対するリクエスト処理のブロック)バインドされ、呼び出されています。

route_eval

上で説明した「リクエストに対する処理」はroute_evalにブロックとして渡されるので、route_evalでyieldされないと実際には実行されません。というわけでroute_evalを見てみましょう。

sinatra/base.rbより抜粋
    # Run a route block and throw :halt with the result.
    def route_eval
      throw :halt, yield
    end

渡されたブロックがyieldされ、その戻り値(リクエストの処理ブロックからの戻り値)を引数にthrowが行われています。

invoke再訪

route_evalではthrowが行われていました。これは対応する(同じシンボルを指定して待っている)catchまで呼び出しを一気に戻るという仕組みになっています。:haltで待っているのはどこかというとinvokeメソッドです。

sinatra/base.rbより抜粋
    # Run the block with 'throw :halt' support and apply result to the response.
    def invoke
      res = catch(:halt) { yield }

      res = [res] if Integer === res or String === res
      if Array === res and Integer === res.first
        res = res.dup
        status(res.shift)
        body(res.pop)
        headers(*res)
      elsif res.respond_to? :each
        body res
      end
      nil # avoid double setting the same response tuple twice
    end

ただし、ここまで見てきた中でinvokeは二回呼び出されていました。call!からroute_evalに至るまでの呼び出しは以下のようになっています。

call!
  invoke
    dispatch!
      invoke
        route!
          process_route
            route_eval

この場合、内側のinvokeまで戻るということになります。その後、dipatch!では(登録されていたら)後処理が実行され、外側のinvokeにはnilが返されるので何もしないというようになっているようです。何故このようなややこしいことしているのかについては「歴史的経緯」な気がしますがともかくthrow-catchの大域脱出を使うことで処理を一気に巻き戻すということが行われています。

Sinatra::Helpers

最後に複線しておいたstatusやbodyについて。Sinatraではステータスコードやボディーを戻り値として返す以外に以下のように書くことも可能です。個人的には宣言的に書けるので好きです。

get '/' do
  status 200
  body   'Hello world!'
end

大体察しはつくと思いますがこのstatusやbodyはApplicationオブジェクトのインスタンスメソッドです。もっとも「DSLとして」Sinatraを使う人にはインスタンスメソッドであるということは意識せずに書けるようになっています。
これらのメソッドはSinatra::Helpersモジュールに定義されており、Baseクラスがインクルードする、リクエストの処理ブロックは先に見たようにApplicationオブジェクトのインスタンスメソッドのため、インクルードされているメソッドが使える(それらを介してインスタンス変数を操作する)というからくりになっています。

sinatra/base.rbより抜粋
  # Methods available to routes, before/after filters, and views.
  module Helpers
    # Set or retrieve the response status code.
    def status(value = nil)
      response.status = Rack::Utils.status_code(value) if value
      response.status
    end

処理編まとめ

以上ここまでリクエストが来たときにどのように処理が行われるのかについて見てきました。登録編、起動編に比べると少し複雑でした。

  • クラスはオブジェクトなので、呼び出し側の指定を満たすメソッドが定義していれば特異メソッドでも問題ない(呼ばれる側として渡すことができる)
  • リクエストが来るとあらかじめ作成しておいた「アプリケーションオブジェクト」を複製し、リクエストに割り当てる。再利用とかを行っていないのは「そこまでパフォーマンスを追求してないから」と思われる。
  • Rubyではデフォルト引数にメソッド呼び出しを書くことができる。
  • 大域脱出を行うためにthrow-catchが使われている(例外処理ではない)

おわりに

三回にわたってSinatraの中身がどうなっているかを見てきました。「DSLをどう実現してるんだろ?(あと、マイクロフレームワークと呼ばれる類の実装を見てみたい)」という興味から読んでみましたがとてもRubyを使い倒したコードになっていたように思います。明日書くコードの役には立たなそうですが参考になりました(笑)

3
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
3