Ruby用API生成フレームワーク「grape」のコードを読んでみる (前半)


仕事で使っているRuby用のAPI生成フレームワーク grape のコードを読んで見たのでメモ。

コード量が多かったので、前半(初期化処理)と 後半(リクエストの処理とレスポンス生成) に分けた。今回は、前半(初期化処理)の内容を記載。

まとめ

  • Rackアプリケーションとしてマウントされている。
  • 1回目のリクエストの処理中に、マウントしたクラス => マウントしたクラスのシングルトンなインスタンス => Grape::Router => APIごとのGrape::Router::Route => APIごとのGrape::Endpoint => マウントしたクラスに書いたAPIの処理内容(ブロック)という参照関係が出来上がる。
  • 自分にとってかなりコードの難易度の高く、読みづらいと感じた。
  • 様々な箇所でHashの破壊的な操作が行われていて、コードを変更するのに怖さを感じた。メンテナンスが大変そう。
  • コードをわかりやすく説明するのは難しい…。何本かブログを書いたら、1回整理したほうがよさそう。

詳細

非常に簡単なAPIを1つ定義し、どのように動作させているのかを把握する。
対象のバージョンは、執筆時点の最新である「v0.19.1」とする。

想定する使い方

今回は業務で使用している方法と同じく、Railsに組み込んで使用することを想定する。
{ status: "OK" } というJSONを返却するAPIを定義し、これがどのように動作するかを確認する。

まず以下のコマンドでRailsの初期化やモデルの生成等を行う。

次にGrape::APIを継承した app/apis/root.rb を生成。

最後に config/routes.rb でRootをRackアプリケーションとしてmountする。

routes.rbでmountを使うと何が起きるのか完全に把握はできていないのだが、以下の記事を見てある程度理解した。
http://qiita.com/higuma/items/838f4f58bc4a0645950a

  • / 以下のリクエストは、最終的に Root.call(env) に到達する。
  • 上記メソッドのenvはHashオブジェクトであり、リクエストに関する様々な情報が含まれている。
  • callメソッドは最終的に 「ステータスコード」「レスポンスヘッダー」「レスポンスボディ」の3つの要素を含む配列をreturnしなければいけない。

初期化時の処理

まずは最初にロードされる lib/grape.rb を見てみる。
grapeが必要とするライブラリのrequireや、自身が定義するクラスやモジュールのautoloadを登録するのみで、重要な処理は行われていない模様。

リクエスト到達時の挙動

次はリクエスト到達時に最終的に呼ばれる Root.call(env) から見てみる。
ただ Root には .call メソッドは定義されておらず、親クラスである Grape::API.call が呼び出される。

大きく分けて以下の2つの処理が行われている。

  1. @instance がnilだった場合、Root.new を呼んでインスタンスを1つ生成する。
  2. 1で生成したインスタンスに対して #call(env) を呼ぶ。

上記から、Grape::APIを継承したクラスはシングルトンなクラスと考えてよさそう。
今回は、1のRootのインスタンス生成処理をみる。2は後半で見ていく。

Rootインスタンスの生成

Root.new を呼んだ場合、最終的に Grape::API#initialize に到達するはず。
Grape::API#initialize は以下のコードになっている。

重要なコードがいくつかある。

  1. Grape::Route クラスのインスタンスを生成し、 @router に代入。
  2. self.class.endpoints.each で繰り返し処理をしている。
  3. 上記の1つ1つの要素に対して Grape::Route のインスタンスを引数に #mount_in を呼び出している。
  4. @router.compile! を実行。

それぞれを見ていく。

Grape::Routerインスタンスの生成

Grape::Route#initialize のコードは以下。いくつかのインスタンス変数の初期化をしているだけ。

self.class.endpointsの内容

self.class.endpointsRoot.endpoints と読み替える。このメソッドは lib/grape/api.rb には定義されていない。
どこに定義されているかというと Grape::DSL::API のinclude経由で Grape::DSL::Routing に定義されていた。
(こういう時、Rubyのmoduleの使い方の難しさを感じる。RubyMineのおかげでかなり楽できているが、それでもコードを追うのが難しい時もあったり)

ActiveSupport::Concern をextendしているため、 ClassMethods に定義した内容が、include先のクラス(Grape::API)にextendされる。
attr_readerendpoints が定義されていることがわかる。
@endpoints はRootクラスがrequireされた時点ではnilだが、 Grape::API.inherited(subclass) が定義されていて、ここで初期化用のresetメソッドが呼ばれている。

これにより @endpoints には空配列が入っている状態になっている。

endpointsへのインスタンスの追加

endpointsへのインスタンスの追加は、 Root クラスに書いた get 'healthcheck' do ... end で実行されている。以下が、 #get のコードである。

#get 内部は、最終的に #route を呼んでいる。
今回の場合、methodsが 'GET' 、pathsは 'healthcheck'、route_optionsはデフォルト値の {}、 ブロックに do { status: 'OK' } end が渡されている。

#route では、まず引数からHashを生成している。
次に Grape::Endpoint.new を呼び出している。ここで、#inheritable_settingGrape::DSL::Settings で追加されたメソッドで Grape::Util::InheritableSetting のインスタンスを返す。今回の場合、何も値は入っていないので、詳細は見ない。
最後に結果を @endpoints に追加している。ここでようやく @endpoints の要素が一つ追加された。

Grape::Endpointの生成

Grape::Endpoint.new のインスタンス生成処理を見てみる。

結構つらい…。熟練のRubyistの方々は、こういったコードをすらすらと読めるのだろうか?
今回の想定と関連が低い部分はばっさりカットして、主要なコードだけ見ていく。

  1. Grape::Endpoint.new をオーバーライドしている。呼び出された対象クラスによって処理が変わるが、今回のケースでは Grape::Endpoint を継承する無名クラスを作成し、このクラスの new を呼び出す。なぜこのようなことをしているかは不明。単純にGrape::Endpointのインスタンスを生成して、必要があればこのインスタンスの特異クラスに処理をすればよいのでは、と思ったが、何かこうしないといけない理由があるのだろうか?
  2. .new の呼び出しの内部で Grape::Endpoint#initialize が呼ばれる。中では色々やっているが、今回の用途で関係ありそうなのは、以下の3つ。
    1. @options にmethodsやpathsをArrayに変換して入れ直す
    2. @source に引数で渡されたブロックのProcオブジェクト自体への参照を保存
    3. self.class.generate_api_method(method_name, &block) の結果を @block に保存
  3. .generate_api_method は渡されたブロックを持つ UnboundMethod のオブジェクトを作成し、これをあとでbindして実行するためのProcオブジェクトを返す。なぜ UnboundMethod に変換するのかについては、メソッドのコメントをみる限り「ブロック内でreturnを使ってもLocalJumpErrorが起きないようにするため」と推測される(確かにブロック内でreturnを使ってもエラーにならない)。おそらく、後半で @block.call(self) と実行することで、 UnboundMethod をEndpointのインスタンスにbindして呼び出すと思われる。

Grape::EndPoint#mount_inの処理

やっと Root.endpoints に何が入っているのかを解明できたので、Grape::EndPoint#mount_in の内容を見ていく。
引数で渡されている routerGrape::Router のインスタンスである。

大まかに以下の3つの処理が実行されている。

  1. #reset_routes! でEndpoint内のルート定義に関連するインスタンス変数にnilをセット
  2. #routes でこのEndpointにセットされたルート定義一覧を取得し、ループ処理を開始
  3. ループの各要素を router#append に渡して実行( route.apply(self) はHEAD用のルート定義なのでスキップでOK )

1は、特に問題なし。
2は、追加のコードを見てみる。

結果として Grape::Router::Route の要素を1つだけ含んだ配列を返している。
最後に Grape::Router#append を呼んでいる。ここでは、「メソッド文字列」 => 「配列」という形式のインスタンス変数 @map に対応するrouteを追加している。
今回、キーは 'GET' である。

routerのcompile

最後に Grape::Router#compile! を呼んでいる。対応するコードは以下。

内容としては、 HTTPのメソッドごとに、各routeのパスの正規表現を一旦 @optimized_map に格納し、これを結合した正規表現を最終的に格納している。
これは、指定されたパスのAPIが存在するかどうかのチェックを高速に実行するために使用されると思われる。(追記、ここで名前付きのキャプチャを使用することで、チェックだけでなくどのEndpointに対応するかまでを高速に確認できるようにしている)

初期化終了後の状態

初期化が終了すると以下のような状態になっている。

  • EndPointクラスには、1つのEndPointクラスのインスタンスがセットされている。(シングルトンクラス)
  • EndPointのインスタンスの @router 変数には、コンパイル済みのGrape::Routerのオブジェクトがセットされている。
  • @router インスタンスには、「GET /healthcheck」(と「HEAD /healthcheck」)に対応する Grape::Router::Route オブジェクトが格納されている。
  • Grape::Router::Route の各インスタンスの @app 変数には Grape::Endpoint のインスタンスがセットされている。
  • Grape::Endpoint の各インスタンスの @block 変数には、EndPointクラス定義時に書いたブロックを実行するためのProcオブジェクトが格納されている。

後半 では、リクエストをどのように処理するかを見ていく。