59
Help us understand the problem. What are the problem?

More than 5 years have passed since last update.

posted at

updated at

Organization

Rack を読む / WEBrick が起動するまで

最近、社内で 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 コマンドなども含めた話は次の記事が要点を得ていて分かりやすい。

Rackとは何か - Qiita

また、なぜつくったかは作者である Christian Neukirchen の次の記事に短くまとまっている。

chris blogs: Introducing Rack


  1. ここにHandlerが入るのは最初不思議に思ったが、次のように解釈した。サーブレットはリクエストに対して起動される処理のことを意味する。Rack::Handler::WEBrickはrun(app, options)を実装しているRackハンドラであり、後で見るようにそのインターフェースに合わせるための変換処理を行うが、WEBrickサーバーの立場からはその処理も含めてサーブレットになる。 

Register as a new user and use Qiita more conveniently

  1. You can follow users and tags
  2. you can stock useful information
  3. You can make editorial suggestions for articles
What you can do with signing up
59
Help us understand the problem. What are the problem?