Rodaのコードリーディングをしました。バージョンは3.8.0です。
以下のconfig.ruで追ってみます。
require "roda"
class App < Roda
route do |r|
r.root do
r.redirect '/hello'
end
r.on 'hello' do
@greeting = 'hey!'
r.get 'world' do
"#{@greeting} world"
end
r.is do
r.get do
"#{@greeting}"
end
r.post do
puts "someone said #{@greeting}"
r.redirect
end
end
end
end
end
run App.freeze.app
RodaクラスはRodaPlugins::Base::ClassMethodsをextendsしてクラスメソッドを定義して、#pluginメソッドを呼び出します。
class Roda
# ...
extend RodaPlugins::Base::ClassMethods
plugin RodaPlugins::Base
end
#pluginは以下のクラスに対して各モジュールをinclude/extendしています。
- Roda
- RodaPlugins::InstanceMethodsをinclude
- RodaPlugins::ClassMethodsをextend
- RodaPlugins::Base::RodaRequest
- RodaPlugins::Base::RequestMethodsをinclude
- RodaPlugins::Base::RequestClassMethodsをextend
- RodaPlugins::Base::RodaResponse
- RodaPlugins::Base::ResponseMethodsをinclude
- RodaPlugins::Base::ResponseClassMethodsをextend
module Base
# Class methods for the Roda class.
module ClassMethods
def plugin(plugin, *args, &block)
raise RodaError, "Cannot add a plugin to a frozen Roda class" if frozen?
plugin = RodaPlugins.load_plugin(plugin) if plugin.is_a?(Symbol)
plugin.load_dependencies(self, *args, &block) if plugin.respond_to?(:load_dependencies)
include(plugin::InstanceMethods) if defined?(plugin::InstanceMethods)
extend(plugin::ClassMethods) if defined?(plugin::ClassMethods)
self::RodaRequest.send(:include, plugin::RequestMethods) if defined?(plugin::RequestMethods)
self::RodaRequest.extend(plugin::RequestClassMethods) if defined?(plugin::RequestClassMethods)
self::RodaResponse.send(:include, plugin::ResponseMethods) if defined?(plugin::ResponseMethods)
self::RodaResponse.extend(plugin::ResponseClassMethods) if defined?(plugin::ResponseClassMethods)
plugin.configure(self, *args, &block) if plugin.respond_to?(:configure)
nil
end
クラスメソッドのrouteはRodaPlugins::ClassMethodsのメソッドで以下のように定義されています。
def route(&block)
@route_block = block
build_rack_app
end
#build_rack_appはlambdaを使って#callを持つRackアプリを作ります。ミドルウェアが定義されている場合は逆順に元のRackアプリをラップしていきます。app.call(env)の呼び出しで、Roda.new(env).call(routeのblock)
が呼ばれます。
def build_rack_app
if block = @route_block
app = lambda{|env| new(env).call(&block)}
@middleware.reverse_each do |args, bl|
mid, *args = args
app = mid.new(app, *args, &bl)
app.freeze if opts[:freeze_middleware]
end
@app = app
end
end
Rodaのコンストラクタや#callはInstanceMethodsに定義されています。RodaRequestはRack::Requestを継承しています。
def initialize(env)
klass = self.class
@_request = klass::RodaRequest.new(self, env)
@_response = klass::RodaResponse.new
end
def call(&block)
catch(:halt) do
r = @_request
r.block_result(instance_exec(r, &block))
@_response.finish
end
end
#callはcatch(:halt)で大域脱出の経路を確保しつつ、blockの中身をRodaRequestのコンテキストで実行します。
RodaRequest#root(RequestMethods#root)は以下のように定義されておりENV["PATH_INFO"]
が”/“と一致するGETリクエストであれば#alwaysが実行されます。
def root(&block)
if remaining_path == "/" && is_get?
always(&block)
end
end
#alwaysは渡されたブロックを実行した結果を#block_resultに渡して、throw :haltによってcallのcatch(:halt)まで抜けます。
def always
block_result(yield)
throw :halt, response.finish
end
RodaResponse#finishは以下のように@statusや@bodyからRackレスポンスを返します。
def finish
b = @body
empty = b.empty?
s = (@status ||= empty ? 404 : default_status)
set_default_headers
h = @headers
if empty && (s == 304 || s == 204 || s == 205 || (s >= 100 && s <= 199))
h.delete("Content-Type")
else
h["Content-Length"] ||= @length.to_s
end
[s, h, b]
end
#block_resultはRodaResponse#writeを呼び出してレスポンスのデータを書き出します。
module RequestMethods
def block_result(result)
res = response
if res.empty? && (body = block_result_body(result))
res.write(body)
end
end
def response
@scope.response
end
def block_result_body(result)
case result
when String
result
when nil, false
# nothing
else
raise RodaError, "unsupported block result: #{result.inspect}"
end
end
module ResponseMethods
def write(str)
s = str.to_s
@length += s.bytesize
@body << s
nil
end
RodaRequest#redirect(RequestMethods#redirect)は以下のように定義されており、RodaResponse#redirectを呼び出してthrow :haltします。
def redirect(path=default_redirect_path, status=default_redirect_status)
response.redirect(path, status)
throw :halt, response.finish
end
module ResponseMethods
def redirect(path, status = 302)
@headers["Location"] = path
@status = status
nil
end
RodaRequest#onは以下のように定義されており、引数指定されていない場合は#always、引数指定されている場合は#if_matchを呼び出します。
def on(*args, &block)
if args.empty?
always(&block)
else
if_match(args, &block)
end
end
引数指定されているケースを追っていきます。
#if_match
は#match_allでパスがマッチした場合は#block_resultを呼び出して引数のブロックを評価します。
def if_match(args)
path = @remaining_path
@captures.clear
if match_all(args)
block_result(yield(*captures))
throw :halt, response.finish
else
@remaining_path = path
false
end
end
#getはGETリクエストの場合に#_verbメソッドを呼び出します。引数があればTERM(=Object.newのユニークな定数)を引数に追加してから#if_matchを呼び出します。TERMは /hoge/:id
と/hoge
を区別するための番兵オブジェクトでパスの終端を意味しています。
def get(*args, &block)
_verb(args, &block) if is_get?
end
def is_get?
@env["REQUEST_METHOD"] == 'GET'
end
def _verb(args, &block)
if args.empty?
always(&block)
else
args << TERM
if_match(args, &block)
end
end
#isは引数が無く評価するパスが無い場合は#always、引数がある場合はTERMを番兵として追加して#if_matchを評価します。
def is(*args, &block)
if args.empty?
if empty_path?
always(&block)
end
else
args << TERM
if_match(args, &block)
end
end
最後に/hoge/:id
などのパラメータがある場合の処理について見ていきます。
config.ruはこんな感じになります。
class App < Roda
route do |r|
r.get "hoge", Integer do |id|
id.to_s
end
# ...
RodaRequest#getには2つ引数が渡ります。再掲すると以下のような処理になっていて、引数があるのでargsには [‘hoge’, Integer, TERM]
の配列が入り、#if_matchが呼び出されます。
def get(*args, &block)
_verb(args, &block) if is_get?
end
def _verb(args, &block)
if args.empty?
always(&block)
else
args << TERM
if_match(args, &block)
end
end
#if_matchはargsを引数に#match_allを呼びだします。#match_allはargsの各々の要素に対して#matchを呼び出します。
def match(matcher)
case matcher
when String
_match_string(matcher)
when Class
_match_class(matcher)
when TERM
empty_path?
# ...
when true, false, nil
matcher
else
unsupported_matcher(matcher)
end
end
‘hoge’はStringクラスなので#_match_stringが呼ばれます
def _match_string(str)
rp = @remaining_path
if rp.start_with?("/#{str}")
last = str.length + 1
case rp[last]
when "/"
@remaining_path = rp[last, rp.length]
when nil
@remaining_path = ""
end
end
end
リクエストパス(正確にはネストした先のremaining_path)が”/hoge”から始まっていれば@remaing_pathに残りのパスを入れます。
IntegerはClassクラスなので#_match_classが呼ばれます。Integerの場合はさらに#_match_class_Integerが呼ばれます。
def _match_class(klass)
meth = :"_match_class_#{klass}"
if respond_to?(meth, true)
# Allow calling private methods, as match methods are generally private
send(meth)
else
unsupported_matcher(klass)
end
end
# Match integer segment, and yield resulting value as an
# integer.
def _match_class_Integer
consume(/\A\/(\d+)(?=\/|\z)/){|i| [i.to_i]}
end
#_match_class_IntegerはIntegerに対する正規表現を生成して、それを引数に#consumeメソッドを呼び出します。
def consume(pattern)
if matchdata = remaining_path.match(pattern)
@remaining_path = matchdata.post_match
captures = matchdata.captures
captures = yield(*captures) if block_given?
@captures.concat(captures)
end
end
remaining_pathがパターンにマッチした場合はpost_matchした部分をremaining_pathに入れてから、@capturesにマッチしたパラメータをセットします。
この@capturesはmatch_allのif文内のyieldの引数として指定されます。yieldされるブロックは#getで指定されたブロックになります。
def if_match(args)
path = @remaining_path
@captures.clear
if match_all(args)
block_result(yield(*captures))
throw :halt, response.finish
else
# ...
こうしてパスから正規表現を使ってパラメータを抽出してブロックの引数としてセットしています。