2018-06-18

Rodaコードリーディング

Rodaのコードリーディングをしました。バージョンは3.8.0です。

以下のconfig.ruで追ってみます。

require "roda"

class App < Roda
  route do |r|
    r.root do
      r.redirect '/hello'
    end

    r.on 'hello' do
      @greeting = 'hey!'
      r.get 'world' do
        "#{@greeting} world"
      end

      r.is do
        r.get do
          "#{@greeting}"
        end

        r.post do
          puts "someone said #{@greeting}"
          r.redirect
        end
      end
    end
  end
end
run App.freeze.app

RodaクラスはRodaPlugins::Base::ClassMethodsをextendsしてクラスメソッドを定義して、#pluginメソッドを呼び出します。

class Roda
# ...
  extend RodaPlugins::Base::ClassMethods
  plugin RodaPlugins::Base
end

#pluginは以下のクラスに対して各モジュールをinclude/extendしています。

    module Base
      # Class methods for the Roda class.
      module ClassMethods
        def plugin(plugin, *args, &block)
          raise RodaError, "Cannot add a plugin to a frozen Roda class" if frozen?
          plugin = RodaPlugins.load_plugin(plugin) if plugin.is_a?(Symbol)
          plugin.load_dependencies(self, *args, &block) if plugin.respond_to?(:load_dependencies)
          include(plugin::InstanceMethods) if defined?(plugin::InstanceMethods)
          extend(plugin::ClassMethods) if defined?(plugin::ClassMethods)
          self::RodaRequest.send(:include, plugin::RequestMethods) if defined?(plugin::RequestMethods)
          self::RodaRequest.extend(plugin::RequestClassMethods) if defined?(plugin::RequestClassMethods)
          self::RodaResponse.send(:include, plugin::ResponseMethods) if defined?(plugin::ResponseMethods)
          self::RodaResponse.extend(plugin::ResponseClassMethods) if defined?(plugin::ResponseClassMethods)
          plugin.configure(self, *args, &block) if plugin.respond_to?(:configure)
          nil
        end

クラスメソッドのrouteはRodaPlugins::ClassMethodsのメソッドで以下のように定義されています。

        def route(&block)
          @route_block = block
          build_rack_app
        end

#build_rack_appはlambdaを使って#callを持つRackアプリを作ります。ミドルウェアが定義されている場合は逆順に元のRackアプリをラップしていきます。app.call(env)の呼び出しで、Roda.new(env).call(routeのblock)が呼ばれます。

        def build_rack_app
          if block = @route_block
            app = lambda{|env| new(env).call(&block)}
            @middleware.reverse_each do |args, bl|
              mid, *args = args
              app = mid.new(app, *args, &bl)
              app.freeze if opts[:freeze_middleware]
            end
            @app = app
          end
        end

Rodaのコンストラクタや#callはInstanceMethodsに定義されています。RodaRequestはRack::Requestを継承しています。

        def initialize(env)
          klass = self.class
          @_request = klass::RodaRequest.new(self, env)
          @_response = klass::RodaResponse.new
        end

        def call(&block)
          catch(:halt) do
            r = @_request
            r.block_result(instance_exec(r, &block))
            @_response.finish
          end
        end

#callはcatch(:halt)で大域脱出の経路を確保しつつ、blockの中身をRodaRequestのコンテキストで実行します。

RodaRequest#root(RequestMethods#root)は以下のように定義されておりENV["PATH_INFO"]が"/"と一致するGETリクエストであれば#alwaysが実行されます。

        def root(&block)
          if remaining_path == "/" && is_get?
            always(&block)
          end
        end

#alwaysは渡されたブロックを実行した結果を#block_resultに渡して、throw :haltによってcallのcatch(:halt)まで抜けます。

        def always
          block_result(yield)
          throw :halt, response.finish
        end

RodaResponse#finishは以下のように@statusや@bodyからRackレスポンスを返します。

        def finish
          b = @body
          empty = b.empty?
          s = (@status ||= empty ? 404 : default_status)
          set_default_headers
          h = @headers

          if empty && (s == 304 || s == 204 || s == 205 || (s >= 100 && s <= 199))
            h.delete("Content-Type")
          else
            h["Content-Length"] ||= @length.to_s
          end

          [s, h, b]
        end

#block_resultはRodaResponse#writeを呼び出してレスポンスのデータを書き出します。

      module RequestMethods
        def block_result(result)
          res = response
          if res.empty? && (body = block_result_body(result))
            res.write(body)
          end
        end

        def response
          @scope.response
        end

        def block_result_body(result)
          case result
          when String
            result
          when nil, false
            # nothing
          else
            raise RodaError, "unsupported block result: #{result.inspect}"
          end
        end

      module ResponseMethods
        def write(str)
          s = str.to_s
          @length += s.bytesize
          @body << s
          nil
        end

RodaRequest#redirect(RequestMethods#redirect)は以下のように定義されており、RodaResponse#redirectを呼び出してthrow :haltします。

        def redirect(path=default_redirect_path, status=default_redirect_status)
          response.redirect(path, status)
          throw :halt, response.finish
        end

      module ResponseMethods
        def redirect(path, status = 302)
          @headers["Location"] = path
          @status  = status
          nil
        end

RodaRequest#onは以下のように定義されており、引数指定されていない場合は#always、引数指定されている場合は#if_matchを呼び出します。

        def on(*args, &block)
          if args.empty?
            always(&block)
          else
            if_match(args, &block)
          end
        end

引数指定されているケースを追っていきます。

#if_matchは#match_allでパスがマッチした場合は#block_resultを呼び出して引数のブロックを評価します。

        def if_match(args)
          path = @remaining_path
          @captures.clear

          if match_all(args)
            block_result(yield(*captures))
            throw :halt, response.finish
          else
            @remaining_path = path
            false
          end
        end

#getはGETリクエストの場合に#_verbメソッドを呼び出します。引数があればTERM(=Object.newのユニークな定数)を引数に追加してから#if_matchを呼び出します。TERMは /hoge/:id/hogeを区別するための番兵オブジェクトでパスの終端を意味しています。

        def get(*args, &block)
          _verb(args, &block) if is_get?
        end

        def is_get?
          @env["REQUEST_METHOD"] == 'GET'
        end

        def _verb(args, &block)
          if args.empty?
            always(&block)
          else
            args << TERM
            if_match(args, &block)
          end
        end

#isは引数が無く評価するパスが無い場合は#always、引数がある場合はTERMを番兵として追加して#if_matchを評価します。

        def is(*args, &block)
          if args.empty?
            if empty_path?
              always(&block)
            end
          else
            args << TERM
            if_match(args, &block)
          end
        end

最後に/hoge/:idなどのパラメータがある場合の処理について見ていきます。

config.ruはこんな感じになります。

class App < Roda
  route do |r|
    r.get "hoge", Integer do |id|
      id.to_s
    end
# ...

RodaRequest#getには2つ引数が渡ります。再掲すると以下のような処理になっていて、引数があるのでargsには ['hoge', Integer, TERM]の配列が入り、#if_matchが呼び出されます。

        def get(*args, &block)
          _verb(args, &block) if is_get?
        end

        def _verb(args, &block)
          if args.empty?
            always(&block)
          else
            args << TERM
            if_match(args, &block)
          end
        end

#if_matchはargsを引数に#match_allを呼びだします。#match_allはargsの各々の要素に対して#matchを呼び出します。

        def match(matcher)
          case matcher
          when String
            _match_string(matcher)
          when Class
            _match_class(matcher)
          when TERM
            empty_path?
# ...
          when true, false, nil
            matcher
          else
            unsupported_matcher(matcher)
          end
        end

'hoge'はStringクラスなので#_match_stringが呼ばれます

        def _match_string(str)
          rp = @remaining_path
          if rp.start_with?("/#{str}")
            last = str.length + 1
            case rp[last]
            when "/"
              @remaining_path = rp[last, rp.length]
            when nil
              @remaining_path = ""
            end
          end
        end

リクエストパス(正確にはネストした先のremaining_path)が"/hoge"から始まっていれば@remaing_pathに残りのパスを入れます。

IntegerはClassクラスなので#_match_classが呼ばれます。Integerの場合はさらに#_match_class_Integerが呼ばれます。

        def _match_class(klass)
          meth = :"_match_class_#{klass}"
          if respond_to?(meth, true)
            # Allow calling private methods, as match methods are generally private
            send(meth)
          else
            unsupported_matcher(klass)
          end
        end

        # Match integer segment, and yield resulting value as an
        # integer.
        def _match_class_Integer
          consume(/\A\/(\d+)(?=\/|\z)/){|i| [i.to_i]}
        end

#_match_class_IntegerはIntegerに対する正規表現を生成して、それを引数に#consumeメソッドを呼び出します。

        def consume(pattern)
          if matchdata = remaining_path.match(pattern)
            @remaining_path = matchdata.post_match
            captures = matchdata.captures
            captures = yield(*captures) if block_given?
            @captures.concat(captures)
          end
        end

remaining_pathがパターンにマッチした場合はpost_matchした部分をremaining_pathに入れてから、@capturesにマッチしたパラメータをセットします。

この@capturesはmatch_allのif文内のyieldの引数として指定されます。yieldされるブロックは#getで指定されたブロックになります。

def if_match(args)
          path = @remaining_path
          @captures.clear

          if match_all(args)
            block_result(yield(*captures))
            throw :halt, response.finish
          else
# ...

こうしてパスから正規表現を使ってパラメータを抽出してブロックの引数としてセットしています。

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