技術と魚

技術調査、開発TIPS、駄文

Rackコード探訪 Part.1

Railsとハイパー便利gemで大体のことが出来てしまうご時世、Rackについてはほとんど考えなくて良いと思う。 けど、ふともっと詳しくなるためにRackを知ろう、ちゃんとサーバを知ろう、なんて思うことがきっとある。

今使っているものが、どうやって成り立っているのか知る過程はとても楽しい。 色んな側面で技術やパターンが現れ、適切な目的で使われているのを知れるからだ。 それを抽象化したパターンは、普段の仕事でも効いてくる。

「分かった」時に楽しいのはシステムだけに限らず、理知的な全ての研究活動に通ずると思う。 数学、ビジネス、歴史等、何だってそうだ。100%の説明ができるようになるし、応用して価値を生み出すことも出来る。

ソフトウェアについて言えば、OSSなどの情報へのアクセス手段があれば、この活動は全て端末の前にいればかなり面白いところまで到達できる。 Rackについての理解を確実にするためにコードを読んでいたのに、最終的にCのコードに行き着いたりもする。

本コードリーディング録は、旅行の日記みたいなものですが、過程の思考を通じて技術の世界観が伝わったら面白いかなと思っております。 最後まで読まなくても、「今度自分も何か気になるものを読んでみよう」と思ってもらえれば嬉しいです。

f:id:norainu234:20190121213350p:plain


旅の始まり

ーさて、とりあえずおもむろに、githubのrack/rackをローカルにcloneし、いつもの bundle install をしよう。

大概のことは、READMEを見れば書いてある。READMEを見よう。 すると以下のコマンドでサンプルを始められると書いてある。

$ ruby -Ilib lib/rack/lobster.rb

実際やってみる。localhost:9292にpumaサーバが立ち上がった。(puma以外になることもあると思われる)

f:id:norainu234:20190121200557p:plain

疑いようもない、ロブスターが立ち上がった。

-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::ShowExceptionsRack::Lint というものがあるが、Rackという名前が表すように、このようにして機能を繋げていくことができると読み取れる。

それを確かめるにはどうするか? 試しに何かを外せばいい。

Lobsterは通常立ち上げると crash というリンクが存在する。 crashにアクセスすると、Lobster#call上のraise文にたどり着くはずだ。 そしてスタックトレースを含む内容が表示されるページに遷移する。

f:id:norainu234:20190121201506p:plain

いかにも 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オプションを指定している場合、 この serverRack::Handler.get(options[:server]) となる。

Handler.getを見よう。登録された(=Handler.register)ハンドラを見つけ出し、対応するクラスを取得する。 登録されているのは、cgi, fastcgi, webrick, lsws, scgi, thinである。

thinを指定した場合、Rack::Handler::Thin になる。つまり、server.runRack::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::Connectionreceive_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ヘッダも、送られてきたものは文字列なので同じことを行うということだ。 構文解析はコンパイラを作ったりしたことがあるなら必ず通る道なので、興味がある人は調べると良い。