最近、社内で Rails 関係のコードを読む会を持ち回りでやっていて、今週は僕が担当だったのだけど、Rack をもうちょっとちゃんと知りたかったので、Rack 対応の軽量サーバーである WEBrick を読んだ。pryで実行しながら読んだのだけど、流れを整理しておく。
TL; DR
具体的には、公式サイトのトップページに書かれた以下がどのように立ち上がりリクエストを処理するかを追った。
require 'rack'
app = Proc.new do |env|
['200', {'Content-Type' => 'text/html'}, ['A barebones rack app.']]
end
Rack::Handler::WEBrick.run app
ファイルで言うと以下。
<gems directory>/rack-1.6.4/lib/rack/handler/webrick.rb
<ruby lib directory>/webrick/server.rb
<ruby lib directory>/webrick/httpserver.rb
最後に、Railsの場合を簡単に見る。
Rack とは?
前提となる予備知識。Rack については公式サイト のトップページに非常に完結にまとめられていて、Ruby のサーバーとフレームワークのインターフェースであると書いてある。
Rack provides a minimal interface between webservers that support Ruby and Ruby frameworks.
より具体的に言うと、次のような振る舞いをするオブジェクトは Rack アプリであると言え、Rack 対応のサーバーと連携することができる。
-
call
メソッドに反応すること - 引数としてハッシュを受け取ること
- 返り値として3要素の配列を返すこと
- 第1要素:HTTPステータスコード
- 第2要素:HTTPレスポンスのヘッダーに相当するハッシュ
- 第3要素:HTTPレスポンスのボディに対応するeachメソッドに反応するオブジェクト
先ほどの例に戻ると、Procオブジェクトはcallで反応するので、ミニマムなRackアプリを作って渡しているということが分かる。
require 'rack'
app = Proc.new do |env|
['200', {'Content-Type' => 'text/html'}, ['A barebones rack app.']]
end
Rack::Handler::WEBrick.run app
Rack::Handler::Webrick.run(app, options)
rack gem に入っているこのクラスは、この場合は以下を行う。
options には {BindAddress: 'localhost', Port: 8080}
がデフォルトで入る。
@server = ::WEBrick::HTTPServer.new(options)
@server.mount "/", Rack::Handler::WEBrick, app
@server.start
これを1行ずつ読んでいく。
WEBrick::HTTPServer.new(config={}, default=Config::HTTP)
WEBrick::HTTPServer は WEBrick::GenericServer を継承していて、initialize では最初に super(config, default)
によって上位メソッドを呼び出してる。
WEBrick::GenericsServer
重要な部分を抜き出すと以下。
@tokens = SizedQueue.new(@config[:MaxClients])
@config[:MaxClients].times{ @tokens.push(nil) }
@listeners = []
listen(@config[:BindAddress], @config[:Port])
前半では、最大クライアント数が決まっていて、その大きさで初期化され nil
が詰まった固定長のキューが@tokens
として用意されている。SizedQueueはスレッドセーフに実装されたRubyの組み込みクラス。
後半では、最初に設定したアドレスとポートで listen するソケットが@listeners
に用意されている。
WEBrick::HTTPServer
こちらの主な仕事はマウントテーブルを作成すること。
@mount_tab = MountTable.new
サーバーの初期化はこれで終了。Handler::WEBrick.run の次の行を追う。
HTTPServer#mount(dir, servlet, *options)
@mount_tab[dir] = [ servlet, options ]
引数を当てはめると、次のようになる。ここでは、Rack::Handler::WEBrickがサーブレットで、Rackアプリに相当するProcはそのオプションとなっている1。
@mount_tab['/'] = [ Rack::Handler::WEBrick, [app]]
ここまでで準備終了。サーバーを起動する。
GenericsServer#start(&block)
最後のstartは親クラスのGenericsServerが用意している。今回はブロックは渡していない。configに色々渡せるようになっていたり、起動・終了でコールバックが仕掛けられるようになっていたりするが、骨格は次のようになっている。
thgroup = ThreadGroup.new
@status = :Running
while @status == :Running
if svrs = IO.select(@listeners, nil, nil, 2.0)
svrs[0].each{|svr|
@tokens.pop # blocks while no token is there.
if sock = accept_client(svr)
th = start_thread(sock, &block)
thgroup.add(th)
else
@tokens.push(nil)
end
}
end
end
end
IO.selectはIO多重化のためのシステムコールをラップする関数。この場合、渡している@listeners
のいずれかが読む込み可能な状態になるまでブロックし、可能になれば@listeners
のうち読み込み可能になったもの(配列)を第一要素とする配列svrsを返す。accept_clientによってアクセスのあったクライアントとサーバーが接続されたソケットを返し、それを引数にstart_threadでスレッドが起動する。
GenericsServer#start_thread(sock, &block)
Thread.start{
begin
run(sock)
ensure
@tokens.push(nil)
end
}
さっきから出てくる@tokens
は何に使われているかと言うと、生成するスレッド及び同時に受け付けるクライアントの数を制限している。キューには最初N個の要素が入っていて、クライアントをひとつ受け付けるごとに要素をひとつ減らす。SizedQueue#pop はキューが空の場合、ブロックするようになっているので、N個クライアントを受け付るとブロックする。スレッドが終了時に @tokens
に対して nil を push することで席を一つ空ける。
HTTPServer#run(sock)
ここはコールバックを呼んだりアクセスログを残したり、タイムアウトした場合には EOFError をレスポンスに設定して返したりと色々やっているが、それを除くと次のようになっている。
while true
begin
res = HTTPResponse.new(@config)
req = HTTPRequest.new(@config)
server = self
req.parse(sock)
server.service(req, res)
ensure
res.send_response(sock)
end
break unless req.keep_alive?
end
リクエストオブジェクトとレスポンスオブジェクトを生成し、リクエストオブジェクトがソケットから読み込む。両方をserviceに渡し、終わった結果をレスポンスオブジェクトがソケットに送信する。ループすることで、複数リクエストを一回のTCPソケットで処理するHTTP1.1の機能 keep_alive を実現している。
HTTPServer#service(req, res)
servlet, options, script_name, path_info = search_servlet(req.path)
si = servlet.get_instance(self, *options)
si.service(req, res)
ここでは、リクエストされたパスに対応するサーブレットをマウントテーブルから探し出し、そのインスタンスに対して処理を委譲している。
servletはRack::Handler::WEBrickで、optionsは最初に作ったProcオブジェクトを包む配列になっている。get_instanceはこのservletの場合、newを呼び出しててインスタンスをするだけ。この時、Rackアプリに相当するProcオブジェクトはインスタンス変数 @app
に割り当てられる。
Rack::Handler::WEBrick.service(req, res)
RackインターフェースをWEBrickに適合させる部分。3つの部分に分ける。
リクエストオブジェクトからenvへの変換
env = req.meta_vars
env.delete_if { |k, v| v.nil? }
rack_input = StringIO.new(req.body.to_s)
rack_input.set_encoding(Encoding::BINARY) if rack_input.respond_to?(:set_encoding)
env.update({"rack.version" => Rack::VERSION,
"rack.input" => rack_input,
"rack.errors" => $stderr,
"rack.multithread" => true,
"rack.multiprocess" => false,
"rack.run_once" => false,
"rack.url_scheme" => ["yes", "on", "1"].include?(ENV["HTTPS"]) ? "https" : "http"
})
env["HTTP_VERSION"] ||= env["SERVER_PROTOCOL"]
env["QUERY_STRING"] ||= ""
unless env["PATH_INFO"] == ""
path, n = req.request_uri.path, env["SCRIPT_NAME"].length
env["PATH_INFO"] = path[n, path.length-n]
end
env["REQUEST_PATH"] ||= [env["SCRIPT_NAME"], env["PATH_INFO"]].join
Rackプロトコルが要求している値を準備している。大きく、リクエストパスやリクエストメソッドなど処理のために必要な情報と、rackの情報に分かれてる。
Rackアプリの呼び出し
status, headers, body = @app.call(env)
シンプルにRackアプリを呼んでいる。
結果のレスポンスオブジェクトへの変換
begin
res.status = status.to_i
headers.each { |k, vs|
if k.downcase == "set-cookie"
res.cookies.concat vs.split("\n")
else
# Since WEBrick won't accept repeated headers,
# merge the values per RFC 1945 section 4.2.
res[k] = vs.split("\n").join(", ")
end
}
body.each { |part|
res.body << part
}
ensure
body.close if body.respond_to? :close
end
ステータスコード、ヘッダー、ボディをそれぞれレスポンスオブジェクトに詰めている。heaersのコメントは、Rackの仕様としては改行で句切られた多値も受け付けるんだけどWEBrickは対応してないので1行にするというようなことが書かれている。cookieは例外的に対応しているようだ。bodyはeachを使っているので、eachを実装しているオブジェクトであれば例えばファイルでも良いということが分かる。closeするのも仕様なので安心して使える。
おまけ:結局 Rails ではどうなのか
ここまででまだ rackup コマンドとそこで使えるDSLの動きが見れていないので情報としては足りないのだけど、$ RACK_HANDLER=webrick rackup config.ru
でRailsアプリケーションがWEBrickで起動できることを合わせて見てみると、Railsの初期化プロセスが分かりやすく書かれていることが分かる。
# config.ru
require ::File.expand_path('../config/environment', __FILE__)
run Huntr::Application # generated by `$ rails new huntr`
Handlerとして誰がrunするかをコードに書かなくて良く実行時に選択できるのがこのDSLの一つの利点であることが分かる。
Huntr::Application がどこから来たかと言うと、
# config/environment.rb
require File.expand_path('../application', __FILE__)
Huntr::Application.initialize!
最初に environment.rb を読み込んで、
# config/application.rb
require File.expand_path('../boot', __FILE__)
require 'rails/all'
Bundler.require(*Rails.groups)
module Huntr
class Application < Rails::Application
…
end
end
application.rb がgemを読み込んだ上で Huntr::Application
を用意していることが分かる。
その他参考になる解説
Rack Middleware や rackup コマンドなども含めた話は次の記事が要点を得ていて分かりやすい。
また、なぜつくったかは作者である Christian Neukirchen の次の記事に短くまとまっている。
-
ここにHandlerが入るのは最初不思議に思ったが、次のように解釈した。サーブレットはリクエストに対して起動される処理のことを意味する。Rack::Handler::WEBrickはrun(app, options)を実装しているRackハンドラであり、後で見るようにそのインターフェースに合わせるための変換処理を行うが、WEBrickサーバーの立場からはその処理も含めてサーブレットになる。 ↩