Railsとハイパー便利gemで大体のことが出来てしまうご時世、Rackについてはほとんど考えなくて良いと思う。 けど、ふともっと詳しくなるためにRackを知ろう、ちゃんとサーバを知ろう、なんて思うことがきっとある。
今使っているものが、どうやって成り立っているのか知る過程はとても楽しい。 色んな側面で技術やパターンが現れ、適切な目的で使われているのを知れるからだ。 それを抽象化したパターンは、普段の仕事でも効いてくる。
「分かった」時に楽しいのはシステムだけに限らず、理知的な全ての研究活動に通ずると思う。 数学、ビジネス、歴史等、何だってそうだ。100%の説明ができるようになるし、応用して価値を生み出すことも出来る。
ソフトウェアについて言えば、OSSなどの情報へのアクセス手段があれば、この活動は全て端末の前にいればかなり面白いところまで到達できる。 Rackについての理解を確実にするためにコードを読んでいたのに、最終的にCのコードに行き着いたりもする。
本コードリーディング録は、旅行の日記みたいなものですが、過程の思考を通じて技術の世界観が伝わったら面白いかなと思っております。 最後まで読まなくても、「今度自分も何か気になるものを読んでみよう」と思ってもらえれば嬉しいです。
旅の始まり
ーさて、とりあえずおもむろに、githubのrack/rackをローカルにcloneし、いつもの bundle install
をしよう。
大概のことは、READMEを見れば書いてある。READMEを見よう。 すると以下のコマンドでサンプルを始められると書いてある。
$ ruby -Ilib lib/rack/lobster.rb
実際やってみる。localhost:9292にpumaサーバが立ち上がった。(puma以外になることもあると思われる)
疑いようもない、ロブスターが立ち上がった。
-I directory
は、requireで指定できるライブラリのサーチパスに指定したディレクトリをに追加している。
*1
さて、lobster.rbを覗いてみよう
LambdaLobsterという定数があるが、ここでは使われていない。次へ進もう *2
Lobsterクラスはcall
メソッドが定義されており、引数にenv
をとる。
返り値は Response#finish
の返り値である。
そして、最後に以下がある。
Rack::Server.start( app: Rack::ShowExceptions.new(Rack::Lint.new(Rack::Lobster.new)), Port: 9292 )
ここでサーバが立ち上がっているということだろう。Port指定はわかるとして、問題はappオプションに渡されているものだ
Rack::ShowExceptions.new(Rack::Lint.new(Rack::Lobster.new))
Rack::ShowExceptions
や Rack::Lint
というものがあるが、Rackという名前が表すように、このようにして機能を繋げていくことができると読み取れる。
それを確かめるにはどうするか? 試しに何かを外せばいい。
Lobsterは通常立ち上げると crash というリンクが存在する。 crashにアクセスすると、Lobster#call上のraise文にたどり着くはずだ。 そしてスタックトレースを含む内容が表示されるページに遷移する。
いかにも Rack::ShowExceptions
がやりそうな内容である。これを外してみよう。
app: Rack::Lint.new(Rack::Lobster.new)
としてcrashにアクセスする。すると、
Puma caught this error: Lobster crashed (RuntimeError) lib/rack/lobster.rb:47:in `call' :
となる。Pumaが例外を捕捉したらしい。ということはこの挙動はrackは規定していない。 試しにwebrickにしてみて違いを確認しよう。
Server.startのオプションに server: :webrick
を追加するとwebrickが立ち上がる。
app: Rack::Lint.new(Rack::Lobster.new), Port: 9292, server: :webrick
これでcrashしてみる。
Internal Server Error
表示が変わった。さらに、 :thin
を選ぶとまた異なる。
よってこの.newで包めている部分で機能を追加したりできるのだとわかる。
そもそもpumaやwebrickとrackの関係はどうなっているのだろうか?
さて、ここでRackについてわかることといえばこうだ。
- Rackはappを元にアプリケーションサーバを立ち上げている。
- appは、callメソッドを持つ必要がある。
- Rackのアプリケーションは、以下の方式で機能を積み上げる。(よって括弧の中心を除き、newはappを受け取りappを生成する必要がある。)
Xxx.new(Yyy.new(Zzz.new(...)))
次に、 call
メソッドが満たすべき要件について調査しよう。
まずは引数の env
である。 env
の正体に当たりをつけるため、色々出力をためそう。(実際にはRackのドキュメントにある程度書いてあるが..)
callメソッドの最初の行に puts env.class.name
といれることで、サーバへのアクセス時にenvのクラスがログ出力され、Hashであることがわかる。
次に、hashの中身の雰囲気を学ぼう。 env.each { |k,v| puts "#{v.class.name}\t#{k}" }
などと書いて、valueに当たる部分のクラスまで調べておこう。
- HTTP_USER_AGENTやQUERY_STRINGのようにリクエストにあたるような情報に対応しそうなキー
- rack.***やasync.***のようにドット区切りのキー
がある。そしてHTTP_USER_AGENTなど前者は全てStringで、rack.***などは、Rack::Lint::InputWrapperのような不思議なクラスもある。
したがって、恐らくHTTPリクエストから得られる情報と、付随して様々なデータがあることがわかる。これらはどこで書き込まれる・・?
さて、 Server.start
は全てのことを行っている。追っていけばどこで call
が呼ばれるのかわかるはずだ。なので軽く展開しよう。
def self.start(..) self.new(..).start end def start : server.run wrapped_app, options, &blk end
つまり、 Server#server
が返すインスタンスが run
メソッドを持っていて、そこに wrapped_app
を渡す。
wrapped_app
は、特別な場合だけミドルウェアを追加したappである。
*3
次に server
の中身を見よう。ややこしいが、optionsにserverオプションを指定している場合、
この server
は Rack::Handler.get(options[:server])
となる。
Handler.getを見よう。登録された(=Handler.register)ハンドラを見つけ出し、対応するクラスを取得する。 登録されているのは、cgi, fastcgi, webrick, lsws, scgi, thinである。
thinを指定した場合、Rack::Handler::Thin
になる。つまり、server.run
は
Rack::Handler::Thin.run
である。
そして Thin.run
がやっていることを展開するとおおよそ全て展開される。
::Thin::Server.new(host, port, app, options).start
だとわかる。ここ以降はRackではなくThinの機能ということだ。
Thin::Server#start
は要約すると、
def start backend = Backends::TcpServer.new(host, port) backend.server = app backend.start { setup_signals if @setup_signals } end
を行う。ここで Backends::TcpServer#start
はさらに
def start EventMachine.run do @signature = EventMachine.start_server(@host, @port, Connection) do |connection| : connection.app = @server.app : end binary_name = EventMachine.get_sockname( @signature ) port_name = Socket.unpack_sockaddr_in( binary_name ) @port = port_name[0] @host = port_name[1] @signature end end
といったことを実行している。EventMachineとはなにか。
今欲しいのは .call
を実行している箇所である。connection.appに対しappを格納していることや、 Connection
クラスを EventMachine.start_server
に渡していることから、
Thin::Connection
が関係していると思われる。そこで Thin::Connection
を見る。これは EventMachine::Connection
のサブクラスである。
EventMachineについては2つだけ見ておこう。以下はドキュメントを雑に和訳している。
Connection#receive_data
はネットワークコネクションからデータを受け取った際に実行される。受け取ったバイナリ文字列を引数とする。サブクラスはこれをオーバーライドして挙動を実装すること。Connection#send_data
はネットワークコネクションにデータを送信する際に使用する。受け取ったバイナリ文字列を引数とする。
サブクラスである Thin::Connection
は receive_data
メソッドをオーバーライドしているはずである。
Thin::Connection#receive_data
の挙動は要約すると次の通り:
def receive_data(data) if @request.parse(data) result = @app.call(@request.env) @response.status, @response.headers, @response.body = *result @response.each do |chunk| send_data chunk end end rescue Exception => e unexpected_error(e) close_connection end
よって .call
に渡されるべき対象は Thin::Request#env
となることがわかる。
また .call
返り値の展開からstatus, headers, bodyの順の配列を想定していると見て取れる。
@request.parse
の返り値がtruthyでなければsend_dataしていない。これはどういう意味か。
まず Thin::Request#parse
および Thin::Request#env
を見てみる。
envについてはattr_readerなので、最終的に @env
に対してparseの中で何が構成されるかさえ見れば良い。
parseメソッドは以下のようになっている。
def parse(data) if @parser.finished? # Header finished, can only be some more body @body << data else # Parse more header using the super parser @data << data @nparsed = @parser.execute(@env, @data, @nparsed) end if finished? # Check if header and body are complete @data = nil true # Request is fully parsed else false # Not finished, need more data end end
先に返り値を見よう。 *4
finished?かどうかを返している。前のreceive_dataで if @request.parse(data)
としているので、
finished?であればレスポンスデータを送信しているといえる。
finished?とは何か、見に行くと、 @parser.finished? && @body.size >= content_length
である。
@parserが終了しておらず、bodyの長さがcontent-length以上ではない、というケースがあるということは、
おそらくparseは適当なチャンクごとに分割してに実行されるということだとわかる。
EventMachine::Connection#receive_data` のドキュメントを読むと、「プロトコルやバッファサイズ、OSに依存して、不完全なメッセージでありうる」とかいてある。
まあ考えてみればそりゃそうだ。ファイルアップロードのように2GBのリクエストがきたら大変なことになる。
さて、@envに書き込んでいそうなところというと、
@parser.execute(@env, @data, @nparsed)
だとわかる。 @parserは、initializeメソッドで初期化されていて Thin::HttpParser
である。
ではそのソースはどこか。おや?rbファイルがない!
それらしいのを探すと ext/thin_parser/*
というディレクトリがある。ここを見ると、Cのファイルなどがある。
Thin::HttpParser
はCで書かれている。この辺のコメントを読むとわかるが、これは実際にはMongrel)を少し改変したものである。
thin.c
の最後の方を見る。それっぽいコードがある。
void Init_thin_parser() { : cHttpParser = rb_define_class_under(mThin, "HttpParser", rb_cObject); rb_define_alloc_func(cHttpParser, Thin_HttpParser_alloc); : rb_define_method(cHttpParser, "execute", Thin_HttpParser_execute,3); rb_define_method(cHttpParser, "finished?", Thin_HttpParser_is_finished,0); : }
さて、RubyでCを動かす際のルールはどこを見ればよいか。おもむろに調べると、次が見つかる. https://silverhammermba.github.io/emberb/extend/
ビルド方法についてはスキップするとして、Initの章をみる。
requireでは、Init_foobar
が呼ばれるのだ。
だから、require時は上記のコードが呼ばれ、この中でexecuteメソッドが定義される。
rb_define_method(cHttpParser, "execute", Thin_HttpParser_execute,3);
はおそらく、 Thin::HttpParser#execute
メソッドを定義し、その実体を Thin_Http_Parser_execute
とするということである。
なるほど、ではこの関数を見に行けば良い。だいたいこんな感じ:
VALUE Thin_HttpParser_execute(VALUE self, VALUE req_hash, VALUE data, VALUE start) { http_parser *http = NULL; DATA_GET(self, http_parser, http); : from = FIX2INT(start); dptr = RSTRING_PTR(data); dlen = RSTRING_LEN(data); : http->data = (void *)req_hash; thin_http_parser_execute(http, dptr, dlen, from); : }
selfはインスタンス自身、req_hashは変数名からして第一引数の @env
だとわかる。
DATA_GETでhttp_parserのポインタを取得していて、http->dataに対してreq_hashを代入している。
この中で、 thin_http_parser_execute
を実行している。この実体は parser.cにある。
parser.cを読みに行くと、謎のcase, switch, gotoおよびラベルが存在し、到底読解不能なので、機械的に生成されたファイルだと予想できる。そしてパーサなので、パーサジェネレータで文法ファイルから作ったのだと分かる。 *5
実際、parser.rlやparser_common.rlというそれらしいファイルがあるので、そこから作るのだろう。
作り方は今はどうでも良い。今は http_parser->data に対する操作を見つければ良い。 調べると、次のようなものが出てくる: common.rl 中身はこんな感じ:
: http_number = ( digit+ "." digit+ ) ; HTTP_Version = ( "HTTP/" http_number ) >mark %http_version ; Request_Line = ( Method " " Request_URI ("#" Fragment){0,1} " " HTTP_Version CRLF ) ; field_name = ( token -- ":" )+ >start_field %write_field; field_value = any* >start_value %write_value; message_header = field_name ":" " "* field_value :> CRLF; Request = Request_Line ( message_header )* ( CRLF @done );
これがリクエストのbodyより前の部分を表すBackus-naur Formであるとおおよそイメージできる。文法のほか、それがマッチした際に実行するべきアクションが > や % で指定されていると予想できる。
さて、 parser.rlには
action mark {MARK(mark, fpc); } : action start_value { MARK(mark, fpc); } action write_field { parser->field_len = LEN(field_start, fpc); } action write_value { if (parser->http_field != NULL) { parser->http_field(parser->data, PTR_TO(field_start), parser->field_len, PTR_TO(mark), LEN(mark, fpc)); } } :
とあり、アクションの中身が書いてある。ヘッダを一行読み込む際は、 write_value
によってファイナライズされているようである。
では、 parser->http_field
を見れば良さそうだ。それがこれだ:
static void http_field(void *data, const char *field, size_t flen, const char *value, size_t vlen) { char *ch, *end; VALUE req = (VALUE)data; VALUE v = Qnil; VALUE f = Qnil; VALIDATE_MAX_LENGTH(flen, FIELD_NAME); VALIDATE_MAX_LENGTH(vlen, FIELD_VALUE); v = rb_str_new(value, vlen); f = rb_str_dup(global_http_prefix); f = rb_str_buf_cat(f, field, flen); for(ch = RSTRING_PTR(f) + RSTRING_LEN(global_http_prefix), end = RSTRING_PTR(f) + RSTRING_LEN(f); ch < end; ch++) { if (*ch >= 'a' && *ch <= 'z') { *ch &= ~0x20; // upcase } else if (*ch == '-') { *ch = '_'; } } rb_hash_aset(req, f, v); }
最後のfor文で分かる通り、キーはまず、"HTTP_" というプレフィックスをつけ、大文字化と"-"から"_"に変換して正規化した後、 rb_hash_aset
で@envに書き込まれている。
以上のことをまとめると、ヘッダをパースしている時に、@envにキーを正規化して追加しているとわかる。
例えば X-CSRF-Token: HOGEHOGE
というのがあれば @env['HTTP_X_CSRF_TOKEN'] = 'HOGEHOGE'
となっているということである。
検証するためにlobsterのcallメソッドの直下に puts env.keys
として起動した上で
curl localhost:9292 -H 'X-Sample-Header: hello'
としてみる。すると、ログに HTTP_X_SAMPLE_HEADER が現れる。
探検は終わりそうもない。日帰り予定なので、この辺で帰ってくることにする。
Rackで行われることの全体感が大体わかり、更にいくつかの副産物が得られた。
- Rackの基本
- Rack自体はHTTPサーバの基盤処理を行わず、puma, webrick, thinのような他のものと協働する
- Rackは、ミドルウェアのスタックによるインターフェースを提供しアプリケーションおよびそのプラグインのためのベースとなるフレームワークを提供する
- callが実際に行われる流れ
Rack::Handler.get
のように、文字列引数からクラスを取得するデザインパターン- 高速に処理したい部分でCのプログラムに橋渡しするスタイル
- 久しぶりにCを読むことで脳トレ
みなさんも良い探検を!
*1:要するにlib以下をrequireで取れるようにするために書いている。
これがないとうまくいかないのかと言うと、サーチパスの他の場所にrackがいれば、それを見ることが出来るので無くても立ち上がってしまうかもしれない。 サーチパスはruby内で$: もしくは$LOAD_PATHで確認できる。 -e引数を使えばワンライナーでruby -e 'puts $:'と書いて確認できる。 つまり、-Ilib無しで、もし立ち上がってしまったのなら、cloneしたファイルを読んでいない。本当にそうだろうか、確認するにはどうする?
*2:なのに何故あるのか。答えはgit grep LambdaLobsterを調べてみよう。
LambdaLobsterは、Proc(env: Hash? -> Array<Integer, Hash, String>)という型のように読み取れる。 見た感じだと 返り値はレスポンスを表している: [ステータスコード, ヘッダ, body] だろう。 spec_lobster.rbにRack::MockRequest.new Rack::Lint.new(Rack::Lobster::LambdaLobster) という形のテストケースがある。 Procであってもrackのスタックに追加できるということを検証するためにLambdaLobsterが存在している。 Procオブジェクトはcallで呼び出すことが出来る!
*3:この文脈からおそらくミドルウェアとは、appを用いてappを構成するもの、つまり、Rack::ShowExceptionsやRack::Lintのことである。
wrapped_appを追うと、 environmentオプションに応じて、ミドルウェアと呼ばれるものを繋げている。 wrapするミドルウェアに[A, B, C]が見つかれば、A.new(B.new(C.new(app)))にしてくれる。 この仕組により、 environment: 'development' などとすれば、Rack::ShowExceptionsやRack::Lintは自動的に入るようになっている。 それを確認するには・・?
*4:メソッドの意味を掴みたい時は、下から読むのがおすすめ。
*5:パーサ(構文解析器)は普通、パーサジェネレータというものを使って文法ファイルから生成する。
プログラミング言語も同様だが、特定の文法に沿った記号列を読み込む時は、字句解析、構文解析の順で行う。 HTTPヘッダも、送られてきたものは文字列なので同じことを行うということだ。 構文解析はコンパイラを作ったりしたことがあるなら必ず通る道なので、興味がある人は調べると良い。