LoginSignup
32
28

More than 5 years have passed since last update.

RailsのRoutingでどうHTTPメソッド解決をしているか追ってみた

Posted at

概要

すっごい今更感ありますが、RailsのRoutingを、routes.rbの定義から実際処理されているところまで追ってみました。

  • routes.rbに定義するHTTPメソッドを適当なものにしてたら?
  • viaで指定するメソッドってどこで解決されているの?
  • via :allと同等の指定の仕方ってどうするんだろう?

といった疑問がわいてきたので、一念発起して調べてみました。
ちなみに、パス解決については以下の記事が非常に分かりやすく勉強になりました。まずはこちらを読むのがオススメです。

環境

  • rails3, rails4のactionpack (なるべく環境差異がないように書いていきます)

Routeの登録部分

routes.rbに書く中身をどう扱っているか、少しずつ追っていきます。
まずrailsアプリのconfig/routes.rbを見てみる。

config/routes.rb
Hoge::Application.routes.draw do
  get 'hoge', to: 'hoge/index'
end

ActionDispatch::Routing::RouteSet編1

Application.routesは何者か?

Hoge::Application.routes #=> <ActionDispatch::Routing::RouteSet:0x000000053b1988> 

drawメソッドを見てみます。
route.rbの定義がblockで渡ってきて、eval_blockが呼ばれてる。

route_set.rb
261       def draw(&block)
262         clear! unless @disable_clear_and_finalize
263         eval_block(block)
264         finalize! unless @disable_clear_and_finalize
265         nil
266       end

...

276       def eval_block(block)
277         if block.arity == 1
278           raise "You are using the old router DSL which has been removed in Rails 3.1. " <<
279             "Please check how to update your routes file at: http://www.engineyard.com/blog/2010/the-lowdown-on-routes-in-rails-    3/"
280         end
281         mapper = Mapper.new(self)
282         if default_scope
283           mapper.with_default_scope(default_scope, &block)
284         else
285           mapper.instance_exec(&block)
286         end
287       end

Mapper.new(self)は省略。
Railsで特に何もしていなければdefault_scopeはnilのはず。
なのでmapper.instance_exec(&block)が呼ばれます。
つまり、routes.rbに定義したものがそのままメソッドとして呼ばれます。

ActionDispatch::Routing::Mapper編

例として、get 'hoge', to => 'hoge/index'が定義してある場合を見てみる。

mapper.rb
 461         def get(*args, &block)
 462           map_method(:get, *args, &block)
 463         end

 495         private
 496           def map_method(method, *args, &block)
 497             options = args.extract_options!
 498             options[:via] = method
 499             args.push(options)
 500             match(*args, &block)
 501             self
 502           end

argsにviaを追加してmatchを呼んでいる。

  • HTTPメソッドのうち、get, post, put, deleteは定義されている
    • map_methodを通過してviaが追加される
    • それ以外のHTTPメソッドをroutes.rbに書くとNoMethodErrorになるのはこのため
  • matchやroot等が定義してあり、対応したメソッドが呼ばれる

ではmatchが何をしてるかを見てみます。
@scopeとかoptionsとかゴソゴソしてるますが・・・

mapper.rb
1249         def match(path, *rest)
1250           if rest.empty? && Hash === path
1251             options  = path
1252             path, to = options.find { |name, value| name.is_a?(String) }
1253             options[:to] = to
1254             options.delete(path)
1255             paths = [path]
1256           else
1257             options = rest.pop || {}
1258             paths = [path] + rest
1259           end
1260 
1261           if @scope[:controller] && @scope[:action]
1262             options[:to] ||= "#{@scope[:controller]}##{@scope[:action]}"
1263           end
1264 
1265           path_without_format = path.to_s.sub(/\(\.:format\)$/, '')
1266           if using_match_shorthand?(path_without_format, options)
1267             options[:to] ||= path_without_format.gsub(%r{^/}, "").sub(%r{/([^/]*)$}, '#\1')
1268           end
1269 
1270           options[:anchor] = true unless options.key?(:anchor)
1271 
1272           if options[:on] && !VALID_ON_OPTIONS.include?(options[:on])
1273             raise ArgumentError, "Unknown scope #{on.inspect} given to :on"
1274           end
1275 
1276           paths.each { |_path| decomposed_match(_path, options.dup) }
1277           self
1278         end

最後self返す前のdecomposed_matchが気になる。ちなみに、pathsは[ "hoge" ]。
optionsは{:to=>"hoge/index", :via=>:get, :anchor=>true}となってます。
次に呼ばれるdecomposed_matchを見ます。

mapper.rb
1286         def decomposed_match(path, options) # :nodoc:
1287           if on = options.delete(:on)
1288             send(on) { decomposed_match(path, options) }
1289           else
1290             case @scope[:scope_level]
1291             when :resources
1292               nested { decomposed_match(path, options) }
1293             when :resource
1294               member { decomposed_match(path, options) }
1295             else
1296               add_route(path, options)
1297             end
1298           end
1299         end

ここで@scopeは{:path_names=>{:new=>"new", :edit=>"edit"}}なので、add_routeが呼ばれます。
ついにルートを定義するのか!?

mapper.rb
1301         def add_route(action, options) # :nodoc:
1302           path = path_for_action(action, options.delete(:path))
1303           action = action.to_s.dup
1304             
1305           if action =~ /^[\w\/]+$/
1306             options[:action] ||= action unless action.include?("/")
1307           else
1308             action = nil
1309           end
1310             
1311           if !options.fetch(:as, true)
1312             options.delete(:as)
1313           else
1314             options[:as] = name_for_action(options[:as], action)
1315           end
1317           mapping = Mapping.new(@set, @scope, path, options)
1318           app, conditions, requirements, defaults, as, anchor = mapping.to_route
1319           @set.add_route(app, conditions, requirements, defaults, as, anchor)
1320         end

Mapping生成したやつを使ってRouteSet#add_routeしてる。
mapping.to_route中で呼ばれるconditionsでviaの値が許可するリクエストメソッドとして変換されます。

  • routes.rbにgetやpostを定義した場合はviaにそのメソッドが指定されるのでここで変換される
  • via: [ :get, :post ] なら [ "GET", "POST" ] に
  • 指定がなければ何もしない

途中省略しますが、変換してるのは以下のメソッド。

mapping.rb
 201           def request_method_condition
 202             if via = @options[:via]
 203               list = Array(via).map { |m| m.to_s.dasherize.upcase }
 204               { :request_method => list }
 205             else
 206               { }
 207             end
 208           end 

ActionDispatch::Routing::RouteSet編2

結局add_routeするのはどんな値?

app.class     #=> ActionDispatch::Routing::RouteSet::Dispatcher
conditions    #=> {:path_info=>"/hoge(.:format)", :request_method=>["GET"]}
requirements  #=> {}
defaults      #=> {:action=>"hoge", :controller=>"hoge/index"}
as            #=> "hoge"
anchor        #=> true

マッチした場合にコントローラ呼ぶためのDispatcherができました。
マッチする条件はパスとメソッドを含んでるconditionかな。
などなどでRouteSetに登録してる、と。
最後はRouteSet#add_route。

route_set.rb
365       def add_route(app, conditions = {}, requirements = {}, defaults = {}, name = nil, anchor = true)
366         raise ArgumentError, "Invalid route name: '#{name}'" unless name.blank? || name.to_s.match(/^[_a-z]\w*$/i)
367 
368         path = build_path(conditions.delete(:path_info), requirements, SEPARATORS, anchor)
369         conditions = build_conditions(conditions, valid_conditions, path.names.map { |x| x.to_sym })
370 
371         route = @set.add_route(app, path, conditions, defaults, name)
372         named_routes[name] = route if name
373         route 
374       end

ここの@setはJourney::Routes。Rails4ではactionpackに組み込まれてますね。
pathはJourney::Path::Pattern、conditionsからはpathが切り出されます。
Journey::Route#add_routeでルートの出来上がり。

これがroutes.rb各定義全部で実行されます。
どう使われるかは後述。

実際のリクエストをRoute使ってどう判別しているか

パス解決は最初にお勧めしたリンク先に詳しいです。
いきなりJourney::Routerから行きますがご了承ください。

Journey::Router編

参考記事でパス解決してたのもこの中でした。

router.rb
124     def find_routes env
125       req = request_class.new env
126 
127       routes = filter_routes(req.path_info) + custom_routes.find_all { |r|
128         r.path.match(req.path_info)
129       }
130         
131       routes.sort_by(&:precedence).find_all { |r|
132         r.constraints.all? { |k,v| v === req.send(k) } &&
133           r.verb === req.request_method
134       }.reject { |r| req.ip && !(r.ip === req.ip) }.map { |r|
135         match_data  = r.path.match(req.path_info)
136         match_names = match_data.names.map { |n| n.to_sym }
137         match_values = match_data.captures.map { |v| v && Utils.unescape_uri(v) }
138         info = Hash[match_names.zip(match_values).find_all { |_,y| y }]
139         
140         [match_data, r.defaults.merge(info), r]
141       } 
142     end 

ここで、routesには何が入っているかというと、routesに定義したルートのうち、リクエストにマッチしたもの全てが配列で入っています。
中身は前述のJourney::Route。
で、HTTPメソッドの検証はここ。

router.rb
131       routes.sort_by(&:precedence).find_all { |r|
132         r.constraints.all? { |k,v| v === req.send(k) } &&
133           r.verb === req.request_method
134       }.reject { |r| req.ip && !(r.ip === req.ip) }.map { |r|

リクエストメソッドとrouteのverbが一致するものがここで決まります。
viaでメソッドを絞っている場合等はここでそのrouteが除外されます。

該当のコントローラ・アクションの呼び出し

次に呼び出し元のcallメソッドに戻ります。

router.rb
 53     def call env
 54       env['PATH_INFO'] = Utils.normalize_path env['PATH_INFO']
 55 
 56       find_routes(env).each do |match, parameters, route|
 57         script_name, path_info, set_params = env.values_at('SCRIPT_NAME',
 58                                                            'PATH_INFO',
 59                                                            @params_key)
 60 
 61         unless route.path.anchored
 62           env['SCRIPT_NAME'] = (script_name.to_s + match.to_s).chomp('/')
 63           env['PATH_INFO']   = Utils.normalize_path(match.post_match)
 64         end
 65         env[@params_key] = (set_params || {}).merge parameters
 66 
 67         status, headers, body = route.app.call(env)
 68 
 69         if 'pass' == headers['X-Cascade']
 70           env['SCRIPT_NAME'] = script_name
 71           env['PATH_INFO']   = path_info
 72           env[@params_key]   = set_params
 73           next
 74         end
 75 
 76         return [status, headers, body]
 77       end
 78 
 79       return [404, {'X-Cascade' => 'pass'}, ['Not Found']]
 80     end

見つかったrouteに登録されているappをcallしてる。
つまり、ActionDispatch::Routing::RouteSet::Dispatcherがcallされ、該当のcontrollerのアクションが呼ばれ、returnでレスポンスが返ります。
一致するものがなければ404が返るのが最終行からわかりますね。

大分時間がかかりましたがルーティングの挙動が分かりました!
読んでいただいた方もお疲れ様でした!!!

まとめ (個人的な疑問解決)

概要に書いてた疑問は以下のように解決!

  • routes.rbに定義するHTTPメソッドを適当なものにしてたら?
    • => NoMethodError
  • viaで指定するメソッドってどこで解決されているの?
    • => 定義 : ActionDispatch::Routing::Mapper
    • => 比較 : Journey::Router
  • via :allと同等の指定の仕方ってどうするんだろう?
    • => Rails3 : ならなにも書かなければOK
    • => Rails4 : match使う場合は書かなきゃいけないけどそもそもmatch非推奨
    • どうしても書くならActionDispatch::Request::HTTP_METHODS
    • 普通のリクエストだけでいいならActionDispatch::Routing::HTTP_METHODS

参考

32
28
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
32
28