本文嘗試觀察 Rails 透過什麼方式來解析輸入的網址,找到對應的 controller action 以及 params,然後自幹一個。
參考資料
由於篇幅的關係,請先花個 1~2 分鐘大概讀一下參考資料再繼續往下閱讀。
想要自幹一個 Rails Router 就要從了解 Rails Router 開始。
了解 Rails Router
在參考資料中,我們得到了一個寶貴的資訊:
ActionDispatch::Routing::RouteSet 是一個 Rack App。
也就是說 RouteSet 有一個 call 方法,並且他會傳回符合 Rack 規範的內容。
https://github.com/rails/rails/blob/master/actionpack/lib/actiondispatch/routing/routeset.rb#L832:
def call(env)
req = make_request(env)
req.path_info = Journey::Router::Utils.normalize_path(req.path_info)
@router.serve(req)
end
這就是 Rack App 的進入點。他在這裡是製作 request,然後丟給 @router.serve
方法去處理。
這個 @router 的 class 是 ActionDispatch::Journey::Router
。
https://github.com/rails/rails/blob/master/actionpack/lib/action_dispatch/journey/router.rb#L31
def serve(req)
find_routes(req).each do |match, parameters, route|
# 略
status, headers, body = route.app.serve(req)
# 略
return [status, headers, body]
end
[404, { "X-Cascade" => "pass" }, ["Not Found"]]
end
大意:用 find_routes
找出對應的 route 之後,執行 route.app.serve
。
這個 route 的 class 是 ActionDispatch::Journey::Route
。
這個 route.app 的 class 是 ActionDispatch::Routing::RouteSet::Dispatcher
。
我們繼續看他到底 serve 了什麼。
https://github.com/rails/rails/blob/master/actionpack/lib/actiondispatch/routing/routeset.rb#L29:
def serve(req)
params = req.path_parameters
controller = controller req
res = controller.make_response! req
dispatch(controller, params[:action], req, res)
rescue ActionController::RoutingError
if @raise_on_name_error
raise
else
[404, { "X-Cascade" => "pass" }, []]
end
end
def controller(req)
req.controller_class
rescue NameError => e
raise ActionController::RoutingError, e.message, e.backtrace
end
def dispatch(controller, action, req, res)
controller.dispatch(action, req, res)
end
在這裡可以看出,他直接從 req.controller_class
找到對應的 controller 並且做處理,然後就回傳結果了。
可以看出 controller class 需要支援兩個方法:
- make_response!(request)
- dispatch(action, request, response)
且 dispatch 的輸入值 response 正好就是 make_response! 的輸出。
自幹一個 Rack App
所以我們可以仿照參考資料的做法,自己做一個 Rack App 來試用看看:
首先弄一個空資料夾,然後在裡面建立一個 config.ru
檔案,內容如下:
require 'action_dispatch'
class EtrexController
def self.make_response!(request)
end
def self.dispatch(action, req, res)
[200, {"Content-Type" => "text/html"}, ['QQ']]
end
end
app = ActionDispatch::Routing::RouteSet.new
app.draw do
get 'etrex', to: 'etrex#index'
end
run app
因為 makeresponse! 的輸出也只有我們自己用,所以就乾脆不在 makeresponse! 做事了。
你可以在 bash 下用 rackup
指令來執行這個 Rack App 。
接著在瀏覽器輸入對應的網址 http://localhost:9292/etrex 就可以看到結果「QQ」。
繼續了解 Rails Router
我們已經知道整個 RouterSet 大概做了些什麼事,接下來我們就專注在本文重點:Router 是如何將 request 轉換為 controller action 和 params。
def find_routes(req)
routes = filter_routes(req.path_info).concat custom_routes.find_all { |r|
r.path.match(req.path_info)
}
routes =
if req.head?
match_head_routes(routes, req)
else
match_routes(routes, req)
end
routes.sort_by!(&:precedence)
routes.map! { |r|
match_data = r.path.match(req.path_info)
path_parameters = {}
match_data.names.zip(match_data.captures) { |name, val|
path_parameters[name.to_sym] = Utils.unescape_uri(val) if val
}
[match_data, path_parameters, r]
}
end
這個程式碼很長,我們一段一段來解讀。
routes = filter_routes(req.path_info).concat custom_routes.find_all { |r|
r.path.match(req.path_info)
}
因為我先偷看了 filterroutes 的實作,所以我知道這段是用一個不單純的演算法,根據網址來排除 routes,而後面的 `.concat customroutes ...` 可以先忽略,感覺不是重點。
routes =
if req.head?
match_head_routes(routes, req)
else
match_routes(routes, req)
end
這段則是根據 http method 來排除 routes。
routes.sort_by!(&:precedence)
routes.map! { |r|
match_data = r.path.match(req.path_info)
path_parameters = {}
match_data.names.zip(match_data.captures) { |name, val|
path_parameters[name.to_sym] = Utils.unescape_uri(val) if val
}
[match_data, path_parameters, r]
}
因為有可能多重 match,這裡就依照優先序排序,然後把所有結果都整理好傳回。同時,他也在這裡做了網址參數的解析。
其中的 r.path 的 class 是 ActionDispatch::Journey::Path::Pattern。
r.path.match 方法如下:
def match(other)
return unless match = to_regexp.match(other)
MatchData.new(names, offsets, match)
end
我們從上而下的解讀,未必要完全理解,透過實際觀察其輸入輸出,就能知道他大概的意圖。
他這裡想要解析的參數是在 router 規則中的 etrex/:id
之類的變數。
舉例來說,我們會期待用一個 get 'etrex/:id'
來捕捉所有 etrex/1
、etrex/2
、etrex/3
... 的網址。
而我們希望 router 能從中解析出參數 {id: 1},而這段 match 就是在做這件事。
觀察小結
Rails 的 router 中有許多的 route,每一個 route 負責保存一組規則,明確指向特定的 controller 以及 action。在建立 route 時,就已經進行了一些預處理來做加速。
Rails 在 router 上定義 find_routes 方法,比對 uri 以及 http method 來找出需要的 route,並且即時生成正規表示式來做參數解析。
開始自幹 Router
我們的目標是要讓 get 'etrex/:id', to: 'etrex#show'
能運作,儘量用最少最好讀的 code 來完成,效能什麼的在這裡不是重點。
require 'action_dispatch'
require 'cgi'
class Router
def initialize
@routes = []
end
# 設定規則
def draw(&block)
self.instance_exec(&block)
end
def get(pattern, options)
@routes << Route.new('get', pattern, options)
end
# 程式進入點
def call(env)
route = find_route(env)
return route.serve(env) if route.present?
[404, {"Content-Type" => "text/html"}, ['404 not found']]
end
private
def find_route(env)
method = env['REQUEST_METHOD'].downcase
path = env['PATH_INFO'].downcase
@routes.find do |route|
route.match?(method, path)
end
end
end
class Route
def initialize(method, pattern, options)
@method = method.downcase
@pattern = pattern.downcase
controller_name, @action = options[:to].split('#')
@controller = controller_class(controller_name)
end
def match?(method, path)
match_method(method) && match_path(path)
end
def serve(env)
query_string_params = Rack::Utils.parse_nested_query env['QUERY_STRING']
params = in_path_params.merge(query_string_params)
@controller.dispatch(@action, params, env)
end
private
def in_path_params
@in_path_params
end
def controller_class(controller_name)
Object.const_get("#{ controller_name.capitalize }Controller")
end
def match_method(method)
@method == method
end
def match_path(path)
path = path[1..-1] if path[0] == '/'
path_words = path.split('/')
pattern_words = @pattern.split('/')
return false unless path_words.count == pattern_words.count
# 在 match 的同時紀錄在網址中的 params
@in_path_params = {}
pattern_words.zip(path_words).each do | pattern_word, path_word |
if pattern_word[0] == ':'
@in_path_params[pattern_word[1..-1]] = path_word
else
return false unless pattern_word == path_word
end
end
true
end
end
class BaseController
attr_accessor :params
def initialize(params)
@params = params
end
def self.dispatch(action, params, env)
body = self.new(params).send(action)
[200, {"Content-Type" => "text/html"}, [body]]
end
end
# 以上都是框架的部分
# 以下看起來像是正常的 rails code
class EtrexController < BaseController
def index
"etrex/index, params: #{params}"
end
def show
"etrex/show, params: #{params}"
end
end
app = Router.new
app.draw do
get 'etrex', to: 'etrex#index'
get 'etrex/:id', to: 'etrex#show'
end
run app
我做了最基礎的 Router 架構,可以把規則變成 Route 物件保存在 @routes 中,由 Route 提供 match 方法,並在 match 網址的同時擷取出網址中的參數。
同時也做了基礎的 controller 結構,確認能夠正常取得來自網址以及 query string 的參數。到這裡總算是入門囉!!
「What I cannot create, I do not understand.」 Richard Philip Feynman
👩🏫 課務小幫手:
✨ 想掌握 Ruby on Rails 觀念和原理嗎?
我們有開設 🏓 Ruby on Rails 實戰課程 課程唷 ❤️️