Rails で URL と ルーティング設定のマッチ処理をどこでやってるのか調べたログ
きっかけ
Railsアプリケーションでは URL の末尾に .json
などと書いてレスポンスのフォーマットを指定することができる。先日、このフォーマット部分の末尾に記号をつけてリクエストしても正しく動くということに気がついた。
/v1/users.json
(これが正しいけど)/v1/users.json'
(これでも動くし)/v1/users.json!
(これでも動く)
でも一部の記号はエラーになる。
/v1/users.json.
(これはルーティングエラー)
ここで疑問が湧いてくる。
URLとルーティング設定のマッチ処理ってどうなってるんだろう?
たぶん正規表現でマッチングしてるんだろうけど、コードを追って調べてみましょう。
概要をつかむ
Rails のコードにおいてルーティング処理がどこに書いてあるのかよく知らないので、まずは Ruby on Rails Hacking Guide // Speaker Deck の「Chapter5. Routes」を読んでみる。この資料は Rails のコードを追うための導入にはぴったりだと思う。そうすると、以下のファイルあたりを見れば良いということがわかる。
- action_dispatch/routing/route_set.rb
- action_dispatch/routing/mapper.rb
- action_dispatch/journey/routes.rb
ここでは出てくる単語とか雰囲気がつかめたらいいと思うので次に進む。
スタックトレース
次にコントローラのアクションにディスパッチされるまでの流れを知るためにスタックトレースが欲しい。 コントローラのアクションメソッドの先頭に binding.pry と書いて開発用サーバを立ち上げる。リクエストを送ってデバッガを起動する。
で、止まったら、
pry-backtrace
とやるとスタックトレースが表示されるのでさっき掴んだものと突き合わせると、以下のあたりを見ればよさそうだというのがわかる。
vendor/bundle/ruby/2.1.0/gems/actionpack-4.2.2/lib/action_controller/metal.rb:237:in `block in action' vendor/bundle/ruby/2.1.0/gems/actionpack-4.2.2/lib/action_dispatch/routing/route_set.rb:74:in `call' vendor/bundle/ruby/2.1.0/gems/actionpack-4.2.2/lib/action_dispatch/routing/route_set.rb:74:in `dispatch' vendor/bundle/ruby/2.1.0/gems/actionpack-4.2.2/lib/action_dispatch/routing/route_set.rb:43:in `serve' vendor/bundle/ruby/2.1.0/gems/actionpack-4.2.2/lib/action_dispatch/journey/router.rb:43:in `block in serve' vendor/bundle/ruby/2.1.0/gems/actionpack-4.2.2/lib/action_dispatch/journey/router.rb:30:in `each' vendor/bundle/ruby/2.1.0/gems/actionpack-4.2.2/lib/action_dispatch/journey/router.rb:30:in `serve' vendor/bundle/ruby/2.1.0/gems/actionpack-4.2.2/lib/action_dispatch/routing/route_set.rb:819:in `call'
で、その中から以下のコードを見てみる(上のリストを下から見ていくと、 call
は読み飛ばして次にここが見るべき場所に思える)。
vendor/bundle/ruby/2.1.0/gems/actionpack-4.2.2/lib/action_dispatch/journey/router.rb
29 def serve(req) 30 find_routes(req).each do |match, parameters, route| 31 set_params = req.path_parameters 32 path_info = req.path_info 33 script_name = req.script_name 34 35 unless route.path.anchored 36 req.script_name = (script_name.to_s + match.to_s).chomp('/') 37 req.path_info = match.post_match 38 req.path_info = "/" + req.path_info unless req.path_info.start_with? "/" 39 end 40 41 req.path_parameters = set_params.merge parameters 42 43 status, headers, body = route.app.serve(req) 44 45 if 'pass' == headers['X-Cascade'] 46 req.script_name = script_name 47 req.path_info = path_info 48 req.path_parameters = set_params 49 next 50 end 51 52 return [status, headers, body] 53 end 54 55 return [404, {'X-Cascade' => 'pass'}, ['Not Found']] 56 end
あとはデバッガを使ってステップ実行していく。43行目の
status, headers, body = route.app.serve(req)
からステップインする。
vendor/bundle/ruby/2.1.0/gems/actionpack-4.2.2/lib/action_dispatch/routing/route_set.rb
32: def serve(req) 33: req.check_path_parameters! 34: params = req.path_parameters 35: => 36: prepare_params!(params) 37: 38: # Just raise undefined constant errors if a controller was specified as default. 39: unless controller = controller(params, @defaults.key?(:controller)) 40: return [404, {'X-Cascade' => 'pass'}, []] 41: end 42: 43: dispatch(controller, params[:action], req.env) 44: end
ここで params の中身を調べてみると・・・
[1] pry(#<ActionDispatch::Routing::RouteSet::Dispatcher>)> params => {:format=>"json", :controller=>"v1/users", :action=>"index"}
もう解決されている。ほむほむ。なんか違った。もっと手前か。
やりなおし。
29: def serve(req) 30: find_routes(req).each do |match, parameters, route| 31: set_params = req.path_parameters 32: path_info = req.path_info 33: script_name = req.script_name
たぶんここだろう。30行目の find_routes
にステップインする。
vendor/bundle/ruby/2.1.0/gems/actionpack-4.2.2/lib/action_dispatch/journey/router.rb
100 def find_routes req 101 routes = filter_routes(req.path_info).concat custom_routes.find_all { |r| 102 r.path.match(req.path_info) 103 }
match
とか出てきた。ここっぽい。
r
ってなんだ?
[1] pry(#<ActionDispatch::Journey::Router>)> r => #<ActionDispatch::Journey::Route:0x007f88bc7808b0
なるほど。このクラス、最初に見た資料で出てきたな。
r.path
は?
[2] pry(#<ActionDispatch::Journey::Router>)> r.path => #<ActionDispatch::Journey::Path::Pattern:0x007f88bc781ff8 @anchored=false, @names=[], @offsets=nil, @optional_names=nil, @re=nil, @required_names=nil, @requirements={}, @separators="/.?", @spec=#<ActionDispatch::Journey::Nodes::Cat:0x007f88bc782638 @left=#<ActionDispatch::Journey::Nodes::Slash:0x007f88bc782ca0 @left="/", @memo=nil>, @memo=nil, @right=#<ActionDispatch::Journey::Nodes::Literal:0x007f88bc782750 @left="assets", @memo=nil>>>
なるほど〜
Pattern って書いてあるし近づいてきた気がする。
次はr.path.match
の中を見よう。
164: def match(other) 165: return unless match = to_regexp.match(other) 166: MatchData.new(names, offsets, match) 167: end
to_regexp.match
だと。ここっぽい。こういうのを待ってた。
other
の中身を見てみるとパスが入ってる。
[1] pry(#<ActionDispatch::Journey::Path::Pattern>)> other => "/v1/users.json'"
to_regexp するとこうなる。
[2] pry(#<ActionDispatch::Journey::Path::Pattern>)> to_regexp => /\A\/assets/
あたりっぽい。
パスがマッチするところを見たいので他のブレークポイントを全部解除して、match
だけにブレークポイントを設定する。
で、other
の中身を見ながら順番に実行していく。
[1] pry(#<ActionDispatch::Journey::Path::Pattern>)> to_regexp => /\A\/v1\/users(?:\.([^\/.?]+))?\Z/
で、以下のようにマッチしたのであった。
[2] pry(#<ActionDispatch::Journey::Path::Pattern>)> next 164: def match(other) 165: binding.pry 166: return unless match = to_regexp.match(other) => 167: MatchData.new(names, offsets, match) 168: end [2] pry(#<ActionDispatch::Journey::Path::Pattern>)> match => #<MatchData "/v1/users.json'" 1:"json'">
満足。
最初の疑問に戻ると、.json
の部分にマッチする正規表現は以下。
(?:\.([^\/.?]+))
これは ドット(.)
と スラッシュ(/)、ドット(.)、はてな(?) 以外の1つ以上の文字列
にマッチするので、.json'
など末尾に記号があるときにマッチしたわけですね。で、その記号がドット(.)だとルーティングエラーになった(マッチしない)のも納得です。
まとめ
疑問を持ったのでコードを追って確認しました。またいい機会だったので、自分がコードを追う時のやり方も含めて書いてみました。
不思議な現象に出会った時に「そういうものだ」で済ますのもいいけど、ちょっと時間をとってコードを読んで確かめてみると勉強になるのでおすすめです。
マッチした .json'
の行方も追ってみたいけど時間がないので今日はここまで。