概要
すっごい今更感ありますが、RailsのRoutingを、routes.rbの定義から実際処理されているところまで追ってみました。
- routes.rbに定義するHTTPメソッドを適当なものにしてたら?
- viaで指定するメソッドってどこで解決されているの?
- via :allと同等の指定の仕方ってどうするんだろう?
といった疑問がわいてきたので、一念発起して調べてみました。
ちなみに、パス解決については以下の記事が非常に分かりやすく勉強になりました。まずはこちらを読むのがオススメです。
環境
- rails3, rails4のactionpack (なるべく環境差異がないように書いていきます)
Routeの登録部分
routes.rbに書く中身をどう扱っているか、少しずつ追っていきます。
まずrailsアプリの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が呼ばれてる。
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'が定義してある場合を見てみる。
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とかゴソゴソしてるますが・・・
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を見ます。
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が呼ばれます。
ついにルートを定義するのか!?
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" ] に
- 指定がなければ何もしない
途中省略しますが、変換してるのは以下のメソッド。
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。
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編
参考記事でパス解決してたのもこの中でした。
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メソッドの検証はここ。
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メソッドに戻ります。
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