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' の行方も追ってみたいけど時間がないので今日はここまで。